852 lines
30 KiB
TypeScript
852 lines
30 KiB
TypeScript
import {
|
||
bigint,
|
||
boolean,
|
||
char,
|
||
date,
|
||
doublePrecision,
|
||
index,
|
||
integer,
|
||
jsonb,
|
||
primaryKey,
|
||
text
|
||
} from 'drizzle-orm/pg-core';
|
||
import { statsSchema } from './schemas';
|
||
import { timestamptz } from './helpers';
|
||
|
||
// Public /stats section: open statistics from official sources (CBS, Eurostat, FRED, BIS, …).
|
||
// Two layers (see docs/stats.md):
|
||
// - raw tables per source (`cbs_*`, `eurostat_*`, `fred_*`, `bis_*`), keeping the source's own codes;
|
||
// - a normalised layer (`source`, `series`, `obs`, `region`) that every chart reads.
|
||
// Written only by the stats collector (scripts/stats-fetch.ts, systemd user timers on the host);
|
||
// every run is logged in `fetch_run`. Everything here is open data, so personal_ro may read it.
|
||
|
||
export const statsSource = statsSchema.table('source', {
|
||
sourceId: text('source_id').primaryKey(),
|
||
name: text('name').notNull(),
|
||
baseUrl: text('base_url').notNull(),
|
||
licence: text('licence').notNull(),
|
||
keyRequired: boolean('key_required').notNull().default(false),
|
||
/** Footer line under every chart that uses this source. */
|
||
attribution: text('attribution').notNull()
|
||
});
|
||
|
||
export const statsRegion = statsSchema.table('region', {
|
||
/** ISO3 for countries, 'EU27' / 'EA' / 'WLD' aggregates, CBS codes (GM0513, CR28) below that. */
|
||
regionCode: text('region_code').primaryKey(),
|
||
name: text('name').notNull(),
|
||
/** country, municipality, corop, aggregate */
|
||
kind: text('kind').notNull(),
|
||
parent: text('parent')
|
||
});
|
||
|
||
export const statsSeries = statsSchema.table(
|
||
'series',
|
||
{
|
||
/** Stable id, e.g. `cbs:86141NED:cpi-yoy`, `fred:DCOILBRENTEU`, `bis:XM`. */
|
||
seriesId: text('series_id').primaryKey(),
|
||
sourceId: text('source_id')
|
||
.notNull()
|
||
.references(() => statsSource.sourceId),
|
||
/** The source's own code (table + measure + dimension keys, FRED id, …). */
|
||
sourceCode: text('source_code').notNull(),
|
||
title: text('title').notNull(),
|
||
unit: text('unit').notNull(),
|
||
/** D, M, Q, S (semester) or A */
|
||
freq: char('freq', { length: 1 }).notNull(),
|
||
regionCode: text('region_code').references(() => statsRegion.regionCode),
|
||
/** economy, energy, gouda, budget, countries, … */
|
||
theme: text('theme').notNull(),
|
||
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({})
|
||
},
|
||
(t) => [index('series_theme_idx').on(t.theme), index('series_source_idx').on(t.sourceId)]
|
||
);
|
||
|
||
export const statsObs = statsSchema.table(
|
||
'obs',
|
||
{
|
||
seriesId: text('series_id')
|
||
.notNull()
|
||
.references(() => statsSeries.seriesId, { onDelete: 'cascade' }),
|
||
/** Period start date: 2026-S1 → 2026-01-01, 2026Q3 → 2026-07-01, 2026 → 2026-01-01. */
|
||
period: date('period', { mode: 'string' }).notNull(),
|
||
value: doublePrecision('value'),
|
||
/** Source status flag (provisional, estimated, …) where given. */
|
||
status: text('status'),
|
||
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.seriesId, t.period] })]
|
||
);
|
||
|
||
export const statsFetchRun = statsSchema.table(
|
||
'fetch_run',
|
||
{
|
||
runId: integer('run_id').primaryKey().generatedAlwaysAsIdentity(),
|
||
sourceId: text('source_id')
|
||
.notNull()
|
||
.references(() => statsSource.sourceId),
|
||
/** What the run did: 'refresh', 'backfill', or a single table/dataset code. */
|
||
job: text('job').notNull().default('refresh'),
|
||
startedAt: timestamptz('started_at').notNull().defaultNow(),
|
||
finishedAt: timestamptz('finished_at'),
|
||
rows: integer('rows'),
|
||
ok: boolean('ok'),
|
||
error: text('error')
|
||
},
|
||
(t) => [index('fetch_run_source_idx').on(t.sourceId, t.startedAt)]
|
||
);
|
||
|
||
// ── A1 CBS StatLine (OData v4, long format) ───────────────────────────────────────────────
|
||
|
||
export const statsCbsTable = statsSchema.table('cbs_table', {
|
||
tableId: text('table_id').primaryKey(),
|
||
title: text('title').notNull(),
|
||
/** `Modified` from the table's Properties; the nightly run re-pulls only when it changes. */
|
||
modified: timestamptz('modified'),
|
||
status: text('status'),
|
||
/** OData $filter used for big tables (regions of interest only); null = full table. */
|
||
filter: text('filter'),
|
||
obsCount: integer('obs_count'),
|
||
fetchedAt: timestamptz('fetched_at')
|
||
});
|
||
|
||
export const statsCbsCode = statsSchema.table(
|
||
'cbs_code',
|
||
{
|
||
tableId: text('table_id').notNull(),
|
||
/** 'Measure' for MeasureCodes, otherwise the dimension identifier (RegioS, Perioden, …). */
|
||
dimension: text('dimension').notNull(),
|
||
code: text('code').notNull(),
|
||
title: text('title').notNull(),
|
||
unit: text('unit'),
|
||
decimals: integer('decimals'),
|
||
groupId: text('group_id'),
|
||
sortIndex: integer('sort_index')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.tableId, t.dimension, t.code] })]
|
||
);
|
||
|
||
export const statsCbsObs = statsSchema.table(
|
||
'cbs_obs',
|
||
{
|
||
tableId: text('table_id').notNull(),
|
||
/** CBS observation Id (unique per table). */
|
||
obsId: bigint('obs_id', { mode: 'number' }).notNull(),
|
||
measure: text('measure').notNull(),
|
||
/** Every non-time dimension, trimmed: {"RegioS":"GM0513","Geslacht":"T001038"}. */
|
||
dims: jsonb('dims').$type<Record<string, string>>().notNull(),
|
||
/** CBS period code as-is: 2026MM09, 2026KW03, 2026JJ00. */
|
||
period: text('period').notNull(),
|
||
value: doublePrecision('value'),
|
||
valueAttribute: text('value_attribute')
|
||
},
|
||
(t) => [
|
||
primaryKey({ columns: [t.tableId, t.obsId] }),
|
||
index('cbs_obs_table_measure_idx').on(t.tableId, t.measure)
|
||
]
|
||
);
|
||
|
||
// ── A2 Eurostat dissemination API (JSON-stat) ─────────────────────────────────────────────
|
||
|
||
export const statsEurostatDataset = statsSchema.table('eurostat_dataset', {
|
||
code: text('code').primaryKey(),
|
||
label: text('label').notNull(),
|
||
/** `updated` from the JSON-stat payload. */
|
||
updated: timestamptz('updated'),
|
||
/** Query used (filters), so a refresh repeats it exactly. */
|
||
query: text('query').notNull(),
|
||
/** Dimension ids in order plus their category labels. */
|
||
dims: jsonb('dims').$type<Record<string, unknown>>().notNull().default({}),
|
||
obsCount: integer('obs_count'),
|
||
fetchedAt: timestamptz('fetched_at')
|
||
});
|
||
|
||
export const statsEurostatObs = statsSchema.table(
|
||
'eurostat_obs',
|
||
{
|
||
dataset: text('dataset').notNull(),
|
||
/** Non-time dimension codes joined with '.', in the dataset's dimension order. */
|
||
key: text('key').notNull(),
|
||
geo: text('geo').notNull(),
|
||
/** Eurostat time code as-is: 2026-S1, 2026-09, 2026-Q2, 2025. */
|
||
period: text('period').notNull(),
|
||
value: doublePrecision('value'),
|
||
status: text('status')
|
||
},
|
||
(t) => [
|
||
primaryKey({ columns: [t.dataset, t.key, t.period] }),
|
||
index('eurostat_obs_dataset_geo_idx').on(t.dataset, t.geo)
|
||
]
|
||
);
|
||
|
||
// ── A7 FRED graph CSV, A5 BIS policy rates ────────────────────────────────────────────────
|
||
|
||
export const statsFredObs = statsSchema.table(
|
||
'fred_obs',
|
||
{
|
||
seriesId: text('series_id').notNull(),
|
||
period: date('period', { mode: 'string' }).notNull(),
|
||
value: doublePrecision('value')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.seriesId, t.period] })]
|
||
);
|
||
|
||
export const statsBisPolicyRate = statsSchema.table(
|
||
'bis_policy_rate',
|
||
{
|
||
/** BIS area code: US, XM (euro area), KR, CN, GB, JP. */
|
||
refArea: text('ref_area').notNull(),
|
||
/** D or M */
|
||
freq: char('freq', { length: 1 }).notNull(),
|
||
period: date('period', { mode: 'string' }).notNull(),
|
||
rate: doublePrecision('rate')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.refArea, t.freq, t.period] })]
|
||
);
|
||
|
||
// ── A3 World Bank indicators, A4 IMF WEO (DataMapper) ─────────────────────────────────────
|
||
|
||
export const statsWbIndicator = statsSchema.table(
|
||
'wb_indicator',
|
||
{
|
||
indicatorId: text('indicator_id').notNull(),
|
||
countryIso3: text('country_iso3').notNull(),
|
||
year: integer('year').notNull(),
|
||
value: doublePrecision('value')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.indicatorId, t.countryIso3, t.year] })]
|
||
);
|
||
|
||
export const statsImfWeo = statsSchema.table(
|
||
'imf_weo',
|
||
{
|
||
/** WEO round the values come from, e.g. '2026-10' (October 2026 WEO). */
|
||
vintage: text('vintage').notNull(),
|
||
indicator: text('indicator').notNull(),
|
||
countryIso3: text('country_iso3').notNull(),
|
||
year: integer('year').notNull(),
|
||
value: doublePrecision('value'),
|
||
isForecast: boolean('is_forecast').notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.vintage, t.indicator, t.countryIso3, t.year] })]
|
||
);
|
||
|
||
// ── A9 Our World in Data (Maddison GDP per capita, energy dataset) ───────────────────────
|
||
|
||
export const statsOwidMaddison = statsSchema.table(
|
||
'owid_maddison',
|
||
{
|
||
entity: text('entity').notNull(),
|
||
/** null for OWID regions (Western Europe, World, …). */
|
||
iso3: text('iso3'),
|
||
year: integer('year').notNull(),
|
||
/** International-$ at 2011 prices. */
|
||
gdpPc: doublePrecision('gdp_pc')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.entity, t.year] })]
|
||
);
|
||
|
||
export const statsOwidEnergy = statsSchema.table(
|
||
'owid_energy',
|
||
{
|
||
country: text('country').notNull(),
|
||
iso3: text('iso3'),
|
||
year: integer('year').notNull(),
|
||
/** Selected OWID columns as-is: {"solar_share_elec": 21.3, "energy_per_capita": 40000, …}. */
|
||
values: jsonb('values').$type<Record<string, number>>().notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.country, t.year] })]
|
||
);
|
||
|
||
// ── A10 Energy-Charts (Fraunhofer ISE): day-ahead prices, cross-border physical flows ─────
|
||
|
||
export const statsEcDayaheadPrice = statsSchema.table(
|
||
'ec_dayahead_price',
|
||
{
|
||
bzn: text('bzn').notNull(),
|
||
ts: timestamptz('ts').notNull(),
|
||
eurMwh: doublePrecision('eur_mwh')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.bzn, t.ts] })]
|
||
);
|
||
|
||
export const statsEcCrossborder = statsSchema.table(
|
||
'ec_crossborder',
|
||
{
|
||
/** Lower-case Energy-Charts country code, e.g. 'nl'. */
|
||
country: text('country').notNull(),
|
||
/** Neighbour name as Energy-Charts reports it ('Belgium', …, 'sum'). */
|
||
neighbour: text('neighbour').notNull(),
|
||
ts: timestamptz('ts').notNull(),
|
||
/** GW; positive = import into `country`, negative = export. */
|
||
gw: doublePrecision('gw')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.country, t.neighbour, t.ts] })]
|
||
);
|
||
|
||
// ── A13 Rijksfinanciën (NL central-government budget tables) ──────────────────────────────
|
||
|
||
export const statsRfBudgetTable = statsSchema.table(
|
||
'rf_budget_table',
|
||
{
|
||
year: integer('year').notNull(),
|
||
/** OWB (budget), O1/O2 (supplementary budgets), JV (annual report). */
|
||
phase: text('phase').notNull(),
|
||
/** Rijksfinanciën row index, unique within year + phase (includes chapter/article/vuo). */
|
||
rowKey: text('row_key').notNull(),
|
||
/** U = expenditure, O = revenue, V = commitments. */
|
||
vuo: char('vuo', { length: 1 }).notNull(),
|
||
chapter: text('chapter').notNull(),
|
||
chapterName: text('chapter_name').notNull(),
|
||
article: text('article'),
|
||
articleName: text('article_name'),
|
||
detail: jsonb('detail').$type<Record<string, unknown>>().notNull().default({}),
|
||
/** Thousand euro. */
|
||
amountEurK: doublePrecision('amount_eur_k')
|
||
},
|
||
(t) => [
|
||
primaryKey({ columns: [t.year, t.phase, t.rowKey] }),
|
||
index('rf_budget_table_chapter_idx').on(t.year, t.vuo, t.chapter)
|
||
]
|
||
);
|
||
|
||
// ── A11 US Treasury Fiscal Data (Monthly Treasury Statement, Debt to the Penny) ───────────
|
||
|
||
const mtsColumns = {
|
||
recordDate: date('record_date', { mode: 'string' }).notNull(),
|
||
classificationId: text('classification_id').notNull(),
|
||
parentId: text('parent_id'),
|
||
description: text('description').notNull(),
|
||
level: integer('level'),
|
||
printOrder: integer('print_order'),
|
||
/** Net amount for the month and fiscal year to date, USD. */
|
||
netMonth: doublePrecision('net_month'),
|
||
netFytd: doublePrecision('net_fytd'),
|
||
grossMonth: doublePrecision('gross_month')
|
||
};
|
||
|
||
export const statsUstMtsReceipts = statsSchema.table('ust_mts_receipts', mtsColumns, (t) => [
|
||
primaryKey({ columns: [t.recordDate, t.classificationId] })
|
||
]);
|
||
|
||
export const statsUstMtsOutlays = statsSchema.table('ust_mts_outlays', { ...mtsColumns }, (t) => [
|
||
primaryKey({ columns: [t.recordDate, t.classificationId] })
|
||
]);
|
||
|
||
export const statsUstDebtDaily = statsSchema.table('ust_debt_daily', {
|
||
recordDate: date('record_date', { mode: 'string' }).primaryKey(),
|
||
debtHeldPublic: doublePrecision('debt_held_public'),
|
||
intragovHoldings: doublePrecision('intragov_holdings'),
|
||
totalDebt: doublePrecision('total_debt')
|
||
});
|
||
|
||
// ── A12 USAspending (budgetary resources per top-tier agency and fiscal year) ─────────────
|
||
|
||
export const statsUsasAgencyFy = statsSchema.table(
|
||
'usas_agency_fy',
|
||
{
|
||
toptierCode: text('toptier_code').notNull(),
|
||
fiscalYear: integer('fiscal_year').notNull(),
|
||
agencyName: text('agency_name').notNull(),
|
||
abbreviation: text('abbreviation'),
|
||
budgetaryResources: doublePrecision('budgetary_resources'),
|
||
obligated: doublePrecision('obligated'),
|
||
outlayed: doublePrecision('outlayed')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.toptierCode, t.fiscalYear] })]
|
||
);
|
||
|
||
// ── B11 EP-Online (registered energy labels, homes; aggregates only, no addresses) ─────────
|
||
|
||
export const statsEpLabelAgg = statsSchema.table(
|
||
'ep_label_agg',
|
||
{
|
||
/** Publication date of the monthly total file. */
|
||
snapshot: date('snapshot', { mode: 'string' }).notNull(),
|
||
/** NL, GOUDA (postcodes 2800–2809) or a Gouda PC4. */
|
||
area: text('area').notNull(),
|
||
label: text('label').notNull(),
|
||
homes: integer('homes').notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.snapshot, t.area, t.label] })]
|
||
);
|
||
|
||
// ── §8 Curaçao: CBS Curaçao files (hash-tracked) and CBCS manual uploads ───────────────────
|
||
|
||
/** One row per CBS Curaçao table: the file last imported and its hash (re-import on change). */
|
||
export const statsCwCbsFile = statsSchema.table('cw_cbs_file', {
|
||
url: text('url').primaryKey(),
|
||
topic: text('topic').notNull(),
|
||
filename: text('filename').notNull(),
|
||
sha256: text('sha256').notNull(),
|
||
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
|
||
});
|
||
|
||
export const statsCwCbsCpi = statsSchema.table('cw_cbs_cpi', {
|
||
period: date('period', { mode: 'string' }).primaryKey(),
|
||
cpiIndex: doublePrecision('cpi_index').notNull(),
|
||
inflMom: doublePrecision('infl_mom'),
|
||
inflYoy: doublePrecision('infl_yoy'),
|
||
/** CBS's inflation figure: change of the 12-month average index. */
|
||
infl12mAvg: doublePrecision('infl_12m_avg'),
|
||
base: text('base')
|
||
});
|
||
|
||
export const statsCwCbsGdpIndustry = statsSchema.table(
|
||
'cw_cbs_gdp_industry',
|
||
{
|
||
year: integer('year').notNull(),
|
||
/** ISIC 4 section(s), or GVA / TLS / GDP / REAL_GROWTH_PCT / NOMINAL_GROWTH_PCT. */
|
||
isic: text('isic').notNull(),
|
||
label: text('label').notNull(),
|
||
/** mln ANG (current prices); % for the *_PCT rows. */
|
||
mlnAng: doublePrecision('mln_ang'),
|
||
provisional: boolean('provisional').notNull().default(false)
|
||
},
|
||
(t) => [primaryKey({ columns: [t.year, t.isic] })]
|
||
);
|
||
|
||
export const statsCwCbsGovFinance = statsSchema.table(
|
||
'cw_cbs_gov_finance',
|
||
{
|
||
year: integer('year').notNull(),
|
||
/** R./E. + ESA code (R.D2, E.D1, …) or R.TOTAL / E.TOTAL. */
|
||
itemCode: text('item_code').notNull(),
|
||
label: text('label').notNull(),
|
||
mlnAng: doublePrecision('mln_ang'),
|
||
provisional: boolean('provisional').notNull().default(true)
|
||
},
|
||
(t) => [primaryKey({ columns: [t.year, t.itemCode] })]
|
||
);
|
||
|
||
export const statsCwCbsPopulation = statsSchema.table(
|
||
'cw_cbs_population',
|
||
{
|
||
year: integer('year').notNull(),
|
||
sex: text('sex').notNull(),
|
||
ageGroup: text('age_group').notNull(),
|
||
/** jan1, dec31 (before 1992), census, midyear. */
|
||
kind: text('kind').notNull(),
|
||
value: doublePrecision('value')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.year, t.sex, t.ageGroup, t.kind] })]
|
||
);
|
||
|
||
export const statsCwCbsLfs = statsSchema.table(
|
||
'cw_cbs_lfs',
|
||
{
|
||
year: integer('year').notNull(),
|
||
sex: text('sex').notNull(),
|
||
ageGroup: text('age_group').notNull(),
|
||
measure: text('measure').notNull(),
|
||
value: doublePrecision('value')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.year, t.sex, t.ageGroup, t.measure] })]
|
||
);
|
||
|
||
export const statsCwCbsTourism = statsSchema.table(
|
||
'cw_cbs_tourism',
|
||
{
|
||
period: date('period', { mode: 'string' }).notNull(),
|
||
/** cruise_calls, cruise_pax, daytrippers, nights, air_landings_scheduled, air_landings_civil. */
|
||
measure: text('measure').notNull(),
|
||
/** total or source market (north_america, europe, …). */
|
||
market: text('market').notNull(),
|
||
value: doublePrecision('value')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.period, t.measure, t.market] })]
|
||
);
|
||
|
||
/** CBCS balance of payments: manual upload only (centralbank.cw blocks scripted access). */
|
||
export const statsCwCbcsBop = statsSchema.table(
|
||
'cw_cbcs_bop',
|
||
{
|
||
period: date('period', { mode: 'string' }).notNull(),
|
||
item: text('item').notNull(),
|
||
mlnAng: doublePrecision('mln_ang'),
|
||
uploadedAt: timestamptz('uploaded_at').notNull().defaultNow()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.period, t.item] })]
|
||
);
|
||
|
||
// ── Step 3: elections (A14) ──────────────────────────────────────────────────────────────
|
||
|
||
/** One election: NL Tweede Kamer (Kiesraad, CBS 37278) or US presidential (MIT Election Lab). */
|
||
export const statsElecElection = statsSchema.table('elec_election', {
|
||
/** NL-TK-2025, US-PRES-2024, … */
|
||
electionId: text('election_id').primaryKey(),
|
||
/** ISO 3166-1 alpha-3. */
|
||
country: char('country', { length: 3 }).notNull(),
|
||
/** TK (Tweede Kamer), PRES (US president). */
|
||
kind: text('kind').notNull(),
|
||
date: date('date', { mode: 'string' }).notNull(),
|
||
name: text('name').notNull(),
|
||
sourceId: text('source_id').notNull(),
|
||
totalSeats: integer('total_seats')
|
||
});
|
||
|
||
export const statsElecResult = statsSchema.table(
|
||
'elec_result',
|
||
{
|
||
electionId: text('election_id').notNull(),
|
||
/** national, province (kieskring not used), muni, station, state. */
|
||
level: text('level').notNull(),
|
||
/** NL / GM0513 / GM0513-SB101 / TX … */
|
||
areaCode: text('area_code').notNull(),
|
||
areaName: text('area_name'),
|
||
/** List name as the source spells it (normalised to a short label on the page). */
|
||
party: text('party').notNull(),
|
||
votes: bigint('votes', { mode: 'number' }),
|
||
seats: integer('seats')
|
||
},
|
||
(t) => [
|
||
primaryKey({ columns: [t.electionId, t.level, t.areaCode, t.party] }),
|
||
index('elec_result_area_idx').on(t.level, t.areaCode)
|
||
]
|
||
);
|
||
|
||
export const statsElecTurnout = statsSchema.table(
|
||
'elec_turnout',
|
||
{
|
||
electionId: text('election_id').notNull(),
|
||
level: text('level').notNull(),
|
||
areaCode: text('area_code').notNull(),
|
||
areaName: text('area_name'),
|
||
/** Persons entitled to vote (kiesgerechtigden). */
|
||
electorate: bigint('electorate', { mode: 'number' }),
|
||
/** Ballots cast / counted (opkomst). */
|
||
ballots: bigint('ballots', { mode: 'number' }),
|
||
valid: bigint('valid', { mode: 'number' }),
|
||
blank: bigint('blank', { mode: 'number' }),
|
||
invalid: bigint('invalid', { mode: 'number' })
|
||
},
|
||
(t) => [primaryKey({ columns: [t.electionId, t.level, t.areaCode] })]
|
||
);
|
||
|
||
// ── Step 3: world opinions (§4, A15) ─────────────────────────────────────────────────────
|
||
// The /projects/world-opinions guide reads these. Curated rows (seeded from the former
|
||
// src/lib/world-opinions/data.ts) keep Stan's text exactly; collectors add CHES / V-Party data.
|
||
|
||
export const statsWoCountry = statsSchema.table('wo_country', {
|
||
iso3: char('iso3', { length: 3 }).primaryKey(),
|
||
/** ISO 3166-1 alpha-2, lower case (the guide's own id). */
|
||
id: text('id').notNull().unique(),
|
||
/** ISO 3166-1 numeric as a zero-padded string (world-atlas shape id). */
|
||
isoNum: text('iso_num').notNull(),
|
||
name: text('name').notNull(),
|
||
/** One of the 11 RegionId keys in src/lib/world-opinions/data.ts. */
|
||
region: text('region').notNull(),
|
||
/** left | market | western */
|
||
liberalMeaning: text('liberal_meaning'),
|
||
/** economy | culture | history | none */
|
||
mainDivide: text('main_divide'),
|
||
note: text('note'),
|
||
/** Country profile {center, fights[], timeline[{year,label}], blindSpots[]}, or null. */
|
||
profile: jsonb('profile'),
|
||
sort: integer('sort').notNull()
|
||
});
|
||
|
||
/** Inglehart–Welzel map positions; the guide's hand-placed values are wave 'curated'. */
|
||
export const statsWoCulturalMap = statsSchema.table(
|
||
'wo_cultural_map',
|
||
{
|
||
iso3: char('iso3', { length: 3 }).notNull(),
|
||
wave: text('wave').notNull(),
|
||
yearFrom: integer('year_from'),
|
||
yearTo: integer('year_to'),
|
||
/** y axis: traditional (-) ↔ secular-rational (+). */
|
||
tradSecular: doublePrecision('trad_secular'),
|
||
/** x axis: survival (-) ↔ self-expression (+). */
|
||
survSelfexp: doublePrecision('surv_selfexp'),
|
||
n: integer('n'),
|
||
source: text('source')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.iso3, t.wave] })]
|
||
);
|
||
|
||
/** WVS/EVS/Pew item aggregates (Tier C manual loads; empty until then). */
|
||
export const statsWoValueScore = statsSchema.table(
|
||
'wo_value_score',
|
||
{
|
||
iso3: char('iso3', { length: 3 }).notNull(),
|
||
wave: text('wave').notNull(),
|
||
itemCode: text('item_code').notNull(),
|
||
itemLabel: text('item_label'),
|
||
stat: text('stat').notNull(),
|
||
value: doublePrecision('value'),
|
||
n: integer('n')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.iso3, t.wave, t.itemCode, t.stat] })]
|
||
);
|
||
|
||
export const statsWoNarrative = statsSchema.table('wo_narrative', {
|
||
sort: integer('sort').primaryKey(),
|
||
event: text('event').notNull(),
|
||
years: text('years').notNull(),
|
||
/** [{country (alpha-2), story}] in display order. */
|
||
views: jsonb('views').notNull()
|
||
});
|
||
|
||
export const statsWoSource = statsSchema.table('wo_source', {
|
||
sort: integer('sort').primaryKey(),
|
||
name: text('name').notNull(),
|
||
href: text('href').notNull()
|
||
});
|
||
|
||
/**
|
||
* Parties. Curated guide parties: party_id `wo:<cc>:<short>`; data-driven parties:
|
||
* `pf:<partyfacts_id>` (or `ches:<id>` / `vp:<id>` when Party Facts has no link).
|
||
*/
|
||
export const statsWoParty = statsSchema.table(
|
||
'wo_party',
|
||
{
|
||
partyId: text('party_id').primaryKey(),
|
||
iso3: char('iso3', { length: 3 }),
|
||
name: text('name'),
|
||
abbrev: text('abbrev'),
|
||
family: text('family'),
|
||
chesId: integer('ches_id'),
|
||
manifestoId: integer('manifesto_id'),
|
||
vpartyId: integer('vparty_id'),
|
||
partyfactsId: integer('partyfacts_id'),
|
||
/** Display order of curated parties (null for data-driven rows). */
|
||
sort: integer('sort')
|
||
},
|
||
(t) => [index('wo_party_iso3_idx').on(t.iso3)]
|
||
);
|
||
|
||
export const statsWoPartyPosition = statsSchema.table(
|
||
'wo_party_position',
|
||
{
|
||
partyId: text('party_id').notNull(),
|
||
year: integer('year').notNull(),
|
||
/** ches, vparty */
|
||
source: text('source').notNull(),
|
||
/** lrgen, lrecon, galtan, eu_position, v2xpa_popul, v2pariglef, … */
|
||
dim: text('dim').notNull(),
|
||
value: doublePrecision('value'),
|
||
voteShare: doublePrecision('vote_share')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.partyId, t.year, t.source, t.dim] })]
|
||
);
|
||
|
||
/** Stan's curated shared-compass placements (x = economic 0–10, y = social 0–10). */
|
||
export const statsWoCompassOverride = statsSchema.table('wo_compass_override', {
|
||
partyId: text('party_id').primaryKey(),
|
||
x: doublePrecision('x').notNull(),
|
||
y: doublePrecision('y').notNull(),
|
||
note: text('note')
|
||
});
|
||
|
||
// ── Step 3: opinion feeds (A16) ──────────────────────────────────────────────────────────
|
||
|
||
/** Raw Wikipedia wikitext of the poll pages, one row per revision seen. */
|
||
export const statsOpWikiSnapshot = statsSchema.table(
|
||
'op_wiki_snapshot',
|
||
{
|
||
page: text('page').notNull(),
|
||
revid: bigint('revid', { mode: 'number' }).notNull(),
|
||
fetchedAt: timestamptz('fetched_at').notNull().defaultNow(),
|
||
wikitext: text('wikitext').notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.page, t.revid] })]
|
||
);
|
||
|
||
/** Parsed poll rows (long format): one value per poll × option. */
|
||
export const statsOpPoll = statsSchema.table(
|
||
'op_poll',
|
||
{
|
||
/** nl-tk (seats), us-approval, us-approval-avg, us-generic, kr-approval, kr-party. */
|
||
series: text('series').notNull(),
|
||
/** `${fieldwork_end}|${pollster}|${n}` (n = order within that pollster and date). */
|
||
pollKey: text('poll_key').notNull(),
|
||
fieldworkEnd: date('fieldwork_end', { mode: 'string' }).notNull(),
|
||
pollster: text('pollster').notNull(),
|
||
sample: integer('sample'),
|
||
option: text('option').notNull(),
|
||
value: doublePrecision('value'),
|
||
seenAt: timestamptz('seen_at').notNull().defaultNow()
|
||
},
|
||
(t) => [
|
||
primaryKey({ columns: [t.series, t.pollKey, t.option] }),
|
||
index('op_poll_series_date_idx').on(t.series, t.fieldworkEnd)
|
||
]
|
||
);
|
||
|
||
/** Standard Eurobarometer volume A: most-important-issue and trust questions per country. */
|
||
export const statsEbItem = statsSchema.table(
|
||
'eb_item',
|
||
{
|
||
/** 104.1 etc. */
|
||
wave: text('wave').notNull(),
|
||
fieldworkEnd: date('fieldwork_end', { mode: 'string' }),
|
||
/** mii_country, mii_personal, mii_eu, trust */
|
||
topic: text('topic').notNull(),
|
||
/** Sheet name in the wave's file (QA3, QA6_9, …). */
|
||
qcode: text('qcode').notNull(),
|
||
/** Answer (issue) for MII; institution for trust. */
|
||
item: text('item').notNull(),
|
||
/** Eurobarometer country code (EU27, BE, EL, …). */
|
||
country: text('country').notNull(),
|
||
/** Share of respondents, 0–1. */
|
||
share: doublePrecision('share')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.wave, t.topic, t.item, t.country] })]
|
||
);
|
||
|
||
/** World Happiness Report: life evaluation (3-year average) and its explained parts. */
|
||
export const statsWhrScore = statsSchema.table(
|
||
'whr_score',
|
||
{
|
||
year: integer('year').notNull(),
|
||
country: text('country').notNull(),
|
||
iso3: char('iso3', { length: 3 }),
|
||
rank: integer('rank'),
|
||
ladder: doublePrecision('ladder'),
|
||
lower: doublePrecision('lower'),
|
||
upper: doublePrecision('upper'),
|
||
gdp: doublePrecision('gdp'),
|
||
social: doublePrecision('social'),
|
||
health: doublePrecision('health'),
|
||
freedom: doublePrecision('freedom'),
|
||
generosity: doublePrecision('generosity'),
|
||
corruption: doublePrecision('corruption'),
|
||
dystopia: doublePrecision('dystopia')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.year, t.country] })]
|
||
);
|
||
|
||
/** Google Trends daily trending searches (RSS snapshot). */
|
||
export const statsGtTrending = statsSchema.table(
|
||
'gt_trending',
|
||
{
|
||
geo: text('geo').notNull(),
|
||
date: date('date', { mode: 'string' }).notNull(),
|
||
rank: integer('rank').notNull(),
|
||
query: text('query').notNull(),
|
||
approxTraffic: integer('approx_traffic'),
|
||
pubDate: timestamptz('pub_date'),
|
||
/** First related news headline + source, for context. */
|
||
news: text('news')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.geo, t.date, t.query] })]
|
||
);
|
||
|
||
/** GDELT DOC timelinevol: share of monitored news coverage per theme and source country. */
|
||
export const statsGdeltThemeVol = statsSchema.table(
|
||
'gdelt_theme_vol',
|
||
{
|
||
date: date('date', { mode: 'string' }).notNull(),
|
||
/** ISO 3166-1 alpha-3 of the source country. */
|
||
country: char('country', { length: 3 }).notNull(),
|
||
theme: text('theme').notNull(),
|
||
/** Percentage of all monitored articles (daily mean of GDELT's volume intensity). */
|
||
volPct: doublePrecision('vol_pct')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.date, t.country, t.theme] })]
|
||
);
|
||
|
||
// ── Step 4: history and culture per place (§9) ───────────────────────────────────────────
|
||
// Places are region codes: NLD, GM0513 (Gouda), CUW, USA, KOR. Every row carries its source link.
|
||
|
||
/** Curated key events (scripts/stats/history-events.json), each with a source link. */
|
||
export const statsHistEvent = statsSchema.table(
|
||
'hist_event',
|
||
{
|
||
place: text('place').notNull(),
|
||
/** Stable slug within the place, e.g. 'dutch-revolt'. */
|
||
slug: text('slug').notNull(),
|
||
year: integer('year').notNull(),
|
||
yearEnd: integer('year_end'),
|
||
/** Optional exact or partial date label ("1 July 1863"). */
|
||
dateLabel: text('date_label'),
|
||
title: text('title').notNull(),
|
||
summary: text('summary').notNull(),
|
||
/** politics | war | economy | society | culture | science */
|
||
theme: text('theme').notNull(),
|
||
sourceUrl: text('source_url').notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.place, t.slug] })]
|
||
);
|
||
|
||
/** Curated short culture notes: cuisine and cultural exports. */
|
||
export const statsHistFact = statsSchema.table(
|
||
'hist_fact',
|
||
{
|
||
place: text('place').notNull(),
|
||
slug: text('slug').notNull(),
|
||
/** cuisine | export */
|
||
topic: text('topic').notNull(),
|
||
title: text('title').notNull(),
|
||
body: text('body').notNull(),
|
||
sourceUrl: text('source_url').notNull(),
|
||
sort: integer('sort').notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.place, t.slug] })]
|
||
);
|
||
|
||
/** Office holders from Wikidata (P39 with start/end qualifiers). */
|
||
export const statsHistOffice = statsSchema.table(
|
||
'hist_office',
|
||
{
|
||
place: text('place').notNull(),
|
||
/** head_of_government | head_of_state | mayor | governor */
|
||
kind: text('kind').notNull(),
|
||
officeQid: text('office_qid').notNull(),
|
||
officeLabel: text('office_label').notNull(),
|
||
personQid: text('person_qid').notNull(),
|
||
personLabel: text('person_label').notNull(),
|
||
party: text('party'),
|
||
startDate: date('start_date', { mode: 'string' }).notNull(),
|
||
endDate: date('end_date', { mode: 'string' })
|
||
},
|
||
(t) => [primaryKey({ columns: [t.officeQid, t.personQid, t.startDate] })]
|
||
);
|
||
|
||
/** Well-known people per place from Wikidata, ranked by number of Wikipedia sitelinks. */
|
||
export const statsHistPerson = statsSchema.table(
|
||
'hist_person',
|
||
{
|
||
place: text('place').notNull(),
|
||
personQid: text('person_qid').notNull(),
|
||
label: text('label').notNull(),
|
||
description: text('description'),
|
||
birthYear: integer('birth_year'),
|
||
deathYear: integer('death_year'),
|
||
sitelinks: integer('sitelinks').notNull(),
|
||
/** Wikipedia article (en, else nl) or the Wikidata page. */
|
||
url: text('url').notNull(),
|
||
rank: integer('rank').notNull()
|
||
},
|
||
(t) => [primaryKey({ columns: [t.place, t.personQid] })]
|
||
);
|
||
|
||
/** Public holidays from the Nager.Date API. */
|
||
export const statsHistHoliday = statsSchema.table(
|
||
'hist_holiday',
|
||
{
|
||
place: text('place').notNull(),
|
||
date: date('date', { mode: 'string' }).notNull(),
|
||
name: text('name').notNull(),
|
||
localName: text('local_name'),
|
||
/** Nationwide (false = regional only). */
|
||
global: boolean('global').notNull(),
|
||
types: jsonb('types')
|
||
},
|
||
(t) => [primaryKey({ columns: [t.place, t.date, t.name] })]
|
||
);
|
||
|
||
/** UNESCO World Heritage sites (UNESCO WHC open data, dataset whc001). */
|
||
export const statsUnescoSite = statsSchema.table('unesco_site', {
|
||
idNo: integer('id_no').primaryKey(),
|
||
name: text('name').notNull(),
|
||
/** Region codes of the places this site lies in (NLD, USA, KOR, CUW). */
|
||
places: jsonb('places').notNull(),
|
||
category: text('category').notNull(),
|
||
yearInscribed: integer('year_inscribed'),
|
||
danger: boolean('danger').notNull(),
|
||
description: text('description'),
|
||
lat: doublePrecision('lat'),
|
||
lon: doublePrecision('lon'),
|
||
imageUrl: text('image_url')
|
||
});
|