@reddoorla/maintenance 0.96.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -6
- package/dist/{announce-45HVP4J4.js → announce-5ZD2SBGD.js} +39 -31
- package/dist/announce-5ZD2SBGD.js.map +1 -0
- package/dist/attachments-JFGFQRXQ.js +10 -0
- package/dist/{blux-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
- package/dist/{bump-deps-EL3TKMIL.js → bump-deps-775VLDYB.js} +6 -6
- package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
- package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
- package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
- package/dist/chunk-ASLDCWBF.js +41 -0
- package/dist/chunk-ASLDCWBF.js.map +1 -0
- package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
- package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
- package/dist/chunk-BINFZQ2T.js.map +1 -0
- package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
- package/dist/chunk-DRNZVORX.js.map +1 -0
- package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
- package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
- package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
- package/dist/chunk-FHFXJ3EM.js.map +1 -0
- package/dist/{chunk-H445EH33.js → chunk-FP6HOW6J.js} +10 -7
- package/dist/chunk-FP6HOW6J.js.map +1 -0
- package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
- package/dist/chunk-ICSRWW5Q.js +148 -0
- package/dist/chunk-ICSRWW5Q.js.map +1 -0
- package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
- package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
- package/dist/{chunk-T76MA2FH.js → chunk-IOTOOTII.js} +12 -1
- package/dist/{chunk-T76MA2FH.js.map → chunk-IOTOOTII.js.map} +1 -1
- package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
- package/dist/{chunk-XY7YV2YQ.js → chunk-L3S3QZXL.js} +2 -2
- package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
- package/dist/{chunk-P4A6D6HO.js → chunk-LDHRIQUW.js} +32 -18
- package/dist/chunk-LDHRIQUW.js.map +1 -0
- package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
- package/dist/chunk-LF2TJN4X.js.map +1 -0
- package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
- package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
- package/dist/chunk-LZMAG73U.js.map +1 -0
- package/dist/{chunk-6INOBNRO.js → chunk-MMSMZC7S.js} +2 -2
- package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
- package/dist/chunk-MVKNU4QD.js.map +1 -0
- package/dist/{chunk-62YWOFVN.js → chunk-PARKXDXI.js} +3 -3
- package/dist/{chunk-62YWOFVN.js.map → chunk-PARKXDXI.js.map} +1 -1
- package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
- package/dist/chunk-RQJFGK3A.js +51 -0
- package/dist/chunk-RQJFGK3A.js.map +1 -0
- package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
- package/dist/chunk-TC4GANMX.js.map +1 -0
- package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
- package/dist/chunk-TEMQPSCM.js.map +1 -0
- package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
- package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
- package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
- package/dist/{chunk-3EA43S3R.js → chunk-U2ZI6G7H.js} +12 -12
- package/dist/chunk-UPE6D4AT.js +69 -0
- package/dist/chunk-UPE6D4AT.js.map +1 -0
- package/dist/{chunk-OTEVKRAH.js → chunk-VMGHQUTV.js} +7 -16
- package/dist/chunk-VMGHQUTV.js.map +1 -0
- package/dist/chunk-VQMAAQ5M.js +53 -0
- package/dist/chunk-VQMAAQ5M.js.map +1 -0
- package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
- package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
- package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
- package/dist/chunk-X56LAKPR.js.map +1 -0
- package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
- package/dist/chunk-YJBSB4LP.js.map +1 -0
- package/dist/{chunk-EQQ4H6L3.js → chunk-YXWBM26P.js} +2 -2
- package/dist/cli/bin.js +92 -50
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +33 -3
- package/dist/cli/commands/audit.js +25 -22
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-DGTO65WB.js +11 -0
- package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-KLWA53N5.js} +6 -6
- package/dist/{db-5K4CDEGJ.js → db-II5WAK4D.js} +13 -13
- package/dist/{digest-5AJGWS33.js → digest-CWKK2DMR.js} +28 -27
- package/dist/digest-CWKK2DMR.js.map +1 -0
- package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CYTN2EUX.js} +15 -12
- package/dist/ensure-site-2QZHFVQ3.js +70 -0
- package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
- package/dist/ensure-site-JEKV2ODE.js +105 -0
- package/dist/ensure-site-JEKV2ODE.js.map +1 -0
- package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
- package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
- package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
- package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
- package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-X6UEOI2M.js} +17 -13
- package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
- package/dist/{github-signals-DMU74SAX.js → github-signals-QIRTHHET.js} +20 -15
- package/dist/github-signals-QIRTHHET.js.map +1 -0
- package/dist/{header-image-BRTOLHJF.js → header-image-H7ZJAV26.js} +13 -17
- package/dist/header-image-H7ZJAV26.js.map +1 -0
- package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-BJ6BKRHF.js} +7 -7
- package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-KU3G7V2Y.js} +10 -10
- package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
- package/dist/index.d.ts +102 -494
- package/dist/index.js +60 -49
- package/dist/index.js.map +1 -1
- package/dist/{init-3LMZTCVD.js → init-GOWDA5QS.js} +15 -15
- package/dist/{launch-XTDMC6LD.js → launch-7N3X2MXB.js} +36 -34
- package/dist/launch-7N3X2MXB.js.map +1 -0
- package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
- package/dist/{chunk-IHHAYXKY.js → match-harness-GBCDSVPS.js} +70 -4
- package/dist/match-harness-GBCDSVPS.js.map +1 -0
- package/dist/migrate-XZ7U5DFC.js +7 -0
- package/dist/notify-K3WVH3DM.js +1 -1
- package/dist/{onboard-CZYBWVWH.js → onboard-JQQIMQW7.js} +6 -6
- package/dist/{orchestrate-TGDFGY52.js → orchestrate-L2A5BZFY.js} +13 -10
- package/dist/parity-2JGVUCZD.js +14 -0
- package/dist/{preflight-POZZI3GN.js → preflight-O7Q6FWHU.js} +35 -11
- package/dist/preflight-O7Q6FWHU.js.map +1 -0
- package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-NAPWOXXD.js} +11 -11
- package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
- package/dist/{prismic-models-URYGODNY.js → prismic-models-LL6ORTQ2.js} +19 -16
- package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
- package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-FMPGQIRQ.js} +2 -2
- package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
- package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
- package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-2IHAZDVA.js} +13 -8
- package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
- package/dist/{report-RC5LGPU3.js → report-IAHXN5CW.js} +102 -58
- package/dist/report-IAHXN5CW.js.map +1 -0
- package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-4QM57LMS.js} +28 -10
- package/dist/report-mirror-4QM57LMS.js.map +1 -0
- package/dist/roster-GDNSIRAK.js +19 -0
- package/dist/roster-GDNSIRAK.js.map +1 -0
- package/dist/{self-updating-RKKIYNV4.js → self-updating-ZUWPGYUP.js} +7 -7
- package/dist/{selftest-AFLR7YV7.js → selftest-G4W3LIBQ.js} +21 -17
- package/dist/{selftest-AFLR7YV7.js.map → selftest-G4W3LIBQ.js.map} +1 -1
- package/dist/site-create-ZU3ACBTO.js +45 -0
- package/dist/site-create-ZU3ACBTO.js.map +1 -0
- package/dist/{site-mirror-H2FYMN55.js → site-mirror-WR7ACXOV.js} +10 -10
- package/dist/site-mirror-WR7ACXOV.js.map +1 -0
- package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-WAUPI6EJ.js} +7 -7
- package/dist/{submissions-II5J35QM.js → submissions-2SHCFOKD.js} +11 -6
- package/dist/submissions-2SHCFOKD.js.map +1 -0
- package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-VOLOKKFI.js} +6 -6
- package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
- package/dist/{sync-configs-HGLQV55H.js → sync-configs-OXSWKVZ2.js} +6 -6
- package/dist/turso-KQKBBQCK.js +24 -0
- package/dist/turso-KQKBBQCK.js.map +1 -0
- package/dist/{upgrade-WZ44IARG.js → upgrade-EVAL3JTO.js} +6 -6
- package/dist/util/git.d.ts +1 -1
- package/dist/util/git.js +1 -1
- package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
- package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
- package/package.json +1 -1
- package/dist/airtable-SJV4ZSMS.js +0 -9
- package/dist/announce-45HVP4J4.js.map +0 -1
- package/dist/attachments-NLZIJ5NU.js +0 -9
- package/dist/chunk-37B4LVB3.js.map +0 -1
- package/dist/chunk-72FJZ6LO.js.map +0 -1
- package/dist/chunk-74QRWE6G.js +0 -53
- package/dist/chunk-74QRWE6G.js.map +0 -1
- package/dist/chunk-CGQZFQAY.js.map +0 -1
- package/dist/chunk-F3JWOPTG.js.map +0 -1
- package/dist/chunk-GEUC5VGM.js.map +0 -1
- package/dist/chunk-H445EH33.js.map +0 -1
- package/dist/chunk-IHHAYXKY.js.map +0 -1
- package/dist/chunk-IY6256GK.js.map +0 -1
- package/dist/chunk-KN52PM63.js.map +0 -1
- package/dist/chunk-MZZDMBDT.js.map +0 -1
- package/dist/chunk-OTEVKRAH.js.map +0 -1
- package/dist/chunk-P4A6D6HO.js.map +0 -1
- package/dist/chunk-UIITN37J.js.map +0 -1
- package/dist/chunk-YFAXOD7Z.js +0 -42
- package/dist/chunk-YFAXOD7Z.js.map +0 -1
- package/dist/chunk-ZH5LOQET.js.map +0 -1
- package/dist/client-L7NAUG6J.js +0 -11
- package/dist/digest-5AJGWS33.js.map +0 -1
- package/dist/ensure-site-XESZHZEB.js.map +0 -1
- package/dist/fleet-events-cF2Om88w.d.ts +0 -24
- package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
- package/dist/github-signals-DMU74SAX.js.map +0 -1
- package/dist/header-image-BRTOLHJF.js.map +0 -1
- package/dist/launch-XTDMC6LD.js.map +0 -1
- package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
- package/dist/match-harness-OW45CB5H.js +0 -77
- package/dist/match-harness-OW45CB5H.js.map +0 -1
- package/dist/migrate-NE3DUMTM.js +0 -7
- package/dist/parity-AEKYSLN6.js +0 -14
- package/dist/preflight-POZZI3GN.js.map +0 -1
- package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
- package/dist/prismic-models-URYGODNY.js.map +0 -1
- package/dist/report-RC5LGPU3.js.map +0 -1
- package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
- package/dist/site-mirror-H2FYMN55.js.map +0 -1
- package/dist/submissions-II5J35QM.js.map +0 -1
- package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
- /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
- /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
- /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-775VLDYB.js.map} +0 -0
- /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
- /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
- /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
- /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
- /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
- /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
- /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
- /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
- /package/dist/{chunk-XY7YV2YQ.js.map → chunk-L3S3QZXL.js.map} +0 -0
- /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
- /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
- /package/dist/{chunk-6INOBNRO.js.map → chunk-MMSMZC7S.js.map} +0 -0
- /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
- /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
- /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
- /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
- /package/dist/{chunk-3EA43S3R.js.map → chunk-U2ZI6G7H.js.map} +0 -0
- /package/dist/{chunk-EQQ4H6L3.js.map → chunk-YXWBM26P.js.map} +0 -0
- /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
- /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
- /package/dist/{db-5K4CDEGJ.js.map → db-II5WAK4D.js.map} +0 -0
- /package/dist/{client-L7NAUG6J.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
- /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
- /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-BJ6BKRHF.js.map} +0 -0
- /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
- /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
- /package/dist/{init-3LMZTCVD.js.map → init-GOWDA5QS.js.map} +0 -0
- /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
- /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-XZ7U5DFC.js.map} +0 -0
- /package/dist/{onboard-CZYBWVWH.js.map → onboard-JQQIMQW7.js.map} +0 -0
- /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
- /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
- /package/dist/{prospect-audit-AJEE3Z4L.js.map → prospect-audit-FMPGQIRQ.js.map} +0 -0
- /package/dist/{parity-AEKYSLN6.js.map → render-from-row-75JTED2Z.js.map} +0 -0
- /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-ZUWPGYUP.js.map} +0 -0
- /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-WAUPI6EJ.js.map} +0 -0
- /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
- /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
- /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
- /package/dist/{upgrade-WZ44IARG.js.map → upgrade-EVAL3JTO.js.map} +0 -0
- /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
- /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n {\n // #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.\n // Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there\n // is no Airtable counterpart left afterwards, so parity does not cover it.\n //\n // A single row holding the whole snapshot as JSON, keyed by a constant id.\n // Both readers (runDigest's diff and the fleet homepage's NEW badges) need\n // the ENTIRE map, so a keyed table would buy nothing on reads and its\n // \"give me everything\" query would be a raw scan needing a justified entry\n // in the EXPLAIN gate's allowlist. One row by primary key needs neither.\n id: \"0011_digest_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS digest_state (\n id TEXT PRIMARY KEY,\n snapshot TEXT NOT NULL,\n updated_at TEXT\n );\n `,\n },\n {\n // form_type was the one SubmissionFilter key with an equality predicate and no\n // index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice\n // per load of the submissions page. It shipped green because the EXPLAIN gate\n // named its scenarios by hand and never wrote one for this shape; the gate is\n // now driven by a filter matrix, which is what surfaced it.\n //\n // `submissions` is the one unbounded-growth table in the schema — append-only,\n // one row per fleet lead forever — and Turso meters ROW SCANS, so this is the\n // cost that grows without a ceiling.\n id: \"0012_submissions_form_type_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_form_type\n ON submissions (form_type);\n `,\n },\n {\n // `url` is stored exactly as audited, which makes it a record of the run and\n // a bad thing to group by: the table already holds beachfrontdentistry.com\n // and beachfrontdentistry.com/ as two histories of one practice, and both\n // spellings of ludlowkingsley.com. That costs nothing today because nothing\n // compares two audits of one site — and becomes a returning client with no\n // history the day the report offers a before and an after.\n //\n // Deliberately no SQL backfill. Doing it with lower()/replace()/rtrim()\n // would mangle any URL containing \"www.\" or \"http://\" away from its start,\n // and a key that is quietly wrong is worse than one that is null. Existing\n // rows are backfilled once with the real siteKey().\n // Same ADD COLUMN caveat as 0003: no IF NOT EXISTS in SQLite, so the runner\n // treats \"duplicate column name\" as already-applied. Kept as its own\n // migration, separate from its index below, so that recovery path stays a\n // single statement.\n id: \"0013_prospect_audits_site_key\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN site_key TEXT;`,\n },\n {\n id: \"0014_prospect_audits_site_key_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key\n ON prospect_audits (site_key, created_at DESC);\n `,\n },\n {\n // Operator edits on a prospect report. One JSON blob rather than a side\n // table: the map is always read and written whole, and no query wants a\n // single override in isolation. Same shape of decision as `result_json`.\n //\n // THREE MIGRATIONS, NOT ONE. Verified against migrate.ts: it runs\n // `executeMultiple(m.sql)` inside a try, swallows any \"duplicate column\n // name\" error, then records the marker unconditionally. That recovery is\n // sound for ONE statement. With three, a crash partway through leaves the\n // marker unwritten; the re-run's first statement then throws duplicate,\n // executeMultiple aborts before the later statements, the catch swallows\n // it, and the marker IS written — leaving the remaining columns\n // permanently missing on a migration recorded as applied. Same rule as\n // 0003 and 0013.\n id: \"0015_prospect_audits_overrides\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN overrides_json TEXT;`,\n },\n {\n id: \"0016_prospect_audits_edited_at\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN edited_at TEXT;`,\n },\n {\n id: \"0017_prospect_audits_opened_at\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN opened_at TEXT;`,\n },\n {\n // #783. Resend's `email.bounced` payload carries `data.bounce = { message,\n // subType, type }`; the webhook read only `data.email_id`, so the\n // classification was dropped and a dead mailbox stored identically to a\n // receiving server refusing our CONTENT. Espada sat under a CRITICAL\n // \"check the point-of-contact address\" for three days in September 2026\n // over an address that was fine — their inbound filter was refusing lead\n // notifications whose spam_score (30 and 55) sat under our auto-filter line.\n //\n // Nullable and never backfilled: every row written before this migration\n // has no classification, and `permanent` is counted only where the payload\n // actually SAID so. Inferring a diagnosis for the historical rows would\n // re-manufacture the exact wrong answer this change exists to stop.\n //\n // FOUR MIGRATIONS, NOT ONE — the same rule 0003, 0013 and 0015 carry, and\n // for the reason spelled out on 0015: `migrate.ts` swallows \"duplicate\n // column name\" and then records the marker unconditionally, which is sound\n // for ONE statement. Batched, a crash partway leaves the marker unwritten,\n // the re-run's first statement throws duplicate, `executeMultiple` aborts\n // before the rest, the catch swallows it and the marker IS written —\n // leaving the remaining columns permanently missing on a migration recorded\n // as applied.\n id: \"0018_submissions_bounce_type\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_type TEXT;`,\n },\n {\n id: \"0019_submissions_bounce_subtype\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_subtype TEXT;`,\n },\n {\n id: \"0020_submissions_bounce_message\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_message TEXT;`,\n },\n {\n // #783 item 3: the operator's exit. Before this, a diagnosed false alarm\n // could only be cleared by 14 days of aging or by a hand-written UPDATE\n // against the production `submissions` table — which is what actually\n // happened on 2026-09-14, flipping the two in-window rows from `bounced`\n // back to `sent` and destroying the evidence in the process.\n //\n // A stamp on the ROW rather than an `ack_until` on `sites` (the issue\n // offers both). The row marker clears the alarm EXACTLY — only the bounces\n // the operator actually looked at stop counting — where a site-level\n // window would mute every future bounce for its whole duration, including\n // a genuinely dead address appearing the next day. It also keeps the bounce\n // record intact, which the production workaround did not.\n id: \"0021_submissions_bounce_ack_at\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_ack_at TEXT;`,\n },\n {\n // #786, the other half of #785. That PR made `unknown-site` NON-terminal on\n // replay so both recovery orders are safe (`ensure-site` then replay, or\n // replay then `ensure-site`) — without it, replay-before-heal silently burns\n // every queued lead, and that is the order a person reaches for first.\n //\n // The cost, named at the time: a slug that is genuinely dead but still\n // deployed and posting grows the queue without bound, holds\n // `db replay-deadletters` at exit 1, and leaves a standing CRITICAL cockpit\n // item with no escape short of deleting rows by hand.\n //\n // `abandoned` is that escape — resolved by DECISION rather than by outcome.\n // Deliberately NOT reusing `replayed_at`/`replay_outcome`: those mean \"the\n // pipeline gave this lead an answer\", and a row marked replayed reads as a\n // lead that was placed, which is the opposite of what happened to it.\n //\n // who/when/why are three columns because the decision has to outlive the\n // person who made it; a bare timestamp would leave the next operator\n // guessing why a client's lead was written off.\n //\n // THREE MIGRATIONS, NOT ONE — the rule 0003, 0013 and 0015 already carry:\n // `migrate.ts` swallows \"duplicate column name\" then records the marker\n // unconditionally, which is only sound for a single statement.\n id: \"0022_deadletter_abandoned_at\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_at TEXT;`,\n },\n {\n id: \"0023_deadletter_abandoned_by\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_by TEXT;`,\n },\n {\n id: \"0024_deadletter_abandoned_reason\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_reason TEXT;`,\n },\n {\n // #676. The searches behind \"Where you stand\" were chosen entirely by the\n // analyze stage from what it read on the site. For a client we know, better\n // ones can be written in two minutes — and a comparison over time needs the\n // terms to stay FIXED, which a model re-reading the site cannot promise.\n //\n // Stored on the ROW rather than only inside `result_json` for the two\n // reasons the issue names: a re-run can reuse them, and the /audits listing\n // can show which audits used chosen terms — that listing deliberately never\n // selects `result_json` (large, and useless to a list), so a value only\n // present in the blob could not reach it.\n //\n // JSON arrays in TEXT, NULL when nothing was chosen. Null is the load-\n // bearing state: it means the audit chose its own, which is not the same as\n // an operator supplying an empty list.\n //\n // TWO MIGRATIONS, NOT ONE — the rule 0003, 0013, 0015 and 0018 carry:\n // `migrate.ts` swallows \"duplicate column name\" and then records the marker\n // unconditionally, which is sound only for a single statement.\n id: \"0025_prospect_audits_chosen_terms\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_terms TEXT;`,\n },\n {\n id: \"0026_prospect_audits_chosen_questions\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_questions TEXT;`,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n {\n // #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.\n // Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there\n // is no Airtable counterpart left afterwards, so parity does not cover it.\n //\n // A single row holding the whole snapshot as JSON, keyed by a constant id.\n // Both readers (runDigest's diff and the fleet homepage's NEW badges) need\n // the ENTIRE map, so a keyed table would buy nothing on reads and its\n // \"give me everything\" query would be a raw scan needing a justified entry\n // in the EXPLAIN gate's allowlist. One row by primary key needs neither.\n id: \"0011_digest_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS digest_state (\n id TEXT PRIMARY KEY,\n snapshot TEXT NOT NULL,\n updated_at TEXT\n );\n `,\n },\n {\n // form_type was the one SubmissionFilter key with an equality predicate and no\n // index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice\n // per load of the submissions page. It shipped green because the EXPLAIN gate\n // named its scenarios by hand and never wrote one for this shape; the gate is\n // now driven by a filter matrix, which is what surfaced it.\n //\n // `submissions` is the one unbounded-growth table in the schema — append-only,\n // one row per fleet lead forever — and Turso meters ROW SCANS, so this is the\n // cost that grows without a ceiling.\n id: \"0012_submissions_form_type_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_form_type\n ON submissions (form_type);\n `,\n },\n {\n // `url` is stored exactly as audited, which makes it a record of the run and\n // a bad thing to group by: the table already holds beachfrontdentistry.com\n // and beachfrontdentistry.com/ as two histories of one practice, and both\n // spellings of ludlowkingsley.com. That costs nothing today because nothing\n // compares two audits of one site — and becomes a returning client with no\n // history the day the report offers a before and an after.\n //\n // Deliberately no SQL backfill. Doing it with lower()/replace()/rtrim()\n // would mangle any URL containing \"www.\" or \"http://\" away from its start,\n // and a key that is quietly wrong is worse than one that is null. Existing\n // rows are backfilled once with the real siteKey().\n // Same ADD COLUMN caveat as 0003: no IF NOT EXISTS in SQLite, so the runner\n // treats \"duplicate column name\" as already-applied. Kept as its own\n // migration, separate from its index below, so that recovery path stays a\n // single statement.\n id: \"0013_prospect_audits_site_key\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN site_key TEXT;`,\n },\n {\n id: \"0014_prospect_audits_site_key_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key\n ON prospect_audits (site_key, created_at DESC);\n `,\n },\n {\n // Operator edits on a prospect report. One JSON blob rather than a side\n // table: the map is always read and written whole, and no query wants a\n // single override in isolation. Same shape of decision as `result_json`.\n //\n // THREE MIGRATIONS, NOT ONE. Verified against migrate.ts: it runs\n // `executeMultiple(m.sql)` inside a try, swallows any \"duplicate column\n // name\" error, then records the marker unconditionally. That recovery is\n // sound for ONE statement. With three, a crash partway through leaves the\n // marker unwritten; the re-run's first statement then throws duplicate,\n // executeMultiple aborts before the later statements, the catch swallows\n // it, and the marker IS written — leaving the remaining columns\n // permanently missing on a migration recorded as applied. Same rule as\n // 0003 and 0013.\n id: \"0015_prospect_audits_overrides\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN overrides_json TEXT;`,\n },\n {\n id: \"0016_prospect_audits_edited_at\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN edited_at TEXT;`,\n },\n {\n id: \"0017_prospect_audits_opened_at\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN opened_at TEXT;`,\n },\n {\n // #783. Resend's `email.bounced` payload carries `data.bounce = { message,\n // subType, type }`; the webhook read only `data.email_id`, so the\n // classification was dropped and a dead mailbox stored identically to a\n // receiving server refusing our CONTENT. Espada sat under a CRITICAL\n // \"check the point-of-contact address\" for three days in September 2026\n // over an address that was fine — their inbound filter was refusing lead\n // notifications whose spam_score (30 and 55) sat under our auto-filter line.\n //\n // Nullable and never backfilled: every row written before this migration\n // has no classification, and `permanent` is counted only where the payload\n // actually SAID so. Inferring a diagnosis for the historical rows would\n // re-manufacture the exact wrong answer this change exists to stop.\n //\n // FOUR MIGRATIONS, NOT ONE — the same rule 0003, 0013 and 0015 carry, and\n // for the reason spelled out on 0015: `migrate.ts` swallows \"duplicate\n // column name\" and then records the marker unconditionally, which is sound\n // for ONE statement. Batched, a crash partway leaves the marker unwritten,\n // the re-run's first statement throws duplicate, `executeMultiple` aborts\n // before the rest, the catch swallows it and the marker IS written —\n // leaving the remaining columns permanently missing on a migration recorded\n // as applied.\n id: \"0018_submissions_bounce_type\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_type TEXT;`,\n },\n {\n id: \"0019_submissions_bounce_subtype\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_subtype TEXT;`,\n },\n {\n id: \"0020_submissions_bounce_message\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_message TEXT;`,\n },\n {\n // #783 item 3: the operator's exit. Before this, a diagnosed false alarm\n // could only be cleared by 14 days of aging or by a hand-written UPDATE\n // against the production `submissions` table — which is what actually\n // happened on 2026-09-14, flipping the two in-window rows from `bounced`\n // back to `sent` and destroying the evidence in the process.\n //\n // A stamp on the ROW rather than an `ack_until` on `sites` (the issue\n // offers both). The row marker clears the alarm EXACTLY — only the bounces\n // the operator actually looked at stop counting — where a site-level\n // window would mute every future bounce for its whole duration, including\n // a genuinely dead address appearing the next day. It also keeps the bounce\n // record intact, which the production workaround did not.\n id: \"0021_submissions_bounce_ack_at\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_ack_at TEXT;`,\n },\n {\n // #786, the other half of #785. That PR made `unknown-site` NON-terminal on\n // replay so both recovery orders are safe (`ensure-site` then replay, or\n // replay then `ensure-site`) — without it, replay-before-heal silently burns\n // every queued lead, and that is the order a person reaches for first.\n //\n // The cost, named at the time: a slug that is genuinely dead but still\n // deployed and posting grows the queue without bound, holds\n // `db replay-deadletters` at exit 1, and leaves a standing CRITICAL cockpit\n // item with no escape short of deleting rows by hand.\n //\n // `abandoned` is that escape — resolved by DECISION rather than by outcome.\n // Deliberately NOT reusing `replayed_at`/`replay_outcome`: those mean \"the\n // pipeline gave this lead an answer\", and a row marked replayed reads as a\n // lead that was placed, which is the opposite of what happened to it.\n //\n // who/when/why are three columns because the decision has to outlive the\n // person who made it; a bare timestamp would leave the next operator\n // guessing why a client's lead was written off.\n //\n // THREE MIGRATIONS, NOT ONE — the rule 0003, 0013 and 0015 already carry:\n // `migrate.ts` swallows \"duplicate column name\" then records the marker\n // unconditionally, which is only sound for a single statement.\n id: \"0022_deadletter_abandoned_at\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_at TEXT;`,\n },\n {\n id: \"0023_deadletter_abandoned_by\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_by TEXT;`,\n },\n {\n id: \"0024_deadletter_abandoned_reason\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_reason TEXT;`,\n },\n {\n // #676. The searches behind \"Where you stand\" were chosen entirely by the\n // analyze stage from what it read on the site. For a client we know, better\n // ones can be written in two minutes — and a comparison over time needs the\n // terms to stay FIXED, which a model re-reading the site cannot promise.\n //\n // Stored on the ROW rather than only inside `result_json` for the two\n // reasons the issue names: a re-run can reuse them, and the /audits listing\n // can show which audits used chosen terms — that listing deliberately never\n // selects `result_json` (large, and useless to a list), so a value only\n // present in the blob could not reach it.\n //\n // JSON arrays in TEXT, NULL when nothing was chosen. Null is the load-\n // bearing state: it means the audit chose its own, which is not the same as\n // an operator supplying an empty list.\n //\n // TWO MIGRATIONS, NOT ONE — the rule 0003, 0013, 0015 and 0018 carry:\n // `migrate.ts` swallows \"duplicate column name\" and then records the marker\n // unconditionally, which is sound only for a single statement.\n id: \"0025_prospect_audits_chosen_terms\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_terms TEXT;`,\n },\n {\n id: \"0026_prospect_audits_chosen_questions\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_questions TEXT;`,\n },\n {\n // #539 Phase 6 step 2 (#646): the resend-webhook's REPORT lookup moved from\n // Airtable (`{Resend message ID} = \"…\"`) to Turso, as\n // fleet-state's findReportByMessageId. Every Resend delivery/bounce event\n // that is not a lead notification runs it, so the EXPLAIN-query-plan gate\n // (tests/db/query-plans.test.ts) needs it served by an index rather than a\n // scan of the HTML-bearing reports table — the same reason 0008 indexed\n // submissions.resend_message_id for the bounce lookup. Single statement.\n id: \"0027_reports_resend_message_index\",\n sql: `CREATE INDEX IF NOT EXISTS idx_reports_resend_message ON reports (resend_message_id);`,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-J7SZQUCW.js";
|
|
4
4
|
import {
|
|
5
5
|
withRecipe
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-LUF52EIY.js";
|
|
7
7
|
import {
|
|
8
8
|
bumpDep,
|
|
9
9
|
readPackageJson,
|
|
@@ -243,4 +243,4 @@ async function upgradeSvelte4to5(site, opts = {}) {
|
|
|
243
243
|
export {
|
|
244
244
|
upgradeSvelte4to5
|
|
245
245
|
};
|
|
246
|
-
//# sourceMappingURL=chunk-
|
|
246
|
+
//# sourceMappingURL=chunk-JHJNNJLG.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-LKGVSM2O.js";
|
|
4
4
|
import {
|
|
5
5
|
readGitHubConfig
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-MVKNU4QD.js";
|
|
7
7
|
import {
|
|
8
8
|
assertUrlSegment
|
|
9
9
|
} from "./chunk-43SW3FGW.js";
|
|
@@ -2195,4 +2195,4 @@ export {
|
|
|
2195
2195
|
runAudits,
|
|
2196
2196
|
runAuditsAcross
|
|
2197
2197
|
};
|
|
2198
|
-
//# sourceMappingURL=chunk-
|
|
2198
|
+
//# sourceMappingURL=chunk-L3S3QZXL.js.map
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RESTORED_NOTE,
|
|
3
|
+
refusedByGit,
|
|
4
|
+
undoRefusedWrites
|
|
5
|
+
} from "./chunk-TJLKWQOX.js";
|
|
1
6
|
import {
|
|
2
7
|
PRETTIER_FLAG_NOTE,
|
|
3
8
|
formatWithPrettier,
|
|
4
9
|
resolveTargetPrettier
|
|
5
10
|
} from "./chunk-5CJVUPJU.js";
|
|
6
|
-
import {
|
|
7
|
-
RESTORED_NOTE,
|
|
8
|
-
refusedByGit,
|
|
9
|
-
undoRefusedWrites
|
|
10
|
-
} from "./chunk-4UTJ4G6P.js";
|
|
11
11
|
import {
|
|
12
12
|
withRecipe
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-LUF52EIY.js";
|
|
14
14
|
import {
|
|
15
15
|
defaultSpawn
|
|
16
16
|
} from "./chunk-LBYOLBW7.js";
|
|
@@ -160,4 +160,4 @@ async function healthEndpoint(site, deps = { spawn: defaultSpawn }) {
|
|
|
160
160
|
export {
|
|
161
161
|
healthEndpoint
|
|
162
162
|
};
|
|
163
|
-
//# sourceMappingURL=chunk-
|
|
163
|
+
//# sourceMappingURL=chunk-LAG6IP7Y.js.map
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
listSendableReports,
|
|
3
2
|
stampSent
|
|
4
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-TEMQPSCM.js";
|
|
5
4
|
import {
|
|
6
5
|
gatingHealth,
|
|
7
6
|
isHealthGateClear,
|
|
@@ -9,7 +8,7 @@ import {
|
|
|
9
8
|
} from "./chunk-VAD43MOR.js";
|
|
10
9
|
import {
|
|
11
10
|
fetchAttachmentBytes
|
|
12
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-YJBSB4LP.js";
|
|
13
12
|
import {
|
|
14
13
|
openBase,
|
|
15
14
|
readAirtableConfig
|
|
@@ -17,17 +16,19 @@ import {
|
|
|
17
16
|
import {
|
|
18
17
|
renderReportFromRow,
|
|
19
18
|
requireLighthouse
|
|
20
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-6THEZQLA.js";
|
|
21
20
|
import {
|
|
22
|
-
listWebsites,
|
|
23
21
|
updateLaunched
|
|
24
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-TC4GANMX.js";
|
|
23
|
+
import {
|
|
24
|
+
siteSlug
|
|
25
|
+
} from "./chunk-ICSRWW5Q.js";
|
|
25
26
|
import {
|
|
26
27
|
defaultResendClient
|
|
27
28
|
} from "./chunk-WEKL2HYJ.js";
|
|
28
29
|
import {
|
|
29
30
|
recordFleetEventsBestEffort
|
|
30
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-YXWBM26P.js";
|
|
31
32
|
|
|
32
33
|
// src/reports/send/idempotency.ts
|
|
33
34
|
function isIdempotencyConflict(err) {
|
|
@@ -49,12 +50,12 @@ function withGlobalCc(perSiteCc, to) {
|
|
|
49
50
|
if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);
|
|
50
51
|
return cc;
|
|
51
52
|
}
|
|
52
|
-
async function sendApprovedReports(options
|
|
53
|
+
async function sendApprovedReports(options) {
|
|
53
54
|
const base = openBase(readAirtableConfig());
|
|
54
55
|
const client = options.resend ?? defaultResendClient();
|
|
55
|
-
const sendable = await
|
|
56
|
+
const sendable = await options.sendable();
|
|
56
57
|
if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
|
|
57
|
-
const websites = await
|
|
58
|
+
const websites = await options.roster();
|
|
58
59
|
const sites = new Map(websites.map((w) => [w.id, w]));
|
|
59
60
|
const lines = [];
|
|
60
61
|
let anyFailed = false;
|
|
@@ -66,7 +67,7 @@ async function sendApprovedReports(options = {}) {
|
|
|
66
67
|
continue;
|
|
67
68
|
}
|
|
68
69
|
try {
|
|
69
|
-
const sent = await sendOne(client, base, site, report);
|
|
70
|
+
const sent = await sendOne(client, base, site, report, options.loadHeaderPlate);
|
|
70
71
|
lines.push(`\u2713 sent: ${report.reportId} (${sent.display})`);
|
|
71
72
|
try {
|
|
72
73
|
await options.reportSentMirror?.(report.id, sent.sentAt, sent.messageId);
|
|
@@ -125,7 +126,22 @@ async function sendApprovedReports(options = {}) {
|
|
|
125
126
|
}
|
|
126
127
|
return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
|
|
127
128
|
}
|
|
128
|
-
async function
|
|
129
|
+
async function headerPlateFor(base, site, loadHeaderPlate) {
|
|
130
|
+
const stored = await loadHeaderPlate(site.id);
|
|
131
|
+
if (stored) return { bytes: stored, source: "turso" };
|
|
132
|
+
const slug = siteSlug(site.name);
|
|
133
|
+
const { findWebsiteRecordBySlug } = await import("./ensure-site-OO4UPWNQ.js");
|
|
134
|
+
const { mapRow } = await import("./websites-Y6FN2XFD.js");
|
|
135
|
+
const rec = slug ? await findWebsiteRecordBySlug(base, slug) : null;
|
|
136
|
+
const url = rec ? mapRow(rec).headerImage?.url : void 0;
|
|
137
|
+
if (!url) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Site '${site.name}' has no Header image: no header plate in Turso and no Airtable attachment to fall back to \u2014 run \`reddoor-maint header-image ${slug || site.name} --write-back\``
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { bytes: (await fetchAttachmentBytes(url)).bytes, source: "airtable" };
|
|
143
|
+
}
|
|
144
|
+
async function sendOne(client, base, site, report, loadHeaderPlate) {
|
|
129
145
|
const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };
|
|
130
146
|
if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {
|
|
131
147
|
const failing = gatingHealth(gateReport).filter((h) => h.status !== "pass" && h.status !== "n/a").map((h) => {
|
|
@@ -134,9 +150,6 @@ async function sendOne(client, base, site, report) {
|
|
|
134
150
|
}).join("; ");
|
|
135
151
|
throw new Error(`Report ${report.reportId} health gate not clear \u2014 ${failing}`);
|
|
136
152
|
}
|
|
137
|
-
if (!site.headerImage) {
|
|
138
|
-
throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
|
|
139
|
-
}
|
|
140
153
|
requireLighthouse(report);
|
|
141
154
|
const explicitTo = parseAddresses(site.reportRecipientsTo);
|
|
142
155
|
const fallbackTo = parseAddresses(site.pointOfContact);
|
|
@@ -163,8 +176,9 @@ async function sendOne(client, base, site, report) {
|
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
179
|
+
const header = await headerPlateFor(base, site, loadHeaderPlate);
|
|
180
|
+
console.log(`REPORT_SEND report=${report.reportId} site=${site.name} header=${header.source}`);
|
|
181
|
+
const { html, attachments, subject } = await renderReportFromRow(site, report, header.bytes);
|
|
168
182
|
const payload = {
|
|
169
183
|
from: FROM_ADDRESS,
|
|
170
184
|
to,
|
|
@@ -227,4 +241,4 @@ export {
|
|
|
227
241
|
parseAddresses,
|
|
228
242
|
isProbablyEmail
|
|
229
243
|
};
|
|
230
|
-
//# sourceMappingURL=chunk-
|
|
244
|
+
//# sourceMappingURL=chunk-LDHRIQUW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/send/idempotency.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["/**\n * True when a thrown send error is Resend's same-key + DIFFERENT-body 409\n * (`invalid_idempotent_request`). The ResendClient (send/resend.ts) discards the\n * status/name and only surfaces the message string, so we match defensively on the\n * stable message substring \"idempotency key has been used\" (case-insensitive); a\n * `name`/`statusCode` of 409/`invalid_idempotent_request` is also accepted if a\n * future client happens to preserve it. A same-key + SAME-body re-send is deduped\n * by Resend (returns the original id) and never reaches here.\n *\n * Shared by both send surfaces that key into Resend's idempotency window:\n * `runDigest` (digest.ts, `digest-<date>` key) and `sendOne` (orchestrate.ts,\n * `report:<id>` key). Both treat a 409 as \"the email already went out under this\n * key on a prior run\" — a no-op for the digest, an already-done success for sendOne.\n */\nexport function isIdempotencyConflict(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n if (/idempotency key has been used/i.test(message)) return true;\n const e = err as { name?: unknown; statusCode?: unknown };\n if (e.name === \"invalid_idempotent_request\") return true;\n if (e.statusCode === 409) return true;\n return false;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../airtable/client.js\";\nimport { stampSent } from \"../airtable/reports.js\";\nimport { updateLaunched } from \"../airtable/websites.js\";\nimport { siteSlug, type WebsiteRow } from \"../../fleet/site-row.js\";\nimport type { ReportRow } from \"../report-row.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { renderReportFromRow, requireLighthouse } from \"./render-from-row.js\";\nimport { defaultResendClient, type ResendClient } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { gatingHealth, isHealthGateClear, isSendOverridden } from \"../checklist.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\n/** Operations inbox CC'd on every outgoing report so there's always an internal\n * copy on file alongside the client recipients. */\nexport const GLOBAL_REPORT_CC = \"info@reddoorla.com\";\n\n/**\n * Append {@link GLOBAL_REPORT_CC} to a site's per-site CC list. The per-site CC is\n * passed through unchanged (preserving prior behavior); the global address is added\n * only when it isn't already present in the CC or To lists (case-insensitive), so a\n * report is never double-addressed to the ops inbox. Returns the final CC list (may\n * be empty if the global address is already the sole To recipient — the caller omits\n * an empty CC).\n */\nexport function withGlobalCc(perSiteCc: string[] | null, to: string[]): string[] {\n const cc = [...(perSiteCc ?? [])];\n const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));\n if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);\n return cc;\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n /**\n * #646 step 4: the send queue and the fleet roster, read from TURSO.\n *\n * Required rather than defaulted, for the reason the mirrors below are: this\n * function is called directly by tests, and a default would let one reach a\n * live store. The CLI wires `listSendableReports`/`listSites` over one\n * connection.\n */\n sendable: () => Promise<ReportRow[]>;\n roster: () => Promise<WebsiteRow[]>;\n /**\n * The site's header plate, from `sites.header_image*` (design D5 made Turso its\n * source; the re-render path has read it from there since #643). `null` means\n * Turso holds no bytes for that site, and the send falls back to the site's\n * Airtable attachment — which is the ONLY reason this path still reads\n * Airtable, and why the fallback fetches ONE site's record rather than the\n * roster it used to get the signed url from.\n */\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>;\n /** #539 Phase 5: Turso write-through for the Websites row a Launch send\n * updates. Injected rather than defaulted — this function is called directly\n * by tests, and a default would open a real libSQL handle inside the suite. */\n siteMirror?: SiteMirror;\n /** #643 (the freeze): Turso write-through for the `Sent at` / `Resend message\n * ID` stamp. stampSent is what removes a row from listSendableReports, so\n * once it lands there is no replay left to converge a lost mirror — and the\n * console's already-sent guards (approve, the commentary lock, re-render)\n * read `sent_at` from Turso. Injected like siteMirror; the CLI wires it\n * through `mirrorWrite` so the freeze switch owns the error semantics. */\n reportSentMirror?: (reportId: string, sentAt: Date, messageId: string | null) => Promise<void>;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions,\n): Promise<{ output: string; code: number }> {\n // Airtable is still opened on this path: `stampSent`, `updateLaunched` and the\n // header fallback below are all Airtable calls. What moved in #646 step 4 are\n // the two READS that decided what the run does at all.\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await options.sendable();\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await options.roster();\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const sent = await sendOne(client, base, site, report, options.loadHeaderPlate);\n lines.push(`✓ sent: ${report.reportId} (${sent.display})`);\n // Mirror the stamp into Turso. Caught here rather than thrown so one\n // report's lost mirror still lets the batch continue AND still runs this\n // report's Launch flip below — but it reds the run (`anyFailed`), because\n // post-freeze nothing converges the miss; `db sync --force` is the manual\n // converge during the rollback window.\n try {\n await options.reportSentMirror?.(report.id, sent.sentAt, sent.messageId);\n } catch (e) {\n lines.push(` ✗ sent-stamp mirror failed for ${report.reportId}: ${(e as Error).message}`);\n anyFailed = true;\n }\n if (report.sendOverride) {\n const failing = gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => h.field);\n await recordFleetEventsBestEffort(\n [\n {\n id: `report_sent_with_override:${report.id}`,\n ts: new Date().toISOString(),\n type: \"report_sent_with_override\",\n siteId: site.id,\n siteName: site.name,\n summary: `sent with override — ${report.overrideReason ?? \"\"}`,\n data: { reportId: report.reportId, reason: report.overrideReason, failing },\n },\n ],\n new Date(),\n );\n }\n if (report.reportType === \"Launch\") {\n try {\n const fields = await updateLaunched(base, site.id, new Date().toISOString());\n // Status and `Launched at` travel together — mirroring them as two\n // updates would open a window where Turso says a site is maintained\n // but never launched.\n await options.siteMirror?.site(site.id, fields);\n lines.push(` ↳ launched: ${site.name} flipped to maintained`);\n await recordFleetEventsBestEffort(\n [\n {\n id: `site_launched:${site.id}`,\n ts: new Date().toISOString(),\n type: \"site_launched\",\n siteId: site.id,\n siteName: site.name,\n summary: \"launched — now in maintenance\",\n data: null,\n },\n ],\n new Date(),\n );\n } catch (e) {\n // Post-freeze a swallowed flip failure is permanent divergence: Turso\n // — the store lead routing reads — would keep the site in\n // launch-period forever (its leads go operator-only). Red the run\n // instead of shrugging; the email itself already went out.\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\n/**\n * The header plate for one send: Turso first, the site's Airtable attachment as\n * the fallback (#646 step 4).\n *\n * Turso first for the reason the re-render path already had: the bytes are local,\n * and an Airtable attachment url is SIGNED and expiring, so fetching one when the\n * same image is already in hand is latency plus a dependency on a url that may be\n * dead. The fallback exists because the plate columns were backfilled, not\n * enforced — a site whose image never made it across must still be able to send,\n * exactly as it did before this change.\n *\n * The fallback is a ONE-SITE Airtable lookup, not the whole Websites table: the\n * roster no longer comes from Airtable, so the signed url is not already in hand.\n * A site with no record there (every `site_<ULID>` site) and no plate gets a named\n * error with the command that fixes it — not a silent unillustrated report.\n */\nasync function headerPlateFor(\n base: AirtableBase,\n site: WebsiteRow,\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>,\n): Promise<{ bytes: Uint8Array; source: \"turso\" | \"airtable\" }> {\n const stored = await loadHeaderPlate(site.id);\n if (stored) return { bytes: stored, source: \"turso\" };\n const slug = siteSlug(site.name);\n const { findWebsiteRecordBySlug } = await import(\"../airtable/ensure-site.js\");\n const { mapRow } = await import(\"../airtable/websites.js\");\n const rec = slug ? await findWebsiteRecordBySlug(base, slug) : null;\n const url = rec ? mapRow(rec).headerImage?.url : undefined;\n if (!url) {\n throw new Error(\n `Site '${site.name}' has no Header image: no header plate in Turso and no Airtable ` +\n `attachment to fall back to — run \\`reddoor-maint header-image ${slug || site.name} --write-back\\``,\n );\n }\n return { bytes: (await fetchAttachmentBytes(url)).bytes, source: \"airtable\" };\n}\n\n/** What the caller needs to mirror the stamp: the exact values stampSent wrote\n * (`messageId` null on the 409 path, where Airtable's field is left untouched\n * too), plus the display string for the ✓ line. */\ntype SentStamp = { display: string; sentAt: Date; messageId: string | null };\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n loadHeaderPlate: (siteId: string) => Promise<Uint8Array | null>,\n): Promise<SentStamp> {\n // Hard health gate: a Maintenance/Testing report whose gating evidence isn't all pass/n/a must\n // never go out — even if \"Approved to send\" was set directly in Airtable. Throw so the row is\n // skipped and `Sent at` stays null (at-least-once retry preserved). Launch/Announcement have no\n // gating fields → vacuously clear. A logged send-anyway override (Phase 10) bypasses the gate.\n const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };\n if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {\n const failing = gatingHealth(gateReport)\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => {\n const note = report.autoEvidence?.[h.field]?.note;\n return `${h.field} (${h.status}${note ? `: ${note}` : \"\"})`;\n })\n .join(\"; \");\n throw new Error(`Report ${report.reportId} health gate not clear — ${failing}`);\n }\n // Fail fast, before the header fetch and the sharp downscale. Same rule and\n // same message the renderer enforces — shared, not restated.\n requireLighthouse(report);\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const header = await headerPlateFor(base, site, loadHeaderPlate);\n console.log(`REPORT_SEND report=${report.reportId} site=${site.name} header=${header.source}`);\n // ONE render path, shared with the console's on-demand re-render — so a\n // preview cannot drift from what the client actually receives. The assembly\n // that used to live here moved into renderReportFromRow unchanged.\n const { html, attachments, subject } = await renderReportFromRow(site, report, header.bytes);\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n // Always CC the ops inbox (info@reddoorla.com), in addition to any per-site CC.\n const finalCc = withGlobalCc(cc, to);\n if (finalCc.length > 0) payload.cc = finalCc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n const when = new Date();\n await stampSent(base, report.id, when, null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return { display: \"idempotent-conflict\", sentAt: when, messageId: null };\n }\n throw err;\n }\n const when = new Date();\n await stampSent(base, report.id, when, result.messageId);\n return { display: result.messageId, sentAt: when, messageId: result.messageId };\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":["when"]}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./chunk-VAD43MOR.js";
|
|
5
5
|
import {
|
|
6
6
|
siteSlug
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-ICSRWW5Q.js";
|
|
8
8
|
|
|
9
9
|
// src/db/import-airtable.ts
|
|
10
10
|
var EXCLUDED_WEBSITE_FIELDS = /* @__PURE__ */ new Set([
|
|
@@ -412,4 +412,4 @@ export {
|
|
|
412
412
|
reapRefusal,
|
|
413
413
|
importFleetState
|
|
414
414
|
};
|
|
415
|
-
//# sourceMappingURL=chunk-
|
|
415
|
+
//# sourceMappingURL=chunk-LF2TJN4X.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/import-airtable.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { SitesTable, SiteHealthTable, SiteScheduleTable, ReportsTable } from \"./schema.js\";\nimport { siteSlug } from \"../fleet/site-row.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\n\n/** One raw Airtable record: id + the fields object exactly as the API returns it\n * (empty cells are ABSENT, not null — the API omits them). */\nexport type RawRecord = { id: string; fields: Record<string, unknown> };\n\n/**\n * Phase 1.3 of #539: map raw Airtable records into the 0007 fleet-state tables,\n * and upsert them idempotently.\n *\n * The mapping here is the SINGLE source of truth for Airtable-column →\n * Turso-column: the parity harness (`db parity`) imports these same functions,\n * so \"what the importer writes\" and \"what parity expects\" cannot drift apart.\n *\n * Values are stored RAW (the cell as Airtable returns it, stringified where the\n * column is TEXT). Coercion — `toFrequency`, `toVerdict`, recipient splitting —\n * stays in the READ layer, exactly as `mapRow` does it today, so the store\n * never bakes in a lossy interpretation. The two exceptions are shapes, not\n * meanings: `Accepted Watch Conditions` normalizes to a JSON array (mapRow\n * accepts array or delimited string; the new store keeps one shape), and the\n * report checklist re-keys from Airtable column names to the stable keys in\n * src/reports/checklist.ts.\n */\n\n/** Websites columns that must NEVER reach Turso, and why. Everything else that\n * is populated but unmapped lands in `sites.legacy` (JSON, keyed by original\n * column name) so no data is silently dropped. */\nexport const EXCLUDED_WEBSITE_FIELDS: ReadonlySet<string> = new Set([\n // Dropped under D4's rule (empty everywhere + unreferenced), verified 2026-08-23.\n \"site host username\",\n \"site host password\",\n \"launch day\",\n \"contract link\",\n // Plaintext credentials — operator ruling 2026-08-23: these live on ONLY in\n // the frozen Airtable base. A new store does not inherit plaintext creds.\n \"DNS username\",\n \"DNS password\",\n \"cms username\",\n \"cms password\",\n // Link columns: Turso joins by rec id (D1); the links carry no extra data.\n \"Reports\",\n \"Submissions\",\n \"Spam Screenouts\",\n // Regenerated into sites.header_image as a BLOB by the header-image CLI (D5);\n // the Airtable attachment (an expiring signed URL) is never migrated.\n \"Header image\",\n]);\n\nconst s = (v: unknown): string | null => {\n if (v === undefined || v === null) return null;\n if (typeof v === \"string\") return v.trim() === \"\" ? null : v;\n return String(v);\n};\nconst n = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\nconst b01 = (v: unknown): number => (v === true ? 1 : 0);\nconst b01n = (v: unknown): number | null => (typeof v === \"boolean\" ? (v ? 1 : 0) : null);\nconst json = (v: unknown): string | null => (v === undefined ? null : JSON.stringify(v));\n\n/** Direct field→column map for `sites` (operator-owned config). Exported for\n * the site-details write-through (fleet-state.mirrorSiteField), so the editor\n * and the importer share ONE Airtable-column → sites-column truth. */\nexport const SITE_FIELDS: Record<string, keyof SitesTable> = {\n Name: \"name\",\n url: \"url\",\n Status: \"status\",\n \"point of contact\": \"point_of_contact\",\n \"maintenence freq\": \"maintenance_freq\", // Airtable's misspelling dies at this boundary\n \"testing freq\": \"testing_freq\",\n \"maintenance day\": \"maintenance_day\",\n \"testing day\": \"testing_day\",\n \"GA4 property ID\": \"ga4_property_id\",\n \"Search query\": \"search_query\",\n \"Search Console property\": \"search_console_property\",\n \"Git repo\": \"git_repo\",\n \"Netlify ID\": \"netlify_id\",\n \"Report recipients (To)\": \"report_recipients_to\",\n \"Report recipients (CC)\": \"report_recipients_cc\",\n \"Copy — Intro\": \"copy_intro\",\n \"Copy — Contact\": \"copy_contact\",\n \"Copy — Footer\": \"copy_footer\",\n \"Newsletter Webhook\": \"newsletter_webhook\",\n \"Mailchimp API Key\": \"mailchimp_api_key\",\n \"Mailchimp Audience ID\": \"mailchimp_audience_id\",\n \"Notify Routing\": \"notify_routing\",\n \"Prismic Ack Until\": \"prismic_ack_until\",\n \"Launched at\": \"launched_at\",\n // Non-text columns. They live here so `mirrorSiteField` can resolve them like\n // any other editor field; the coercion that makes them non-text is in\n // `siteValueFor`, which BOTH this importer and that mirror go through.\n \"Require Turnstile\": \"require_turnstile\",\n \"Accepted Watch Conditions\": \"accepted_watch_conditions\",\n};\n\n/** Normalize an `Accepted Watch Conditions` cell (Airtable array, or a delimited\n * string) to the trimmed list both stores agree on. */\nfunction normalizeAwc(raw: unknown): string[] {\n if (Array.isArray(raw)) {\n return raw\n .filter((x): x is string => typeof x === \"string\")\n .map((x) => x.trim())\n .filter(Boolean);\n }\n if (typeof raw === \"string\") {\n return raw\n .split(/[\\n,]/)\n .map((x) => x.trim())\n .filter(Boolean);\n }\n return [];\n}\n\n/**\n * Resolve one Airtable Websites cell to the value its `sites` column stores.\n *\n * The ONE shared path between `mapWebsiteRecord` (full-row import) and\n * `fleet-state.mirrorSiteField` (the editor's write-through) — same contract and\n * the same reason as `healthColumnFor`: a coercion that exists twice will\n * eventually disagree, and here a disagreement is not subtle. Parity compares\n * raw-to-raw, so a mirror storing `\"true\"` where the importer stores `1` reds\n * every hourly run until the next import quietly papers over it.\n */\nexport function siteValueFor(column: keyof SitesTable, raw: unknown): string | number | null {\n if (column === \"require_turnstile\") return b01(raw);\n if (column === \"accepted_watch_conditions\") {\n const awc = normalizeAwc(raw);\n return awc.length > 0 ? JSON.stringify(awc) : null;\n }\n return s(raw);\n}\n\n/** Direct field→column map for `site_health` (nightly-cron-owned). Exported for\n * the Phase 3 writer mirrors (fleet-state.mirrorHealthFields) and their\n * lockstep tests, so the nightly writers and the importer share ONE\n * Airtable-column → site_health-column truth. */\nexport const HEALTH_FIELDS: Record<string, keyof SiteHealthTable> = {\n pScore: \"p_score\",\n rScore: \"r_score\",\n bpScore: \"bp_score\",\n seoScore: \"seo_score\",\n \"Last lighthouse audit at\": \"lighthouse_at\",\n \"A11y Violations\": \"a11y_violations\",\n \"Deps Drifted\": \"deps_drifted\",\n \"Deps Major Behind\": \"deps_major_behind\",\n \"Deps Outdated\": \"deps_outdated\",\n \"Deps Major Outdated\": \"deps_major_outdated\",\n \"Security Vulns Critical\": \"vulns_critical\",\n \"Security Vulns High\": \"vulns_high\",\n \"Security Vulns Moderate\": \"vulns_moderate\",\n \"Security Vulns Low\": \"vulns_low\",\n \"Last security audit at\": \"security_audit_at\",\n \"Security advisories\": \"security_advisories\",\n \"Security Auto-Fix Attempts\": \"auto_fix_attempts\",\n \"Analytics soft-fail at\": \"analytics_soft_fail_at\",\n \"Cert days remaining\": \"cert_days_remaining\",\n \"Domain checked at\": \"domain_checked_at\",\n \"Deploy status\": \"deploy_status\",\n \"Last deploy at\": \"last_deploy_at\",\n \"Deploy log URL\": \"deploy_log_url\",\n \"Deploy checked at\": \"deploy_checked_at\",\n \"Function health\": \"function_health\",\n \"CMS Reachable\": \"cms_reachable\",\n \"Turnstile widget\": \"turnstile_widget\",\n \"Function health checked at\": \"function_health_checked_at\",\n \"Broken links\": \"broken_links\",\n \"Browser checked at\": \"browser_checked_at\",\n \"Uptime Reachable\": \"uptime_reachable\",\n \"Titles & Meta OK\": \"titles_meta_ok\",\n \"Smoke OK\": \"smoke_ok\",\n \"Last Smoke At\": \"last_smoke_at\",\n \"Form E2E OK\": \"form_e2e_ok\",\n \"Form E2E checked at\": \"form_e2e_checked_at\",\n \"Renovate Failing CIs\": \"renovate_failing_cis\",\n \"Default Branch CI\": \"default_branch_ci\",\n \"Last Commit At\": \"last_commit_at\",\n \"GitHub Signals At\": \"github_signals_at\",\n \"Prismic Models\": \"prismic_models\",\n \"Prismic Models Checked At\": \"prismic_models_checked_at\",\n \"Prismic Models Drift\": \"prismic_models_drift\",\n};\n\n/** Numeric health columns (everything else in HEALTH_FIELDS stores as text). */\nconst HEALTH_NUMERIC: ReadonlySet<keyof SiteHealthTable> = new Set([\n \"p_score\",\n \"r_score\",\n \"bp_score\",\n \"seo_score\",\n \"a11y_violations\",\n \"deps_drifted\",\n \"deps_major_behind\",\n \"deps_outdated\",\n \"deps_major_outdated\",\n \"vulns_critical\",\n \"vulns_high\",\n \"vulns_moderate\",\n \"vulns_low\",\n \"auto_fix_attempts\",\n \"cert_days_remaining\",\n \"broken_links\",\n \"renovate_failing_cis\",\n]);\n\n/** Boolean-checkbox health columns (Airtable true/absent → 1/0/null). */\nexport const HEALTH_BOOLEAN: Record<string, keyof SiteHealthTable> = {\n \"Crossbrowser OK\": \"crossbrowser_ok\",\n \"Mobile OK\": \"mobile_ok\",\n \"Links OK\": \"links_ok\",\n};\n\n/** Fields consumed by the schedule table. */\nexport const SCHEDULE_FIELDS: Record<string, keyof SiteScheduleTable> = {\n \"Next maintenance at\": \"next_maintenance_at\",\n \"Next testing at\": \"next_testing_at\",\n};\n\n/** Resolve one Airtable health field to its site_health column AND the exact\n * coercion the importer applies to it. The ONE shared path between\n * mapWebsiteRecord (full-row import) and fleet-state.mirrorHealthFields\n * (partial write-through): a coercion that exists twice will eventually\n * disagree, and the hourly import silently papers over the loser. Returns\n * null for a field no health column claims. */\nexport function healthColumnFor(\n field: string,\n): { col: keyof SiteHealthTable; coerce: (v: unknown) => string | number | null } | null {\n const direct = HEALTH_FIELDS[field];\n if (direct) return { col: direct, coerce: HEALTH_NUMERIC.has(direct) ? n : s };\n const bool = HEALTH_BOOLEAN[field];\n if (bool) return { col: bool, coerce: b01n };\n return null;\n}\n\n/** The schedule twin of {@link healthColumnFor} (both columns store as text). */\nexport function scheduleColumnFor(\n field: string,\n): { col: keyof SiteScheduleTable; coerce: (v: unknown) => string | null } | null {\n const col = SCHEDULE_FIELDS[field];\n return col ? { col, coerce: s } : null;\n}\n\nexport type MappedWebsite = {\n site: Omit<\n SitesTable,\n \"header_image\" | \"header_image_filename\" | \"header_image_type\" | \"header_image_generated_at\"\n >;\n health: SiteHealthTable;\n schedule: SiteScheduleTable;\n};\n\n/** Map one raw Websites record into its three-table shape. Pure. */\nexport function mapWebsiteRecord(rec: RawRecord, computedAt: string): MappedWebsite {\n const f = rec.fields;\n const name = s(f[\"Name\"]);\n if (!name) throw new Error(`Websites ${rec.id}: blank Name — cannot derive a slug`);\n const slug = siteSlug(name);\n if (!slug) throw new Error(`Websites ${rec.id}: Name \"${name}\" yields an empty slug`);\n\n const site: MappedWebsite[\"site\"] = {\n id: rec.id,\n slug,\n name,\n url: null,\n status: null,\n point_of_contact: null,\n maintenance_freq: null,\n testing_freq: null,\n maintenance_day: null,\n testing_day: null,\n ga4_property_id: null,\n search_query: null,\n search_console_property: null,\n git_repo: null,\n netlify_id: null,\n report_recipients_to: null,\n report_recipients_cc: null,\n copy_intro: null,\n copy_contact: null,\n copy_footer: null,\n newsletter_webhook: null,\n mailchimp_api_key: null,\n mailchimp_audience_id: null,\n notify_routing: null,\n require_turnstile: 0,\n accepted_watch_conditions: null,\n prismic_ack_until: null,\n launched_at: null,\n legacy: null,\n };\n // Every mapped column goes through siteValueFor, so the non-text ones\n // (require_turnstile, accepted_watch_conditions) get the SAME coercion the\n // editor's write-through mirror applies. The literals above are placeholders\n // this loop overwrites; they exist only to satisfy the row type.\n for (const [field, col] of Object.entries(SITE_FIELDS)) {\n if (col === \"name\") continue; // handled above (validated)\n (site as unknown as Record<string, unknown>)[col] = siteValueFor(col, f[field]);\n }\n\n const health = { site_id: rec.id } as SiteHealthTable;\n // Through healthColumnFor — the SAME resolution+coercion the Phase 3 writer\n // mirrors use, so import and write-through cannot drift apart.\n for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {\n const m = healthColumnFor(field);\n if (m) (health as unknown as Record<string, unknown>)[m.col] = m.coerce(f[field]);\n }\n\n const schedule: SiteScheduleTable = {\n site_id: rec.id,\n next_maintenance_at: s(f[\"Next maintenance at\"]),\n next_testing_at: s(f[\"Next testing at\"]),\n computed_at: computedAt,\n };\n\n // Everything populated that no table claims and no exclusion bans → legacy.\n const claimed = new Set<string>([\n \"Name\",\n ...Object.keys(SITE_FIELDS),\n ...Object.keys(HEALTH_FIELDS),\n ...Object.keys(HEALTH_BOOLEAN),\n ...Object.keys(SCHEDULE_FIELDS),\n ]);\n const legacy: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(f)) {\n if (claimed.has(field) || EXCLUDED_WEBSITE_FIELDS.has(field)) continue;\n legacy[field] = value;\n }\n site.legacy = Object.keys(legacy).length > 0 ? JSON.stringify(legacy) : null;\n\n return { site, health, schedule };\n}\n\n/** Stable-key checklist mapping (Airtable column name → key). */\nconst CHECKLIST_BY_FIELD: ReadonlyArray<{ field: string; key: string }> = [\n ...MAINTENANCE_CHECKLIST,\n ...TESTING_CHECKLIST,\n].map((i) => ({ field: i.field, key: i.key }));\n\n/** Map one raw Reports record. `renderedHtml` is fetched by the caller (the\n * attachment URL is an expiring signed URL — see the design's Attachments\n * section); pass null when the record has no attachment or the fetch failed. */\nexport function mapReportRecord(rec: RawRecord, renderedHtml: string | null): ReportsTable {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const checklist: Record<string, boolean> = {};\n for (const { field, key } of CHECKLIST_BY_FIELD) checklist[key] = Boolean(f[field]);\n\n return {\n id: rec.id,\n site_id: linkSites[0] ?? null,\n report_id: s(f[\"Report ID\"]),\n report_type: s(f[\"Report type\"]),\n period: s(f[\"Period\"]),\n period_start: s(f[\"Period start\"]),\n period_end: s(f[\"Period end\"]),\n completed_on: s(f[\"Completed on\"]),\n lighthouse_performance: n(f[\"Lighthouse — Performance\"]),\n lighthouse_accessibility: n(f[\"Lighthouse — Accessibility\"]),\n lighthouse_best_practices: n(f[\"Lighthouse — Best Practices\"]),\n lighthouse_seo: n(f[\"Lighthouse — SEO\"]),\n ga_users_current: n(f[\"GA users (period)\"]),\n ga_users_previous: n(f[\"GA users (prev period)\"]),\n search_found_page1: b01n(f[\"Search found page 1\"]),\n search_position: n(f[\"Search position\"]),\n last_tested_date: s(f[\"Last tested date\"]),\n commentary: s(f[\"Commentary\"]),\n subject_override: s(f[\"Subject override\"]),\n draft_ready: b01(f[\"Draft ready\"]),\n approved_to_send: b01(f[\"Approved to send\"]),\n approved_at: s(f[\"Approved At\"]),\n approved_by: s(f[\"Approved By\"]),\n send_override: b01(f[\"Send override\"]),\n override_reason: s(f[\"Override reason\"]),\n override_by: s(f[\"Override by\"]),\n override_at: s(f[\"Override at\"]),\n sent_at: s(f[\"Sent at\"]),\n delivery_status: s(f[\"Delivery status\"]) ?? \"pending\",\n resend_message_id: s(f[\"Resend message ID\"]),\n checklist: JSON.stringify(checklist),\n // The Airtable cell is a long-text field, so the API hands us a STRING of\n // JSON — store it verbatim. json() here would double-encode it, and\n // parseAutoEvidence on the read side would then parse to a string and\n // yield null (evidence silently lost). The non-string branch only exists\n // for defensive completeness.\n checklist_auto_evidence:\n typeof f[\"Checklist auto-evidence\"] === \"string\"\n ? s(f[\"Checklist auto-evidence\"])\n : json(f[\"Checklist auto-evidence\"] ?? undefined),\n rendered_html: renderedHtml,\n };\n}\n\n/** First Rendered HTML attachment's signed URL, or null. Exposed so the CLI can\n * fetch it and tests can assert which URL would be fetched. */\nexport function renderedHtmlUrl(rec: RawRecord): string | null {\n const atts = rec.fields[\"Rendered HTML\"] as Array<{ url?: string }> | undefined;\n const url = atts?.[0]?.url;\n return typeof url === \"string\" && url.length > 0 ? url : null;\n}\n\n/** What the import removed because Airtable no longer has the record — and what\n * it declined to remove. `refusals` is non-empty only when the incoming read\n * looked untrustworthy; in that case NOTHING was deleted for that table. */\nexport type ReapSummary = {\n sites: string[];\n reports: string[];\n refusals: string[];\n};\n\n/** Reaping is the only destructive thing the importer does, and it acts on the\n * absence of evidence — so it is only ever as trustworthy as the read that\n * motivated it. A short read (a truncated page, a partial outage) is\n * indistinguishable from mass deletion by looking at the rows alone, so the\n * allowance is deliberately shaped like an operator tidying up rather than\n * like a bad page: at most 10% of what is stored, with a floor of 5 so a small\n * table stays workable. Over that, refuse the whole table and let parity red\n * the run — a wedged-red sync is recoverable, an emptied Turso is not. */\nconst REAP_FLOOR = 5;\nconst REAP_FRACTION = 0.1;\n\nexport function reapAllowance(stored: number): number {\n return Math.max(REAP_FLOOR, Math.ceil(stored * REAP_FRACTION));\n}\n\n/** Human lines plus the FLEET_REAP machine line, emitted on EVERY pass (nothing\n * reaped included) — an absent line must read as \"the reap never ran\", never as\n * \"it ran clean\", the same contract FLEET_PARITY and FLEET_SYNC hold. Shared by\n * `db sync` and the one-shot `db import-airtable`: both can delete, so a second\n * hand-rolled copy is how one of them ends up doing it quietly. */\nexport function formatReapSummary(reaped: ReapSummary): string[] {\n const lines: string[] = [];\n for (const id of reaped.sites) {\n lines.push(`✂ reaped sites ${id} (+ its health/schedule rows) — no longer in Airtable`);\n }\n for (const id of reaped.reports) {\n lines.push(`✂ reaped reports ${id} — no longer in Airtable`);\n }\n for (const reason of reaped.refusals) lines.push(`⚠ ${reason}`);\n lines.push(\n `FLEET_REAP sites=${reaped.sites.length} reports=${reaped.reports.length} ` +\n `refused=${reaped.refusals.length}`,\n );\n return lines;\n}\n\n/** Null when the reap may proceed, else the human reason it was refused. */\nexport function reapRefusal(\n table: string,\n incoming: number,\n stored: number,\n toReap: number,\n): string | null {\n if (incoming === 0 && stored > 0) {\n return `${table}: REFUSED to reap — Airtable returned 0 rows while ${stored} are stored; that is a failed read, not an empty fleet. Nothing deleted.`;\n }\n const allowed = reapAllowance(stored);\n if (toReap > allowed) {\n return `${table}: REFUSED to reap — ${toReap} rows are missing from Airtable, over the ${allowed}-row allowance for ${stored} stored. Nothing deleted; re-run, and if the deletions are real, remove them in smaller batches.`;\n }\n return null;\n}\n\nexport type ImportSummary = {\n sites: number;\n reports: number;\n /** Rows removed (or refused) because their Airtable record is gone. */\n reaped: ReapSummary;\n /** Reports whose Rendered HTML attachment could not be fetched (URL expired /\n * network) — imported with rendered_html null, named so the run is honest. */\n renderedHtmlMisses: string[];\n /** Attachment fetches attempted and succeeded this run. */\n renderedHtmlFetched: number;\n /** Fetches skipped because the stored row already carries rendered_html\n * (only in reportHtml: \"when-missing\" mode — the hourly sync's mode). */\n renderedHtmlSkipped: number;\n};\n\nexport type ImportOptions = {\n /** \"always\" (default): fetch every report's Rendered HTML attachment — the\n * one-shot full import's mode. \"when-missing\": fetch only for reports whose\n * stored row has no rendered_html yet — the hourly sync's mode, so 24 runs a\n * day don't re-download every attachment every hour. Safe for Phase 2:\n * nothing reads reports.rendered_html from Turso until the Phase 4 report\n * review lands; revisit the staleness story there (a re-rendered report's\n * stored HTML goes stale under \"when-missing\"). Parity is unaffected —\n * rendered_html is a parity SKIP_COLUMN. */\n reportHtml?: \"always\" | \"when-missing\";\n};\n\nexport type ImportIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n listReportRecords: () => Promise<RawRecord[]>;\n /** Fetch an attachment body; null on failure (the miss is reported, not fatal). */\n fetchAttachment: (url: string) => Promise<string | null>;\n now: () => Date;\n};\n\n/**\n * Import both tables, idempotently: upsert by rec id, so a re-run converges\n * instead of duplicating. On conflict the sites upsert deliberately does NOT\n * touch the header_image* columns — Airtable stopped being their source (D5),\n * so a re-import must never wipe a regenerated image. rendered_html is only\n * overwritten when the fetch succeeded, for the same reason: a re-run with an\n * expired signed URL must not erase a body captured while it was valid.\n */\nexport async function importFleetState(\n db: Db,\n io: ImportIo,\n opts: ImportOptions = {},\n): Promise<ImportSummary> {\n const computedAt = io.now().toISOString();\n const websites = await io.listWebsiteRecords();\n\n const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));\n const slugOwner = new Map<string, string>();\n for (const m of mapped) {\n const prior = slugOwner.get(m.site.slug);\n if (prior)\n throw new Error(`slug collision: ${prior} and ${m.site.id} both map to '${m.site.slug}'`);\n slugOwner.set(m.site.slug, m.site.id);\n }\n\n for (const { site, health, schedule } of mapped) {\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n }\n\n const reports = await io.listReportRecords();\n // \"when-missing\": one pre-read of which report rows already hold a body, so\n // the loop can skip their attachment fetches. A skipped fetch keeps the\n // stored body (the upsert falls into the rowSansHtml branch) and is counted,\n // not treated as a miss.\n const alreadyStored =\n opts.reportHtml === \"when-missing\"\n ? new Set(\n (\n await db\n .selectFrom(\"reports\")\n .select(\"id\")\n .where(\"rendered_html\", \"is not\", null)\n .execute()\n ).map((r) => r.id),\n )\n : null;\n const misses: string[] = [];\n let fetched = 0;\n let skipped = 0;\n for (const rec of reports) {\n const url = renderedHtmlUrl(rec);\n const skip = url !== null && alreadyStored !== null && alreadyStored.has(rec.id);\n if (skip) skipped++;\n const html = url && !skip ? await io.fetchAttachment(url) : null;\n if (url && !skip && html === null) misses.push(rec.id);\n if (html !== null) fetched++;\n const row = mapReportRecord(rec, html);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(html !== null ? row : rowSansHtml))\n .execute();\n }\n\n const reaped: ReapSummary = { sites: [], reports: [], refusals: [] };\n\n // Sites first. No foreign keys are declared, so the health and schedule rows\n // must be removed explicitly — parity only reverse-checks `sites`, so an\n // orphan left here would linger forever, unnoticed and still readable.\n const liveSiteIds = new Set(mapped.map((m) => m.site.id));\n const storedSites = (await db.selectFrom(\"sites\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleSites = storedSites.filter((id) => !liveSiteIds.has(id)).sort();\n const siteRefusal = reapRefusal(\"sites\", websites.length, storedSites.length, staleSites.length);\n if (siteRefusal) reaped.refusals.push(siteRefusal);\n else if (staleSites.length > 0) {\n await db.deleteFrom(\"site_health\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"site_schedule\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"sites\").where(\"id\", \"in\", staleSites).execute();\n reaped.sites = staleSites;\n }\n\n const liveReportIds = new Set(reports.map((r) => r.id));\n const storedReports = (await db.selectFrom(\"reports\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleReports = storedReports.filter((id) => !liveReportIds.has(id)).sort();\n const reportRefusal = reapRefusal(\n \"reports\",\n reports.length,\n storedReports.length,\n staleReports.length,\n );\n if (reportRefusal) reaped.refusals.push(reportRefusal);\n else if (staleReports.length > 0) {\n await db.deleteFrom(\"reports\").where(\"id\", \"in\", staleReports).execute();\n reaped.reports = staleReports;\n }\n\n return {\n sites: mapped.length,\n reports: reports.length,\n renderedHtmlMisses: misses,\n renderedHtmlFetched: fetched,\n renderedHtmlSkipped: skipped,\n reaped,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
deleteBranch,
|
|
11
11
|
forceCheckoutBranch,
|
|
12
12
|
isWorkingTreeClean
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-WMF5S2MB.js";
|
|
14
14
|
|
|
15
15
|
// src/recipes/_with-recipe.ts
|
|
16
16
|
async function withRecipe(body) {
|
|
@@ -118,4 +118,4 @@ async function withRecipe(body) {
|
|
|
118
118
|
export {
|
|
119
119
|
withRecipe
|
|
120
120
|
};
|
|
121
|
-
//# sourceMappingURL=chunk-
|
|
121
|
+
//# sourceMappingURL=chunk-LUF52EIY.js.map
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
// src/reports/header-image/geometry.ts
|
|
2
|
+
var CANVAS = { width: 2400, height: 3200 };
|
|
3
|
+
var SCREEN = { x: 309, y: 1887, w: 1347, h: 841 };
|
|
4
|
+
var HEADLINE = { x: 277, y: 998 };
|
|
5
|
+
var HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 };
|
|
6
|
+
var HEADLINE_INK = { r: 209, g: 34, b: 62 };
|
|
7
|
+
var DOMAIN = {
|
|
8
|
+
x: 280,
|
|
9
|
+
baseline: 3020,
|
|
10
|
+
size: 80,
|
|
11
|
+
weight: 400,
|
|
12
|
+
color: "#747474"
|
|
13
|
+
};
|
|
14
|
+
var PAPER = "#fcfcfc";
|
|
15
|
+
|
|
1
16
|
// src/reports/header-image/assets/index.ts
|
|
2
17
|
import { readFile } from "fs/promises";
|
|
3
18
|
import { existsSync } from "fs";
|
|
@@ -98,23 +113,6 @@ async function defaultShooter() {
|
|
|
98
113
|
|
|
99
114
|
// src/reports/header-image/compose.ts
|
|
100
115
|
import sharp from "sharp";
|
|
101
|
-
|
|
102
|
-
// src/reports/header-image/geometry.ts
|
|
103
|
-
var CANVAS = { width: 2400, height: 3200 };
|
|
104
|
-
var SCREEN = { x: 302, y: 1913, w: 1349, h: 844 };
|
|
105
|
-
var HEADLINE = { x: 277, y: 998 };
|
|
106
|
-
var HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 };
|
|
107
|
-
var HEADLINE_INK = { r: 209, g: 34, b: 62 };
|
|
108
|
-
var DOMAIN = {
|
|
109
|
-
x: 280,
|
|
110
|
-
baseline: 3020,
|
|
111
|
-
size: 80,
|
|
112
|
-
weight: 400,
|
|
113
|
-
color: "#747474"
|
|
114
|
-
};
|
|
115
|
-
var PAPER = "#fcfcfc";
|
|
116
|
-
|
|
117
|
-
// src/reports/header-image/compose.ts
|
|
118
116
|
var JPEG_QUALITY = 88;
|
|
119
117
|
function escapeXml(s) {
|
|
120
118
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -214,7 +212,7 @@ async function applyReportTypeHeadline(header, reportType) {
|
|
|
214
212
|
const existing = await headlineInkCount(header);
|
|
215
213
|
if (existing > HEADLINE_INK_TOLERANCE) {
|
|
216
214
|
console.warn(
|
|
217
|
-
`\u2691 header headline skipped: the stored header already carries a headline (${existing} ink px in the band) \u2014 it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \`header-image ${"<site>"} --write-
|
|
215
|
+
`\u2691 header headline skipped: the stored header already carries a headline (${existing} ink px in the band) \u2014 it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \`header-image ${"<site>"} --write-back\` to regenerate it clean.`
|
|
218
216
|
);
|
|
219
217
|
return header;
|
|
220
218
|
}
|
|
@@ -244,10 +242,12 @@ async function generateHeaderImage(input) {
|
|
|
244
242
|
}
|
|
245
243
|
|
|
246
244
|
export {
|
|
245
|
+
CANVAS,
|
|
246
|
+
SCREEN,
|
|
247
247
|
loadPlate,
|
|
248
248
|
loadHeadline,
|
|
249
249
|
domainFromUrl,
|
|
250
250
|
applyReportTypeHeadline,
|
|
251
251
|
generateHeaderImage
|
|
252
252
|
};
|
|
253
|
-
//# sourceMappingURL=chunk-
|
|
253
|
+
//# sourceMappingURL=chunk-LZMAG73U.js.map
|