diff --git a/db/client.ts b/db/client.ts new file mode 100644 index 000000000..d0446b347 --- /dev/null +++ b/db/client.ts @@ -0,0 +1,13 @@ +import { Kysely, PostgresDialect } from 'kysely'; +import { Pool, PoolConfig, types } from 'pg'; +import type { Database } from './types'; + +// pg turns a date into a Date at local midnight, so 2026-07-01 is 04:00Z on a +// laptop in Eastern time and 00:00Z in Lambda. Keep dates as 'YYYY-MM-DD'. +types.setTypeParser(types.builtins.DATE, (value) => value); + +export function createDb(config: PoolConfig): Kysely { + return new Kysely({ + dialect: new PostgresDialect({ pool: new Pool(config) }), + }); +} diff --git a/db/migrations/0001_init_schema.ts b/db/migrations/0001_init_schema.ts new file mode 100644 index 000000000..02a566a54 --- /dev/null +++ b/db/migrations/0001_init_schema.ts @@ -0,0 +1,246 @@ +import { Kysely, sql } from 'kysely'; + +// Tables, columns, and sources are documented in docs/database-schema.md. +export async function up(db: Kysely): Promise { + await db.schema + .createTable('users') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('email', 'text', (col) => col.notNull().unique()) + .addColumn('firstName', 'text', (col) => col.notNull()) + .addColumn('lastName', 'text', (col) => col.notNull()) + .addColumn('status', 'text', (col) => col.notNull()) + .execute(); + + await db.schema + .createTable('stations') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('code', 'text', (col) => col.notNull().unique()) + .execute(); + + await db.insertInto('stations').values({ code: '19737-02' }).execute(); + + await db.schema + .createTable('imports') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('kind', 'text', (col) => col.notNull()) + .addColumn('fileName', 'text', (col) => col.notNull()) + .addColumn('s3Key', 'text', (col) => col.notNull()) + .addColumn('fileHash', 'text', (col) => col.notNull()) + .addColumn('uploadedBy', 'integer', (col) => + col.notNull().references('users.id'), + ) + .addColumn('uploadedAt', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('status', 'text', (col) => col.notNull()) + .addColumn('errors', 'jsonb') + .execute(); + + await db.schema + .createTable('daily_observations') + .addColumn('stationId', 'integer', (col) => + col.notNull().references('stations.id'), + ) + .addColumn('date', 'date', (col) => col.notNull()) + .addColumn('maxTempF', 'smallint') + .addColumn('minTempF', 'smallint') + .addColumn('avgTempF', 'numeric') + .addColumn('normalTempF', 'smallint') + .addColumn('precipIn', 'numeric') + .addColumn('precipTrace', 'boolean', (col) => col.notNull()) + .addColumn('snowfallIn', 'numeric') + .addColumn('snowDepthIn', 'numeric') + .addColumn('sunshineMin', 'smallint') + .addColumn('sunshinePossibleMin', 'smallint') + .addColumn('sunshinePct', 'smallint') + .addColumn('fastestMileMph', 'smallint') + .addColumn('fastestMileDir', 'varchar(3)') + .addColumn('fastestMileTime', 'time') + .addColumn('peakGustKts', 'smallint') + .addColumn('peakGustDir', 'varchar(3)') + .addColumn('peakGustTime', 'time') + .addColumn('avgStationPressureMb', 'numeric') + .addColumn('sunrise', 'time') + .addColumn('sunset', 'time') + .addColumn('observerInitials', 'text') + .addColumn('remarks', 'text') + .addColumn('importId', 'integer', (col) => + col.notNull().references('imports.id'), + ) + .addPrimaryKeyConstraint('daily_observations_pkey', ['stationId', 'date']) + .execute(); + + await db.schema + .createTable('hourly_observations') + .addColumn('stationId', 'integer', (col) => col.notNull()) + .addColumn('date', 'date', (col) => col.notNull()) + .addColumn('hour', 'smallint', (col) => col.notNull()) + .addColumn('tempF', 'smallint') + .addColumn('precipIn', 'numeric') + .addColumn('precipTrace', 'boolean', (col) => col.notNull()) + .addColumn('windDir', 'varchar(3)') + .addColumn('windSpeedMph', 'smallint') + .addColumn('sunshineMin', 'smallint') + .addColumn('skyCover', 'smallint') + .addColumn('visibilityMi', 'numeric') + .addColumn('presentWeather', 'text') + .addColumn('humidityPct', 'smallint') + .addColumn('mountainVis', 'text') + .addColumn('remarks', 'text') + .addPrimaryKeyConstraint('hourly_observations_pkey', [ + 'stationId', + 'date', + 'hour', + ]) + .addForeignKeyConstraint( + 'hourly_observations_day_fkey', + ['stationId', 'date'], + 'daily_observations', + ['stationId', 'date'], + (fk) => fk.onDelete('cascade'), + ) + .execute(); + + await db.schema + .createTable('scheduled_observations') + .addColumn('stationId', 'integer', (col) => col.notNull()) + .addColumn('date', 'date', (col) => col.notNull()) + .addColumn('obsTime', 'time', (col) => col.notNull()) + .addColumn('stationPressureIn', 'numeric') + .addColumn('attachedThermC', 'numeric') + .addColumn('observedBarometerMb', 'numeric') + .addColumn('pressureCorrectionMb', 'numeric') + .addColumn('dryBulbF', 'numeric') + .addColumn('wetBulbF', 'numeric') + .addColumn('dewpointF', 'smallint') + .addColumn('humidityPct', 'smallint') + .addColumn('maxTempF', 'smallint') + .addColumn('minTempF', 'smallint') + .addColumn('precipIn', 'numeric') + .addColumn('precipTrace', 'boolean', (col) => col.notNull()) + .addColumn('snowfallIn', 'numeric') + .addColumn('snowDepthIn', 'numeric') + .addColumn('vaporPressureMb', 'numeric') + .addPrimaryKeyConstraint('scheduled_observations_pkey', [ + 'stationId', + 'date', + 'obsTime', + ]) + .addForeignKeyConstraint( + 'scheduled_observations_day_fkey', + ['stationId', 'date'], + 'daily_observations', + ['stationId', 'date'], + (fk) => fk.onDelete('cascade'), + ) + .execute(); + + await db.schema + .createTable('daily_records') + .addColumn('stationId', 'integer', (col) => + col.notNull().references('stations.id'), + ) + .addColumn('month', 'smallint', (col) => col.notNull()) + .addColumn('day', 'smallint', (col) => col.notNull()) + .addColumn('highF', 'smallint') + .addColumn('highYears', sql`smallint[]`) + .addColumn('lowF', 'smallint') + .addColumn('lowYears', sql`smallint[]`) + .addColumn('precipIn', 'numeric') + .addColumn('precipYears', sql`smallint[]`) + .addColumn('snowIn', 'numeric') + .addColumn('snowYears', sql`smallint[]`) + .addColumn('peakGustMph', 'smallint') + .addColumn('peakGustDir', 'varchar(3)') + .addColumn('peakGustIsEstimated', 'boolean', (col) => col.notNull()) + .addColumn('peakGustYears', sql`smallint[]`) + .addColumn('notes', 'text') + .addColumn('importId', 'integer', (col) => + col.notNull().references('imports.id'), + ) + .addPrimaryKeyConstraint('daily_records_pkey', [ + 'stationId', + 'month', + 'day', + ]) + .execute(); + + await db.schema + .createType('audit_log_action') + .asEnum(['insert', 'update', 'delete']) + .execute(); + + await db.schema + .createTable('audit_log') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('tableName', 'text', (col) => col.notNull()) + .addColumn('rowKey', 'jsonb', (col) => col.notNull()) + .addColumn('action', sql`audit_log_action`, (col) => col.notNull()) + .addColumn('oldData', 'jsonb') + .addColumn('newData', 'jsonb') + .addColumn('changedBy', 'integer', (col) => col.references('users.id')) + .addColumn('importId', 'integer', (col) => col.references('imports.id')) + .addColumn('changedAt', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + // Trigger arguments are the table's primary key columns, used for rowKey. + // Writers identify themselves with set_config('app.user_id' / 'app.import_id', + // ..., true) inside their transaction; hourly and scheduled rows have no + // importId column, so the row itself can't say which import wrote it. + await sql` + CREATE FUNCTION audit_row() RETURNS trigger AS $$ + DECLARE + rec jsonb := to_jsonb(CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END); + key jsonb := '{}'; + col text; + BEGIN + FOREACH col IN ARRAY TG_ARGV LOOP + key := key || jsonb_build_object(col, rec -> col); + END LOOP; + + INSERT INTO audit_log ("tableName", "rowKey", action, "oldData", "newData", "changedBy", "importId") + VALUES ( + TG_TABLE_NAME, + key, + lower(TG_OP)::audit_log_action, + CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) END, + CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) END, + nullif(current_setting('app.user_id', true), '')::int, + nullif(current_setting('app.import_id', true), '')::int + ); + RETURN NULL; + END; + $$ LANGUAGE plpgsql + `.execute(db); + + const audited: [string, string[]][] = [ + ['daily_observations', ['stationId', 'date']], + ['hourly_observations', ['stationId', 'date', 'hour']], + ['scheduled_observations', ['stationId', 'date', 'obsTime']], + ['daily_records', ['stationId', 'month', 'day']], + ]; + for (const [table, key] of audited) { + await sql` + CREATE TRIGGER ${sql.id(`${table}_audit`)} + AFTER INSERT OR UPDATE OR DELETE ON ${sql.table(table)} + FOR EACH ROW EXECUTE FUNCTION audit_row(${sql.raw( + key.map((col) => `'${col}'`).join(', '), + )}) + `.execute(db); + } +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('audit_log').execute(); + await db.schema.dropType('audit_log_action').execute(); + await db.schema.dropTable('daily_records').execute(); + await db.schema.dropTable('scheduled_observations').execute(); + await db.schema.dropTable('hourly_observations').execute(); + await db.schema.dropTable('daily_observations').execute(); + await sql`DROP FUNCTION audit_row()`.execute(db); + await db.schema.dropTable('imports').execute(); + await db.schema.dropTable('stations').execute(); + await db.schema.dropTable('users').execute(); +} diff --git a/db/types.ts b/db/types.ts new file mode 100644 index 000000000..a550120a0 --- /dev/null +++ b/db/types.ts @@ -0,0 +1,143 @@ +import type { ColumnType, Generated } from 'kysely'; + +// Table types for Kysely, matching db/migrations. pg returns numeric as a +// string so no precision is lost, timestamptz as a Date, and date as +// 'YYYY-MM-DD' when connected through createDb() in db/client.ts. +type Numeric = ColumnType; +type Json = ColumnType; + +export interface UsersTable { + id: Generated; + email: string; + firstName: string; + lastName: string; + status: string; +} + +export interface StationsTable { + id: Generated; + code: string; +} + +export interface ImportsTable { + id: Generated; + kind: string; + fileName: string; + s3Key: string; + fileHash: string; + uploadedBy: number; + uploadedAt: ColumnType; + status: string; + errors: Json | null; +} + +export interface DailyObservationsTable { + stationId: number; + date: string; + maxTempF: number | null; + minTempF: number | null; + avgTempF: Numeric | null; + normalTempF: number | null; + precipIn: Numeric | null; + precipTrace: boolean; + snowfallIn: Numeric | null; + snowDepthIn: Numeric | null; + sunshineMin: number | null; + sunshinePossibleMin: number | null; + sunshinePct: number | null; + fastestMileMph: number | null; + fastestMileDir: string | null; + fastestMileTime: string | null; + peakGustKts: number | null; + peakGustDir: string | null; + peakGustTime: string | null; + avgStationPressureMb: Numeric | null; + sunrise: string | null; + sunset: string | null; + observerInitials: string | null; + remarks: string | null; + importId: number; +} + +export interface HourlyObservationsTable { + stationId: number; + date: string; + hour: number; + tempF: number | null; + precipIn: Numeric | null; + precipTrace: boolean; + windDir: string | null; + windSpeedMph: number | null; + sunshineMin: number | null; + skyCover: number | null; + visibilityMi: Numeric | null; + presentWeather: string | null; + humidityPct: number | null; + mountainVis: string | null; + remarks: string | null; +} + +export interface ScheduledObservationsTable { + stationId: number; + date: string; + obsTime: string; + stationPressureIn: Numeric | null; + attachedThermC: Numeric | null; + observedBarometerMb: Numeric | null; + pressureCorrectionMb: Numeric | null; + dryBulbF: Numeric | null; + wetBulbF: Numeric | null; + dewpointF: number | null; + humidityPct: number | null; + maxTempF: number | null; + minTempF: number | null; + precipIn: Numeric | null; + precipTrace: boolean; + snowfallIn: Numeric | null; + snowDepthIn: Numeric | null; + vaporPressureMb: Numeric | null; +} + +export interface DailyRecordsTable { + stationId: number; + month: number; + day: number; + highF: number | null; + highYears: number[] | null; + lowF: number | null; + lowYears: number[] | null; + precipIn: Numeric | null; + precipYears: number[] | null; + snowIn: Numeric | null; + snowYears: number[] | null; + peakGustMph: number | null; + peakGustDir: string | null; + peakGustIsEstimated: boolean; + peakGustYears: number[] | null; + notes: string | null; + importId: number; +} + +// Written only by the audit_row() trigger. +export interface AuditLogTable { + id: Generated; + tableName: string; + rowKey: unknown; + action: 'insert' | 'update' | 'delete'; + oldData: unknown; + newData: unknown; + changedBy: number | null; + importId: number | null; + changedAt: Generated; +} + +export interface Database { + users: UsersTable; + stations: StationsTable; + imports: ImportsTable; + daily_observations: DailyObservationsTable; + hourly_observations: HourlyObservationsTable; + scheduled_observations: ScheduledObservationsTable; + daily_records: DailyRecordsTable; + audit_log: AuditLogTable; +} diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 000000000..e94144be9 --- /dev/null +++ b/docs/database-schema.md @@ -0,0 +1,196 @@ +# Database schema + +Draft for [#3](https://github.com/Code-4-Community/bho/issues/3). PostgreSQL on AWS RDS. Created by `db/migrations/0001_init_schema.ts`; Kysely table types are in `db/types.ts`. Connect with `createDb()` in `db/client.ts`, which returns `date` columns as `'YYYY-MM-DD'` strings rather than a `Date` at local midnight. + +Sources: + +- **Daily sheet**: [DAILY - 07. July2026.xlsx](https://docs.google.com/spreadsheets/d/1cWloMpYHZDGkUFulmeW0g1bY3J97Ts4b/edit), tab `Sheet1`. One 60-row block per day. Cell references are for the July 1 block; add 60 rows for each later day. +- **Historical sheet**: [BHO_WeatherRecords_Master_TestData.xlsx](https://docs.google.com/spreadsheets/d/1WI8CxTJ7edRajo3JHZX0WmTklvCqRy5x/edit), tab `All Daily Records`, rows 4-369. + +Column names are camelCase, so Kysely queries use the same names as the TypeScript types. Raw SQL has to quote them (`"maxTempF"`). + +## Relations + +```mermaid +erDiagram + users ||--o{ imports : uploads + users ||--o{ audit_log : changes + imports ||--o{ audit_log : produces + imports ||--o{ daily_observations : creates + imports ||--o{ daily_records : creates + stations ||--o{ daily_observations : records + stations ||--o{ daily_records : holds + daily_observations ||--|{ hourly_observations : has + daily_observations ||--|{ scheduled_observations : has +``` + +`daily_records` joins to observations on station, month, and day, not a foreign key. + +## users + +Source: the app. `main` no longer has the scaffold's `User` entity, so this schema creates the table, with the same columns. + +| Column | Type | Null | Note | +| --------- | ------ | ---- | ---------------------------------------- | +| id | serial | N | PK | +| email | text | N | Unique | +| firstName | text | N | | +| lastName | text | N | | +| status | text | N | `ADMIN` or `STANDARD`; text, not an enum | + +## stations + +Source: the app. Starts with one row, `19737-02`; the client plans to add more. A table rather than an enum, so a new station is a new row, not a migration. + +| Column | Type | Null | Note | +| ------ | ------ | ---- | ------------------ | +| id | serial | N | PK | +| code | text | N | Unique, `19737-02` | + +## imports + +Source: the app, one row per uploaded spreadsheet. The uploaded file stays in S3 as the raw source of truth. Rows are never updated or deleted. Each upload, including a re-upload of the same file, adds a new row. + +| Column | Type | Null | Note | +| ---------- | ----------- | ---- | ----------------------------------------------- | +| id | serial | N | PK | +| kind | text | N | `daily` or `historical` | +| fileName | text | N | | +| s3Key | text | N | Key of the uploaded file in S3 | +| fileHash | text | N | SHA-256 of the file, to catch duplicate uploads | +| uploadedBy | int | N | FK users | +| uploadedAt | timestamptz | N | Defaults to now | +| status | text | N | `succeeded` or `failed`; text, not an enum | +| errors | jsonb | Y | Flagged rows and the reason for each | + +Ideas for later: + +- `rowCount` (rows imported): not sure what the existing parser looks like +- `partial`? Do we want to allow for this, feels simpler to force a re-import and log that import failed + +## daily_observations + +Source: daily sheet, "Summary of Day" and nearby rows. PK `(stationId, date)`. + +| Column | Type | Null | Source | +| -------------------- | ---------- | ---- | ------------------------------------------- | +| stationId | int | N | FK stations; the station the upload is for | +| date | date | N | J1 "Wednesday, July 1, 2026" | +| maxTempF | smallint | Y | A44 "24HR Max" | +| minTempF | smallint | Y | B44 "24HR Min" | +| avgTempF | numeric | Y | D46 "Average Temperature"; 81.5 | +| normalTempF | smallint | Y | D47 "Normal Temperature" | +| precipIn | numeric | Y | C44 "24HR Precip" | +| precipTrace | boolean | N | Our flag for when C44 is `T` (July 5: C284) | +| snowfallIn | numeric | Y | E44 "Snowfall" | +| snowDepthIn | numeric | Y | F44 "Dpth 0700" | +| sunshineMin | smallint | Y | G44 "Total Min." | +| sunshinePossibleMin | smallint | Y | F29 "Poss 914" (possible sunshine minutes) | +| sunshinePct | smallint | Y | H44 "% poss." (0.77 becomes 77) | +| fastestMileMph | smallint | Y | I44 | +| fastestMileDir | varchar(3) | Y | J44 | +| fastestMileTime | time | Y | K44 "0245E" (24-hour, EST) | +| peakGustKts | smallint | Y | L44 "20 WSW @ 0854E" (knots) | +| peakGustDir | varchar(3) | Y | L44 | +| peakGustTime | time | Y | L44 | +| avgStationPressureMb | numeric | Y | E56 "Station Pressure" AVG | +| sunrise | time | Y | K29 | +| sunset | time | Y | K30 | +| observerInitials | text | Y | H59 "MFD/AKJ" | +| remarks | text | Y | G47-G49 "Additional Notes" | +| importId | int | N | FK imports; the last upload that wrote it | + +Every row comes from an upload. Staff re-upload the month's sheet several times a day, and the most recent upload overwrites each day it contains. Weather values are nullable: an empty cell means no data and is stored as null, never 0. The same applies to `daily_records`. + +Not stored: departure and degree days (rows 48-50). They can be computed. + +## hourly_observations + +Source: daily sheet, rows 4-27 ("00-01" to "23-24"). PK `(stationId, date, hour)`. Deleting a day deletes its hours. + +| Column | Type | Null | Source | +| -------------- | ---------- | ---- | ------------------------------------------------ | +| stationId | int | N | FK daily_observations, with `date` | +| date | date | N | FK daily_observations, with `stationId` | +| hour | smallint | N | A, start hour ("00-01" is 0) | +| tempF | smallint | Y | B "Temp (°F)" | +| precipIn | numeric | Y | C "Precip" | +| precipTrace | boolean | N | Our flag for when C is `T` | +| windDir | varchar(3) | Y | D "Dir." | +| windSpeedMph | smallint | Y | E "Spd (mph)" | +| sunshineMin | smallint | Y | F "Sunshine (min)" | +| skyCover | smallint | Y | G "Sky Cover (0-8)" | +| visibilityMi | numeric | Y | H "Lwst VSBL (mls)"; "1/8" mile stored as 0.125 | +| presentWeather | text | Y | I "Prsnt WX"; codes, "RW-" is light rain showers | +| humidityPct | smallint | Y | J "Rel Hum." | +| mountainVis | text | Y | K "Mnts VSBL" ("1@1.5") | +| remarks | text | Y | L "Notes" | + +G, H, and I are only recorded at a few hours each day (06-07, 09-10, and 12-13 on July 21). An empty cell there means nobody observed that hour; `0` in I means they observed and there was no weather. Codes in I: `R` rain, `W` showers, `L` drizzle, `F` fog, `-` light, `+` heavy, so `R-F` (I370, July 7) is light rain and fog. These are the standard US codes; BHO has not confirmed them. + +## scheduled_observations + +Source: daily sheet, "Scheduled Observations", rows 34-38. PK `(stationId, date, obsTime)`. Deleting a day deletes its readings. + +| Column | Type | Null | Source | +| -------------------- | -------- | ---- | ----------------------------------------------------- | +| stationId | int | N | FK daily_observations, with `date` | +| date | date | N | FK daily_observations, with `stationId` | +| obsTime | time | N | A (700, 800, 1000, 1300, 1900); F52 1200 on some days | +| stationPressureIn | numeric | Y | B, 0700 and 1900 only | +| attachedThermC | numeric | Y | C53 "Attached Therm."; F53 at 1200 | +| observedBarometerMb | numeric | Y | C54 "Observed Barometer"; F54 at 1200 | +| pressureCorrectionMb | numeric | Y | C55 "Total Correction"; F55 at 1200 | +| dryBulbF | numeric | Y | C | +| wetBulbF | numeric | Y | D | +| dewpointF | smallint | Y | E | +| humidityPct | smallint | Y | F (0.8 becomes 80) | +| maxTempF | smallint | Y | G, 0700 only, 24 hours ending 0700 | +| minTempF | smallint | Y | H, 0700 only | +| precipIn | numeric | Y | I, 0700 only | +| precipTrace | boolean | N | Our flag for when I is `T` | +| snowfallIn | numeric | Y | J, 0700 only | +| snowDepthIn | numeric | Y | K, 0700 only | +| vaporPressureMb | numeric | Y | L | + +## daily_records + +Source: historical sheet. One sheet row becomes one row. PK `(stationId, month, day)`. Each element's value is null when the sheet says "None", and its years are null too. + +| Column | Type | Null | Source | +| ------------------- | ---------- | ---- | -------------------------------------------------- | +| stationId | int | N | FK stations; the station the upload is for | +| month | smallint | N | "Month #" | +| day | smallint | N | "Day" | +| highF | smallint | Y | "High °F" | +| highYears | smallint[] | Y | "High Year(s)"; "2014, 1977" becomes `{2014,1977}` | +| lowF | smallint | Y | "Low °F" | +| lowYears | smallint[] | Y | "Low Year(s)" | +| precipIn | numeric | Y | "Precip in" | +| precipYears | smallint[] | Y | "Precip Year(s)" | +| snowIn | numeric | Y | "Snow in"; null for "None" | +| snowYears | smallint[] | Y | "Snow Year(s)"; null when snow is "None" | +| peakGustMph | smallint | Y | "Peak Gust mph" | +| peakGustDir | varchar(3) | Y | "Wind Dir" | +| peakGustIsEstimated | boolean | N | "Estimated?" | +| peakGustYears | smallint[] | Y | "Gust Year(s)" | +| notes | text | Y | "Notes"; internal to staff | +| importId | int | N | FK imports; the last upload that wrote it | + +## audit_log + +Source: a Postgres trigger, one row per inserted, updated, or deleted row in `daily_observations`, `hourly_observations`, `scheduled_observations`, and `daily_records`. + +| Column | Type | Null | Note | +| --------- | ----------- | ---- | ---------------------------------------------- | +| id | serial | N | PK | +| tableName | text | N | | +| rowKey | jsonb | N | PK of the affected row | +| action | enum | N | `insert`, `update`, or `delete` | +| oldData | jsonb | Y | Whole row before; null on insert | +| newData | jsonb | Y | Whole row after; null on delete | +| changedBy | int | Y | FK users; null for system writes | +| importId | int | Y | FK imports; set when an import made the change | +| changedAt | timestamptz | N | Defaults to now | + +One SQL function is attached to the four data tables, so no code path can skip the log. Writers set `app.user_id` and `app.import_id` with `set_config(..., true)` inside their transaction, and the trigger copies them into `changedBy` and `importId`. Hourly and scheduled rows have no `importId` column, so the row alone can't say which import wrote it. diff --git a/package.json b/package.json index c9dd38d2d..e30b19f63 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "global": "^4.4.0", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^4.1.0", + "kysely": "^0.27.0", "listr2": "11", "nodemailer": "^9.0.3", "passport": "^0.7.0", diff --git a/yarn.lock b/yarn.lock index d7ac9b68a..4a1687870 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9392,6 +9392,11 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" +kysely@^0.27.0: + version "0.27.6" + resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.6.tgz#b2a908e160e510f9945586ca9de1616c187a2a8e" + integrity sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ== + language-subtag-registry@^0.3.20: version "0.3.23" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"