@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/fleet/ensure-site.ts"],"sourcesContent":["/**\n * `ensure-site`, Turso-native (#539 Phase 6 step 3, #646; operator decision\n * 2026-09-17).\n *\n * Find-or-create a site by SLUG so it exists in the fleet from day one. Turso is\n * the source of truth: a new site gets a minted `site_<ULID>` id and its three\n * rows (`sites`, `site_health`, `site_schedule`) in one transaction. The command\n * is re-run to RESUME a partial bootstrap, so everything keys on the slug — a\n * re-run finds the site and never mints a second id.\n *\n * Fill-blanks-only on the exists path: a resumed bootstrap must never clobber an\n * operator's edit. The one exception is `displayName` (#664): `--name` targets the\n * name and nothing else, so a differing value IS the operator's rename. A rename\n * changes `sites.name` only; `sites.slug` is the stable external handle and never\n * moves, which is why a name that would slugify differently is refused outright.\n *\n * ## The Airtable shadow for a NEW site: none\n *\n * A new site is not written to Airtable at all. An Airtable record would carry\n * its own `rec` id that is NOT the site id, and every batch job still enumerating\n * the fleet from Airtable (#646 step 4, not yet done) would then write Turso by\n * that `rec` id — an UPDATE matching no row, which under the freeze THROWS\n * (`mirrored=missed`). A half-visible site that reds the nightly sweeps is worse\n * than one those sweeps cannot see yet. The rollback week is over and a rollback\n * to Airtable has never been rehearsed, so there is no recovery the record would\n * be keeping trustworthy. Every Airtable shadow writer skips a `site_` id with an\n * `AIRTABLE_SHADOW skipped=non-rec-id` line (`src/fleet/site-id.ts`).\n *\n * The cost, named: until step 4 moves batch enumeration to Turso, a site created\n * here is invisible to the Airtable-enumerated jobs (audits incl. form-e2e,\n * report drafting, digest, github-signals, renovate-dispatch…). Form ingest,\n * the dashboard and every Turso reader see it immediately.\n *\n * ## Pre-existing `rec` sites\n *\n * Both id shapes coexist permanently. A `rec` site found in Turso keeps its id,\n * and its fill-blanks / rename is still shadowed to Airtable (Airtable stays the\n * shadow until step 6), Airtable first so a re-run converges both stores.\n *\n * The #645 heal survives in one form: when Turso has no row for the slug but\n * Airtable does, the Airtable record is ADOPTED under its own `rec` id instead of\n * minting a second identity for the same site. That lookup runs only when an\n * Airtable client is wired, and a lookup that FAILS refuses the create — minting\n * while unable to rule out a duplicate identity would be the one unrecoverable\n * outcome here.\n */\nimport { siteSlug } from \"./site-row.js\";\nimport { isAirtableRecordId, mintSiteId, skipsAirtableShadow } from \"./site-id.js\";\nimport type { NewSiteRow, SiteIdentityPatch } from \"../db/fleet-state.js\";\n\nexport type { SiteIdentityPatch } from \"../db/fleet-state.js\";\n\nexport type EnsureSiteInput = {\n /** Canonical slug; normalised through `siteSlug`. The stored `sites.slug`. */\n slug: string;\n /** Human display name. Written on create (default: the slug) and, unlike every\n * other input, UPDATED on an existing site when it differs (#664) — it is used\n * verbatim in client-facing copy (forms auto-reply intro, report subjects).\n * Must slugify to the same slug, or it is refused. */\n displayName?: string;\n url?: string;\n pointOfContact?: string;\n /** owner/repo. Default on create: `reddoorla/<slug>`. */\n gitRepo?: string;\n};\n\n/** The identity columns of an existing site, as `ensure-site` compares them. */\nexport type ExistingSite = {\n id: string;\n name: string;\n url: string | null;\n pointOfContact: string | null;\n gitRepo: string | null;\n};\n\n/** The Turso operations the creator needs. `src/db/site-create.ts` implements it. */\nexport type SiteStore = {\n findBySlug: (slug: string) => Promise<ExistingSite | null>;\n /** Insert all three rows atomically. Rejects with `code: \"SLUG_TAKEN\"` when the\n * `sites.slug` UNIQUE index refuses the row. */\n create: (site: NewSiteRow) => Promise<void>;\n /** Resolves whether a row matched. Never touches the slug (by type). */\n updateIdentity: (siteId: string, patch: SiteIdentityPatch) => Promise<boolean>;\n};\n\n/** The Airtable layer, as the creator still needs it until #646 step 6. Every\n * member is legacy: the lookup + adopt keep the #645 heal, `update` shadows a\n * `rec` site's fill. The CLI wires it only when Airtable credentials exist. */\nexport type LegacyAirtableSites = {\n findBySlug: (slug: string) => Promise<{ id: string; fields: Record<string, unknown> } | null>;\n /** Insert Turso rows for an Airtable record that has none (the #645 heal). */\n adopt: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n update: (recordId: string, patch: SiteIdentityPatch) => Promise<void>;\n};\n\nexport type EnsureSiteDeps = {\n store: SiteStore;\n airtable?: LegacyAirtableSites | null;\n /** Injectable for tests; defaults to {@link mintSiteId}. */\n mintId?: () => string;\n};\n\nexport type EnsureSiteResult = {\n status: \"created\" | \"exists\";\n siteId: string;\n /** Identity fields written on the exists path (`name` = a `--name` rename). */\n updatedFields: Array<keyof SiteIdentityPatch>;\n /** Inputs that DIFFERED from an existing non-blank value and were left untouched\n * (fill-blanks-only) — surfaced so a resumed bootstrap with a corrected value\n * does not silently discard it. */\n skippedMismatches: Array<keyof SiteIdentityPatch>;\n /** #645: the site existed in Airtable with NO Turso row, and this run adopted it.\n * Its leads were being answered `unknown-site` until now. */\n healedDbRow: boolean;\n /** What happened to the Airtable shadow: `skipped` for a `site_` id (created or\n * updated), `written` when a `rec` site's changes were shadowed, `none` when\n * there was nothing to shadow or no Airtable client was wired. */\n airtableShadow: \"skipped\" | \"written\" | \"none\";\n};\n\nconst slugTaken = (err: unknown): boolean =>\n typeof err === \"object\" && err !== null && (err as { code?: unknown }).code === \"SLUG_TAKEN\";\n\nexport async function ensureSite(\n input: EnsureSiteInput,\n deps: EnsureSiteDeps,\n): Promise<EnsureSiteResult> {\n const { store } = deps;\n const airtable = deps.airtable ?? null;\n const slug = siteSlug(input.slug);\n if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);\n if (input.displayName !== undefined && siteSlug(input.displayName) !== slug) {\n throw new Error(\n `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' — a rename never changes the slug`,\n );\n }\n\n let existing = await store.findBySlug(slug);\n let healedDbRow = false;\n\n if (!existing && airtable) {\n // Throws through on failure — see \"Pre-existing rec sites\" above.\n const legacy = await airtable.findBySlug(slug);\n if (legacy) {\n await airtable.adopt(legacy);\n existing = await store.findBySlug(slug);\n if (!existing) {\n throw new Error(\n `ensure-site: adopted Airtable record ${legacy.id} for '${slug}' but Turso still has no row for the slug`,\n );\n }\n healedDbRow = true;\n }\n }\n\n if (!existing) {\n const id = (deps.mintId ?? mintSiteId)();\n try {\n await store.create({\n id,\n slug,\n name: input.displayName ?? slug,\n status: \"building\",\n url: input.url ?? null,\n pointOfContact: input.pointOfContact ?? null,\n gitRepo: input.gitRepo ?? `reddoorla/${slug}`,\n });\n } catch (err) {\n if (!slugTaken(err)) throw err;\n const owner = await store.findBySlug(slug).catch(() => null);\n throw new Error(\n `ensure-site: slug '${slug}' is already taken${owner ? ` by site ${owner.id}` : \"\"} — it was created after this run looked; re-run to resume that site`,\n { cause: err },\n );\n }\n skipsAirtableShadow(\"ensureSite.create\", id);\n return {\n status: \"created\",\n siteId: id,\n updatedFields: [],\n skippedMismatches: [],\n healedDbRow: false,\n airtableShadow: \"skipped\",\n };\n }\n\n const patch: SiteIdentityPatch = {};\n const skippedMismatches: Array<keyof SiteIdentityPatch> = [];\n const consider = (\n key: \"url\" | \"pointOfContact\" | \"gitRepo\",\n provided: string | undefined,\n current: string | null,\n ) => {\n if (!provided) return;\n if (current === null || current === \"\") patch[key] = provided;\n else if (current !== provided) skippedMismatches.push(key);\n };\n consider(\"url\", input.url, existing.url);\n consider(\"pointOfContact\", input.pointOfContact, existing.pointOfContact);\n consider(\"gitRepo\", input.gitRepo, existing.gitRepo);\n if (input.displayName !== undefined && existing.name !== input.displayName) {\n patch.name = input.displayName;\n }\n\n const updatedFields = Object.keys(patch) as Array<keyof SiteIdentityPatch>;\n let airtableShadow: EnsureSiteResult[\"airtableShadow\"] = \"none\";\n if (updatedFields.length > 0) {\n if (skipsAirtableShadow(\"ensureSite.update\", existing.id)) {\n airtableShadow = \"skipped\";\n } else if (airtable && isAirtableRecordId(existing.id)) {\n await airtable.update(existing.id, patch);\n airtableShadow = \"written\";\n }\n if (!(await store.updateIdentity(existing.id, patch))) {\n throw new Error(`ensure-site: site ${existing.id} vanished from Turso mid-run`);\n }\n }\n return {\n status: \"exists\",\n siteId: existing.id,\n updatedFields,\n skippedMismatches,\n healedDbRow,\n airtableShadow,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
openBase,
|
|
3
|
-
readAirtableConfig
|
|
4
|
-
} from "./chunk-HN6I2XWI.js";
|
|
5
1
|
import {
|
|
6
2
|
WEBSITES_TABLE,
|
|
7
3
|
mapRow,
|
|
8
|
-
siteSlug,
|
|
9
4
|
toAirtableStatus
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import
|
|
5
|
+
} from "./chunk-TC4GANMX.js";
|
|
6
|
+
import {
|
|
7
|
+
siteSlug
|
|
8
|
+
} from "./chunk-ICSRWW5Q.js";
|
|
9
|
+
import "./chunk-RQJFGK3A.js";
|
|
12
10
|
|
|
13
11
|
// src/reports/airtable/ensure-site.ts
|
|
14
12
|
var COLS = {
|
|
@@ -16,6 +14,17 @@ var COLS = {
|
|
|
16
14
|
pointOfContact: "point of contact",
|
|
17
15
|
gitRepo: "Git repo"
|
|
18
16
|
};
|
|
17
|
+
async function findWebsiteRecordBySlug(base, slug) {
|
|
18
|
+
let found = null;
|
|
19
|
+
await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
20
|
+
for (const rec of records) {
|
|
21
|
+
if (!found && siteSlug(mapRow(rec).name) === slug)
|
|
22
|
+
found = { id: rec.id, fields: rec.fields };
|
|
23
|
+
}
|
|
24
|
+
fetchNextPage();
|
|
25
|
+
});
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
19
28
|
async function ensureSite(base, input, mirror) {
|
|
20
29
|
const slug = siteSlug(input.slug);
|
|
21
30
|
if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);
|
|
@@ -85,40 +94,8 @@ async function ensureSite(base, input, mirror) {
|
|
|
85
94
|
}
|
|
86
95
|
return { status: "exists", siteId: existing.id, updatedFields, skippedMismatches, healedDbRow };
|
|
87
96
|
}
|
|
88
|
-
|
|
89
|
-
// src/cli/commands/ensure-site.ts
|
|
90
|
-
async function runEnsureSiteCommand(slug, opts) {
|
|
91
|
-
if (!slug) return { output: "Provide a <slug> (e.g. `ensure-site roalson`).", code: 2 };
|
|
92
|
-
try {
|
|
93
|
-
const base = openBase(readAirtableConfig());
|
|
94
|
-
const { makeSiteMirror } = await import("./site-mirror-H2FYMN55.js");
|
|
95
|
-
const result = await ensureSite(
|
|
96
|
-
base,
|
|
97
|
-
{
|
|
98
|
-
slug,
|
|
99
|
-
...opts.name ? { displayName: opts.name } : {},
|
|
100
|
-
...opts.url ? { url: opts.url } : {},
|
|
101
|
-
...opts.contact ? { pointOfContact: opts.contact } : {},
|
|
102
|
-
...opts.gitRepo ? { gitRepo: opts.gitRepo } : {}
|
|
103
|
-
},
|
|
104
|
-
await makeSiteMirror()
|
|
105
|
-
);
|
|
106
|
-
const renamed = result.updatedFields.includes("Name") ? ` \u2014 Name set to "${opts.name}"` : "";
|
|
107
|
-
const blanks = result.updatedFields.filter((f) => f !== "Name");
|
|
108
|
-
const filled = blanks.length > 0 ? ` \u2014 filled blank field(s): ${blanks.join(", ")}` : "";
|
|
109
|
-
const skipped = result.skippedMismatches.length > 0 ? ` \u2014 differs from existing, left untouched (edit in Airtable): ${result.skippedMismatches.join(", ")}` : "";
|
|
110
|
-
const healed = result.healedDbRow ? ` \u2014 HEALED: the Turso row was MISSING and has been inserted; leads for this slug were being dropped (run \`db replay-deadletters\`)` : "";
|
|
111
|
-
const nameNote = result.status === "created" && !opts.name ? ` \u2014 Name set to "${slug}"; retitle in Airtable before forms/announce go live (or re-run with --name)` : "";
|
|
112
|
-
return {
|
|
113
|
-
output: `[${slug}] ${result.status} (${result.siteId})${healed}${renamed}${filled}${skipped}${nameNote}`,
|
|
114
|
-
code: 0
|
|
115
|
-
};
|
|
116
|
-
} catch (err) {
|
|
117
|
-
const e = err;
|
|
118
|
-
return { output: e.message ?? String(err), code: e.exitCode ?? 1 };
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
97
|
export {
|
|
122
|
-
|
|
98
|
+
ensureSite,
|
|
99
|
+
findWebsiteRecordBySlug
|
|
123
100
|
};
|
|
124
|
-
//# sourceMappingURL=ensure-site-
|
|
101
|
+
//# sourceMappingURL=ensure-site-OO4UPWNQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/airtable/ensure-site.ts"],"sourcesContent":["import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport { WEBSITES_TABLE, mapRow, siteSlug, type WebsiteRow } from \"./websites.js\";\nimport { toAirtableStatus } from \"./site-status.js\";\n\nexport type EnsureSiteInput = {\n /** Canonical slug — matches by siteSlug(Name); the Name on create when no displayName given. */\n slug: string;\n /** Human display name written to Name on create, and — unlike every other\n * input — UPDATED on an existing row when it differs (#664). Name is\n * consumed VERBATIM in client-facing copy (forms auto-reply intro, report\n * subjects), so a bare machine slug there reads as \"Thanks for reaching out\n * to acme-co.\" — and the row that has it is exactly the one created before\n * the name was settled, which only this command re-run can fix. Must\n * slugify to the same slug or the row wouldn't be found on re-run. */\n displayName?: string;\n url?: string;\n pointOfContact?: string;\n /** owner/repo. Default on create: `reddoorla/<slug>`. */\n gitRepo?: string;\n};\n\n/** #539 Phase 5. Structurally typed rather than importing the db layer's\n * `SiteMirror`, so this module stays the leaf it has always been — a real\n * `SiteMirror` satisfies it. Two ops because `ensureSite` has two write paths,\n * and the create one is the reason this exists: every other site mirror is an\n * UPDATE, which does nothing for a row that does not exist yet. */\nexport type EnsureSiteMirror = {\n created: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n site: (siteId: string, fields: Record<string, unknown>) => Promise<void>;\n /** #645. Does Turso hold a row for this site id? Optional so every pre-#645\n * caller (and every hand-built test mirror) is byte-for-byte unchanged —\n * absent means the heal below never runs. */\n hasRow?: (siteId: string) => Promise<boolean>;\n};\n\nexport type EnsureSiteResult = {\n status: \"created\" | \"exists\";\n siteId: string;\n /** Airtable column names written on the exists path (create path writes all). */\n updatedFields: string[];\n /** Inputs that DIFFERED from an existing non-blank cell and were left\n * untouched (fill-blanks-only) — surfaced so a resumed bootstrap with a\n * corrected value doesn't silently discard it. */\n skippedMismatches: string[];\n /** #645. True when the site existed in Airtable but had NO Turso row and this\n * run inserted one. Surfaced rather than logged-only because it is the\n * difference between \"nothing to do\" and \"this site's leads were being\n * dropped until just now\". */\n healedDbRow: boolean;\n};\n\n/** Column-name map (load-bearing magic strings — see websites.ts's mapRow header). */\nconst COLS = {\n url: \"url\",\n pointOfContact: \"point of contact\",\n gitRepo: \"Git repo\",\n} as const;\n\n/**\n * One select over Websites, returning the RAW stored record whose Name slugifies\n * to `slug` (#646 step 3). The Turso-native creator (`src/fleet/ensure-site.ts`)\n * uses it for the #645 heal only — to ADOPT an Airtable site that has no Turso row\n * under its own `rec` id instead of minting a second identity for it. Raw, not a\n * mapped `WebsiteRow`, because the adopt re-inserts what Airtable STORED.\n */\nexport async function findWebsiteRecordBySlug(\n base: AirtableBase,\n slug: string,\n): Promise<{ id: string; fields: Record<string, unknown> } | null> {\n let found: { id: string; fields: Record<string, unknown> } | null = null;\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) {\n if (!found && siteSlug(mapRow(rec).name) === slug)\n found = { id: rec.id, fields: rec.fields };\n }\n fetchNextPage();\n });\n return found;\n}\n\n/**\n * @deprecated since #646 step 3 — the `ensure-site` command no longer calls this.\n * New sites are created in Turso by `src/fleet/ensure-site.ts` with `site_<ULID>`\n * ids and no Airtable record. Kept, not deleted, because removing anything from\n * the Airtable layer is #646 steps 6–8, which need their own go.\n *\n * Find-or-create the Websites row for a slug so a new site exists in the fleet\n * inventory (audits, form-ingest slug resolution, reports) from day one.\n *\n * Fill-blanks-only on the exists path: this command runs from a bootstrap skill\n * that may be re-run to resume — it must never clobber operator-edited cells.\n * The one exception is `displayName` (#664): `--name` targets Name and nothing\n * else, so a differing value IS the operator's edit, and the create message\n * sends them back here to make it. Frequencies are deliberately NOT set\n * (launch flips the lifecycle); Status is only written on create (\"building\" —\n * Airtable's \"in development\" until the stage-2 vocabulary switch flips).\n */\nexport async function ensureSite(\n base: AirtableBase,\n input: EnsureSiteInput,\n mirror?: EnsureSiteMirror,\n): Promise<EnsureSiteResult> {\n const slug = siteSlug(input.slug);\n if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);\n if (input.displayName && siteSlug(input.displayName) !== slug)\n throw new Error(\n `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' — the row would not be found on re-run`,\n );\n\n // Paged inline rather than through `listWebsites` so the RAW stored record\n // survives alongside the mapped row: the #645 heal below re-inserts what\n // Airtable STORED (the same contract as the create path's mirror), and a\n // mapped `WebsiteRow` cannot be turned back into a FieldSet. Same single\n // select `listWebsites` issues — no extra Airtable read.\n const raw: Array<{ id: string; fields: Record<string, unknown> }> = [];\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) raw.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n let existing: WebsiteRow | undefined;\n let existingRaw: { id: string; fields: Record<string, unknown> } | undefined;\n for (const rec of raw) {\n const row = mapRow(rec);\n if (siteSlug(row.name) === slug) {\n existing = row;\n existingRaw = rec;\n break;\n }\n }\n\n if (!existing || !existingRaw) {\n const fields: FieldSet = {\n Name: input.displayName ?? slug,\n Status: toAirtableStatus(\"building\"),\n [COLS.gitRepo]: input.gitRepo ?? `reddoorla/${slug}`,\n };\n if (input.url) fields[COLS.url] = input.url;\n if (input.pointOfContact) fields[COLS.pointOfContact] = input.pointOfContact;\n const created = (await base(WEBSITES_TABLE).create([{ fields }])) as Records<FieldSet>;\n const rec = created[0]!;\n // The mirror gets what Airtable STORED, never the `fields` we sent: parity\n // diffs Turso against the stored record, so mapping our own payload would\n // diverge the moment Airtable normalises a value.\n await mirror?.created({ id: rec.id, fields: rec.fields as Record<string, unknown> });\n return {\n status: \"created\",\n siteId: rec.id,\n updatedFields: [],\n skippedMismatches: [],\n // The create path just inserted the row; there is nothing to heal, and a\n // `hasRow` probe here would be a round-trip whose answer is already known.\n healedDbRow: false,\n };\n }\n\n // #645. THE gap: before this, `exists` meant \"Airtable has it\" and said nothing\n // about Turso. Post-flip (#643) the form-ingest lookup reads Turso ONLY, so a\n // site with an Airtable row and no Turso row has every lead answered\n // `unknown-site` — silently, permanently, and with no alarm once Phase 6 (#646)\n // deletes `mirror_missed`. `ensure-site` is the command an operator already\n // re-runs to finish a half-created site, so the heal belongs here.\n //\n // FIRST, before the fill-blanks update below: `mirror.site` is an UPDATE, and\n // under the freeze a no-match THROWS (`SITE_MIRROR … no such row in Turso`).\n // Healing second would abort ensure-site on exactly the site it exists to\n // repair. `mirrorSiteInsert` is an upsert, so a lost race just re-writes.\n let healedDbRow = false;\n if (mirror?.hasRow && !(await mirror.hasRow(existingRaw.id))) {\n await mirror.created(existingRaw);\n healedDbRow = true;\n }\n\n const updates: FieldSet = {};\n const skippedMismatches: string[] = [];\n const blank = (v: unknown) => v === null || v === undefined || v === \"\";\n const consider = (column: string, provided: string | undefined, current: unknown) => {\n if (!provided) return;\n if (blank(current)) updates[column] = provided;\n else if (current !== provided) skippedMismatches.push(column);\n };\n consider(COLS.url, input.url, existing.url || null);\n consider(COLS.pointOfContact, input.pointOfContact, existing.pointOfContact);\n consider(COLS.gitRepo, input.gitRepo, existing.gitRepo);\n // #664: Name is NOT fill-blanks. It is never blank (the slug match found it),\n // and `--name` is the only way to retitle a row created before the display\n // name was settled — the slugify guard above already proved the new value\n // resolves to the same row. Goes through the same update + mirror below, so\n // Turso gets it too.\n if (input.displayName && existing.name !== input.displayName) {\n updates[\"Name\"] = input.displayName;\n }\n\n const updatedFields = Object.keys(updates);\n if (updatedFields.length > 0) {\n await base(WEBSITES_TABLE).update([{ id: existing.id, fields: updates }]);\n // The fill-blanks path writes real cells too — a resumed bootstrap that only\n // filled a blank `url` would otherwise leave Turso stale until the sync.\n await mirror?.site(existing.id, updates);\n }\n return { status: \"exists\", siteId: existing.id, updatedFields, skippedMismatches, healedDbRow };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import * as airtable_lib_airtable_base_js from 'airtable/lib/airtable_base.js';
|
|
2
|
+
|
|
3
|
+
type AirtableConfig = {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
baseId: string;
|
|
6
|
+
};
|
|
7
|
+
type AirtableBase = ReturnType<typeof openBase>;
|
|
8
|
+
declare function openBase(cfg: AirtableConfig): airtable_lib_airtable_base_js.AirtableBase;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The site-status vocabulary — ONE module owns it (#539 Phase 4).
|
|
12
|
+
*
|
|
13
|
+
* It lived at `src/reports/airtable/site-status.ts` until #539 Phase 6 step 1
|
|
14
|
+
* (#646) moved it here: `canonicalizeStatus` runs on every Turso lead read, so
|
|
15
|
+
* it cannot sit in the directory Phase 6 deletes. The Airtable write seam
|
|
16
|
+
* `toAirtableStatus`, discussed below, stayed behind in that file, which also
|
|
17
|
+
* re-exports everything here.
|
|
18
|
+
*
|
|
19
|
+
* The fleet's lifecycle names were inherited from an Airtable single-select
|
|
20
|
+
* written by hand years ago ("probably not our problem"). The operator approved
|
|
21
|
+
* a canonical vocabulary, and the migration is now COMPLETE in both stores:
|
|
22
|
+
* Airtable's "Status" single-select carries exactly the six canonical options
|
|
23
|
+
* and nothing else. This module no longer knows the old names at all.
|
|
24
|
+
*
|
|
25
|
+
* The retired mapping, kept as the record of what was merged into what:
|
|
26
|
+
*
|
|
27
|
+
* old (retired 2026-08-25) new (canonical)
|
|
28
|
+
* ───────────────────────── ───────────────
|
|
29
|
+
* in development building
|
|
30
|
+
* launch period launching
|
|
31
|
+
* maintenance maintained
|
|
32
|
+
* hosting hosted-only
|
|
33
|
+
* probably not our problem external
|
|
34
|
+
* legacy ┐
|
|
35
|
+
* deprecated ┴ archived ← approved MERGE, many-to-one
|
|
36
|
+
*
|
|
37
|
+
* `legacy` and `deprecated` were already treated identically by every PREDICATE
|
|
38
|
+
* (ARCHIVED_STATUSES held both), so collapsing them changes no *selection* — no
|
|
39
|
+
* fleet op gains or loses a site. It is NOT true that collapsing them changes
|
|
40
|
+
* nothing at all, and one surface proved it: the cockpit's Archived lane LABELS
|
|
41
|
+
* each row with its Status cell, and canonicalizing there rendered all 12 live
|
|
42
|
+
* archived rows as "archived". That lane now labels from `statusRaw`, which is
|
|
43
|
+
* exactly why `WebsiteRow.statusRaw` exists. The rule this leaves behind:
|
|
44
|
+
* canonical values decide BEHAVIOUR; anything DISPLAYING a Status cell, or
|
|
45
|
+
* writing one back, uses the raw cell.
|
|
46
|
+
*
|
|
47
|
+
* The merge had NO clean reverse map while both vocabularies coexisted, which
|
|
48
|
+
* is why `toAirtableStatus` had to pick one archived name deliberately. With the
|
|
49
|
+
* old names retired there is nothing left to pick between, and no code path
|
|
50
|
+
* feeds operator-supplied text through it anyway (see
|
|
51
|
+
* `src/recipes/forms-notify-target.ts`, which writes `--restore` verbatim).
|
|
52
|
+
*
|
|
53
|
+
* THE SHAPE OF THE TRANSITION (three stages):
|
|
54
|
+
*
|
|
55
|
+
* stage 1 (DONE) code speaks the NEW vocabulary internally; reads canonicalize
|
|
56
|
+
* at the two Airtable/Turso seams; writes still emit the OLD
|
|
57
|
+
* Airtable option names. Airtable untouched. No SELECTION
|
|
58
|
+
* change — every predicate picks exactly the sites it picked
|
|
59
|
+
* before (pinned row-by-row in tests/reports/airtable/
|
|
60
|
+
* site-status.test.ts). Displayed labels come from `statusRaw`.
|
|
61
|
+
* stage 2 (DONE) the Airtable "Status" single-select gained the 6 new options
|
|
62
|
+
* and all 44 cells were migrated (verified live: maintained=13,
|
|
63
|
+
* archived=12, external=9, building=6, hosted-only=2,
|
|
64
|
+
* launching=2 — zero old values, zero unrecognized), `db sync`
|
|
65
|
+
* ran clean (mismatches=0), and AIRTABLE_USES_NEW_VOCABULARY
|
|
66
|
+
* flipped to true. Writers now emit canonical names verbatim.
|
|
67
|
+
* The alias map STAYS: the 7 old options still exist in the
|
|
68
|
+
* field, so a human can still pick one in the Airtable UI.
|
|
69
|
+
* stage 3 (DONE) the operator deleted the 7 old options from the Airtable
|
|
70
|
+
* "Status" field on 2026-08-25, which is what made this safe:
|
|
71
|
+
* the gate was always "no old value CAN BE ENTERED", not
|
|
72
|
+
* merely "none is stored". Verified against the live base
|
|
73
|
+
* before any code changed — the field carries exactly the six
|
|
74
|
+
* canonical choices, byte-exact, and all 44 cells still hold a
|
|
75
|
+
* canonical value with ZERO blanks (deleting a single-select
|
|
76
|
+
* option CLEARS the cells using it, so "no cell was blanked"
|
|
77
|
+
* was checked, not assumed). AIRTABLE_STATUS_ALIASES,
|
|
78
|
+
* AIRTABLE_OLD_NAMES and AIRTABLE_USES_NEW_VOCABULARY are
|
|
79
|
+
* gone; both functions reduce to the identity.
|
|
80
|
+
*
|
|
81
|
+
* WHAT CHANGED IN BEHAVIOUR AT STAGE 3, and it is the only thing that did: an
|
|
82
|
+
* old name is no longer translated. `canonicalizeStatus("maintenance")` used to
|
|
83
|
+
* yield `maintained`; it now yields `"maintenance"` verbatim, which
|
|
84
|
+
* `isUnrecognizedStatus` flags and the cockpit surfaces as a watch row. That is
|
|
85
|
+
* intended. The option cannot be selected any more, so a stored old value would
|
|
86
|
+
* mean something went wrong — a restored backup, a scripted write, an API
|
|
87
|
+
* caller with a stale constant — and the fleet should say so rather than absorb
|
|
88
|
+
* it into a status nobody chose.
|
|
89
|
+
*
|
|
90
|
+
* Canonicalization happens on READ, never at rest: `src/db/import-airtable.ts`
|
|
91
|
+
* still stores the raw Airtable cell verbatim in `sites.status`, so the hourly
|
|
92
|
+
* parity check keeps comparing raw-to-raw and the importer stays honest to its
|
|
93
|
+
* source. Both readers — `mapRow` (Airtable) and `rowFromJoined` (Turso) — run
|
|
94
|
+
* the raw value through `canonicalizeStatus`, which is what keeps the #558
|
|
95
|
+
* reader-equivalence instrument green.
|
|
96
|
+
*/
|
|
97
|
+
/** The canonical site lifecycle vocabulary. */
|
|
98
|
+
type Status = "building" | "launching" | "maintained" | "hosted-only" | "external" | "archived";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The fleet's site-row model and its pure, vendor-neutral coercers.
|
|
102
|
+
*
|
|
103
|
+
* `WebsiteRow` is what BOTH readers return — `mapRow` over an Airtable record
|
|
104
|
+
* (`src/reports/airtable/websites.ts`) and `rowFromJoined` over Turso
|
|
105
|
+
* (`src/db/fleet-state.ts`) — and the coercers below are the ONE truth each of
|
|
106
|
+
* them applies to a raw cell/column. They lived in the Airtable module until
|
|
107
|
+
* #539 Phase 6 step 1 (#646) moved them here: they run on every Turso lead read,
|
|
108
|
+
* so they could not stay in the directory Phase 6 deletes. Nothing here touches
|
|
109
|
+
* a store; the doc comments still name Airtable columns because that is where
|
|
110
|
+
* each value was first defined, and the Turso columns carry the same raw values.
|
|
111
|
+
*
|
|
112
|
+
* `src/reports/airtable/websites.ts` re-exports every name, so existing imports
|
|
113
|
+
* resolve unchanged.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
type Frequency = "None" | "Monthly" | "Quarterly" | "Yearly";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-site notification routing. When present on a `maintained` site, the form
|
|
120
|
+
* notification is addressed by the value of a submission field (`field`, read from
|
|
121
|
+
* `extraFields`) — e.g. route a contact form's `interest` to a different recipient
|
|
122
|
+
* per option, always CC-ing a shared address. Absent (`null`) → the site keeps the
|
|
123
|
+
* default single-POC behavior. Recipients live HERE (server-side Airtable config),
|
|
124
|
+
* never supplied by the submitting site, so the ingest can't be turned into an open
|
|
125
|
+
* relay.
|
|
126
|
+
*/
|
|
127
|
+
type NotifyRouting = {
|
|
128
|
+
/** The `extraFields` key whose value selects a route, e.g. "interest". */
|
|
129
|
+
field: string;
|
|
130
|
+
/** Field-value → recipient address(es). */
|
|
131
|
+
routes: Record<string, string | string[]>;
|
|
132
|
+
/** Recipient(s) when the value matches no route. */
|
|
133
|
+
default?: string | string[];
|
|
134
|
+
/** Address(es) CC'd on every routed (maintenance) send. */
|
|
135
|
+
cc?: string[];
|
|
136
|
+
};
|
|
137
|
+
type WebsiteRow = {
|
|
138
|
+
id: string;
|
|
139
|
+
name: string;
|
|
140
|
+
url: string;
|
|
141
|
+
/** Canonical lifecycle status (see ./site-status.ts). Canonicalized at the read
|
|
142
|
+
* boundary, so an old-vocabulary Airtable cell and a new one read identically.
|
|
143
|
+
* A present-but-unrecognized cell survives VERBATIM (blind-cast) rather than
|
|
144
|
+
* becoming null — `isUnrecognizedStatus` flags it, and null would make
|
|
145
|
+
* due.ts/preflight.ts treat the row as eligible-by-default. */
|
|
146
|
+
status: Status | null;
|
|
147
|
+
/** The literal Airtable Status cell behind `status` — same pattern as
|
|
148
|
+
* `maintenanceFreqRaw`. The dashboard status editor round-trips THIS, so its
|
|
149
|
+
* dropdown offers and preselects the values Airtable actually holds while the
|
|
150
|
+
* two vocabularies coexist. Null = blank cell. */
|
|
151
|
+
statusRaw: string | null;
|
|
152
|
+
pointOfContact: string | null;
|
|
153
|
+
maintenanceFreq: Frequency;
|
|
154
|
+
testingFreq: Frequency;
|
|
155
|
+
/** The literal Airtable cell values behind the coerced frequencies. `toFrequency`
|
|
156
|
+
* trims whitespace ("Monthly " reads as Monthly), then maps any still-unrecognized
|
|
157
|
+
* value ("Quaterly") to "None" with a LOUD console.warn so nothing bogus reaches a
|
|
158
|
+
* client email and the drop is never invisible. Preflight validates THESE to surface
|
|
159
|
+
* the same failure as a structured finding. Null = blank cell. */
|
|
160
|
+
maintenanceFreqRaw: string | null;
|
|
161
|
+
testingFreqRaw: string | null;
|
|
162
|
+
/** Last manually-recorded maintenance day (used as fallback when no Reports row exists). */
|
|
163
|
+
maintenanceDay: string | null;
|
|
164
|
+
testingDay: string | null;
|
|
165
|
+
ga4PropertyId: string | null;
|
|
166
|
+
/** Operator-supplied query for the Google search-presence check (e.g. the business name).
|
|
167
|
+
* Null = no query set → the check is skipped for this site. */
|
|
168
|
+
searchQuery: string | null;
|
|
169
|
+
/** Explicit Search Console property for this site (`sc-domain:...` or `https://.../`).
|
|
170
|
+
* Null = auto-resolve from the SA's visible properties by host. */
|
|
171
|
+
searchConsoleProperty: string | null;
|
|
172
|
+
/** ISO timestamp of the last draft run where THIS site's GA/Search enrichment
|
|
173
|
+
* ERRORED (vs a legitimate "not configured" skip). Set by drafting on a soft-fail,
|
|
174
|
+
* cleared (null) on a clean enrichment, so the per-site analytics-failure signal
|
|
175
|
+
* self-heals. Null when the operator-added `Analytics soft-fail at` column is absent. */
|
|
176
|
+
analyticsSoftFailAt: string | null;
|
|
177
|
+
/** GitHub repo identity as `owner/repo`. Null = no git wiring → self-update ops skip
|
|
178
|
+
* (or, for local runs, fall back to the checkout's origin remote). */
|
|
179
|
+
gitRepo: string | null;
|
|
180
|
+
reportRecipientsTo: string | null;
|
|
181
|
+
reportRecipientsCc: string | null;
|
|
182
|
+
acceptedWatchConditions: string[];
|
|
183
|
+
/** First attachment in the Header image field (Airtable's signed URL — fetch before expiry). */
|
|
184
|
+
headerImage: {
|
|
185
|
+
url: string;
|
|
186
|
+
filename: string;
|
|
187
|
+
type: string;
|
|
188
|
+
} | null;
|
|
189
|
+
/** Lighthouse "current state" snapshot, kept fresh by `audit lighthouse --write-back`. */
|
|
190
|
+
pScore: number | null;
|
|
191
|
+
rScore: number | null;
|
|
192
|
+
bpScore: number | null;
|
|
193
|
+
seoScore: number | null;
|
|
194
|
+
/** ISO timestamp set by `audit lighthouse --write-back` when scores were last refreshed. */
|
|
195
|
+
lastLighthouseAuditAt: string | null;
|
|
196
|
+
/** Last-known counts from non-lighthouse audits, written by
|
|
197
|
+
* `audit --write-back`. `null` = never audited (or this audit
|
|
198
|
+
* type was skipped on the last run). 0 = audited, clean. */
|
|
199
|
+
a11yViolations: number | null;
|
|
200
|
+
/** Declared-range drift vs the Reddoor baseline (what package.json asks for). */
|
|
201
|
+
depsDrifted: number | null;
|
|
202
|
+
depsMajorBehind: number | null;
|
|
203
|
+
/** Real installed-version drift: deps behind the registry's latest, from the
|
|
204
|
+
* committed lockfile (`pnpm outdated`). Null = not determined this run. */
|
|
205
|
+
depsOutdated: number | null;
|
|
206
|
+
/** Of {@link depsOutdated}, how many are a *major* version behind the
|
|
207
|
+
* registry's latest — the "majors available on npm" signal, distinct from the
|
|
208
|
+
* baseline-drift {@link depsMajorBehind}. Null = not determined this run. */
|
|
209
|
+
depsMajorOutdated: number | null;
|
|
210
|
+
securityVulnsCritical: number | null;
|
|
211
|
+
securityVulnsHigh: number | null;
|
|
212
|
+
securityVulnsModerate: number | null;
|
|
213
|
+
securityVulnsLow: number | null;
|
|
214
|
+
/** Count of consecutive nightly Renovate auto-fix dispatches for the CURRENT
|
|
215
|
+
* critical/high vuln episode that have NOT yet cleared it. Owned by
|
|
216
|
+
* `renovate-dispatch`: +1 per real dispatch, reset to 0 when vulns clear.
|
|
217
|
+
* Null = field absent / never dispatched → reads as 0. At/above
|
|
218
|
+
* AUTO_FIX_EXHAUSTED_CYCLES the vuln renders as "auto-fix failed". */
|
|
219
|
+
securityAutoFixAttempts: number | null;
|
|
220
|
+
/** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick
|
|
221
|
+
* (clean counts only auto-tick when recent). */
|
|
222
|
+
lastSecurityAuditAt: string | null;
|
|
223
|
+
/** The known advisories behind the counts (severity-sorted, capped), so the dashboard can show
|
|
224
|
+
* WHICH packages are vulnerable, not just the totals. null = never audited / unparseable;
|
|
225
|
+
* empty array = audited clean. Written alongside the counts by the security audit. */
|
|
226
|
+
securityAdvisories: SecurityAdvisory[] | null;
|
|
227
|
+
/** Domain/DNS/SSL probe (the `domain` audit). `certDaysRemaining` is days until the TLS cert
|
|
228
|
+
* expires (null = unresolved or no usable cert); `domainCheckedAt` is when it last ran. */
|
|
229
|
+
certDaysRemaining: number | null;
|
|
230
|
+
domainCheckedAt: string | null;
|
|
231
|
+
/** Netlify site id — the IDENTITY the `netlify-deploy` audit needs to query the
|
|
232
|
+
* Netlify API. Read-only input (operator-set in Airtable); null = not on Netlify
|
|
233
|
+
* (or not wired) → that audit skips. Never derived from the URL. */
|
|
234
|
+
netlifyId: string | null;
|
|
235
|
+
/** Latest PRODUCTION deploy health (the `netlify-deploy` audit). `deployStatus` is
|
|
236
|
+
* Netlify's deploy state lower-cased (`ready`/`error`/`building`/…), null = none / not
|
|
237
|
+
* checked; `lastDeployAt` is when it deployed; `deployLogUrl` links to the deploy/log. */
|
|
238
|
+
deployStatus: string | null;
|
|
239
|
+
lastDeployAt: string | null;
|
|
240
|
+
deployLogUrl: string | null;
|
|
241
|
+
/** When the `netlify-deploy` audit last RAN (freshness stamp for `deployStatus`). The audit
|
|
242
|
+
* already writes "Deploy checked at"; this read-back is the Plan-2 fix so `deployEvidence`
|
|
243
|
+
* (Plan 4) can gate on check time — NOT on `lastDeployAt`, which is deploy time, not check time. */
|
|
244
|
+
deployCheckedAt: string | null;
|
|
245
|
+
/** Function-health verdict (the `function-health` audit): the deployed `/health` function
|
|
246
|
+
* answered `ok:true` (pass) or `ok:false` (fail). Single-select `pass`/`fail`; null = never ran
|
|
247
|
+
* / unreachable (→ Plan 4 maps to unknown/amber). Kept SEPARATE from `deployStatus` so
|
|
248
|
+
* `isFailedDeployStatus` keeps meaning "the build failed". */
|
|
249
|
+
functionHealth: "pass" | "fail" | null;
|
|
250
|
+
/** CMS reachability (server-side), derived from the same `/health` body's `details.prismic ===
|
|
251
|
+
* "ok"`. Single-select `pass`/`fail`; null = never ran. No per-site Prismic token or identity
|
|
252
|
+
* column is ever built — this rides `/health`. */
|
|
253
|
+
cmsReachable: "pass" | "fail" | null;
|
|
254
|
+
/** Does the deployed site's Turnstile widget actually work? Single-select `pass`/`fail`;
|
|
255
|
+
* null = **unverified**, which is the normal state — see below. Freshness gated by
|
|
256
|
+
* `formE2eCheckedAt`, the clock of the audit that writes it. Powers the cockpit Require-Turnstile guardrail: a site with
|
|
257
|
+
* `requireTurnstile` ON but no working widget silently buckets 100% of its real leads.
|
|
258
|
+
*
|
|
259
|
+
* One of the two `"fail"` arms is derived from `/health` (the other is a browser seeing
|
|
260
|
+
* 110200 on the live hostname), whose `forms.turnstile` is a truthiness check
|
|
261
|
+
* on `PUBLIC_TURNSTILE_SITE_KEY` that never contacts Cloudflare (see
|
|
262
|
+
* audits/function-health-airtable.ts for the incident). No key IS proof the widget can't
|
|
263
|
+
* work; a key is NOT proof that it can — a sitekey whose widget is full at Cloudflare's
|
|
264
|
+
* 10-hostname cap sets the var and still mints no token. So a `"pass"` here has to be
|
|
265
|
+
* earned by a real browser rendering the real widget (form-e2e), and until it is, null
|
|
266
|
+
* keeps the site in the cockpit's amber "can't verify" watch rather than claiming health
|
|
267
|
+
* nobody measured. */
|
|
268
|
+
turnstileWidget: "pass" | "fail" | null;
|
|
269
|
+
/** When the `function-health` audit last ran — the freshness gate for `functionHealth`,
|
|
270
|
+
* `cmsReachable`, and `turnstileWidget`. Null = never ran. */
|
|
271
|
+
functionHealthCheckedAt: string | null;
|
|
272
|
+
/** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,
|
|
273
|
+
* internal-links OK + broken count, and when it last ran (one timestamp gates all three). */
|
|
274
|
+
crossbrowserOk: boolean | null;
|
|
275
|
+
mobileOk: boolean | null;
|
|
276
|
+
linksOk: boolean | null;
|
|
277
|
+
brokenLinks: number | null;
|
|
278
|
+
browserCheckedAt: string | null;
|
|
279
|
+
/** Uptime-reachable verdict (browser audit): every sampled route returned 2xx/3xx. Single-select
|
|
280
|
+
* `pass`/`fail`; null = never ran. Point-in-time. Freshness-gated by `browserCheckedAt`. */
|
|
281
|
+
reachableOk: "pass" | "fail" | null;
|
|
282
|
+
/** Titles & meta verdict (browser audit, chromium): every sampled route has a non-empty `<title>`
|
|
283
|
+
* ≤ 70 chars + a non-empty meta description, and no duplicate titles across the sample.
|
|
284
|
+
* Single-select `pass`/`fail`; null = never ran. Freshness-gated by `browserCheckedAt`. */
|
|
285
|
+
titleMetaOk: "pass" | "fail" | null;
|
|
286
|
+
/** Per-site copy overrides (M6a). Blank → null → the DEFAULT_COPY value. */
|
|
287
|
+
copyIntro: string | null;
|
|
288
|
+
copyContact: string | null;
|
|
289
|
+
copyFooter: string | null;
|
|
290
|
+
/** Go-live timestamp, stamped when a Launch report sends (M6b). Null = not yet launched. */
|
|
291
|
+
launchedAt: string | null;
|
|
292
|
+
/** Optional per-site webhook (e.g. Zapier Catch Hook). When set, the ingest
|
|
293
|
+
* POSTs newsletter-formType submissions here (best-effort). Blank → null. */
|
|
294
|
+
newsletterWebhook: string | null;
|
|
295
|
+
/** Per-site Mailchimp (newsletter). Both must be set for the direct add;
|
|
296
|
+
* blank → skipped. The API key is `key-dc` format; dc is derived from it. */
|
|
297
|
+
mailchimpApiKey: string | null;
|
|
298
|
+
mailchimpAudienceId: string | null;
|
|
299
|
+
/** Per-site Cloudflare Turnstile gate (Airtable checkbox). When true, a submission
|
|
300
|
+
* whose Turnstile token verifies as "fail" (forged) OR "absent" (secret configured
|
|
301
|
+
* centrally but NO token forwarded — the direct-POST-bot signature) is escalated to
|
|
302
|
+
* auto-spam regardless of content score. A present-but-expired token stays
|
|
303
|
+
* "unverifiable" and neutral (a real browser DID render the widget — fail-open).
|
|
304
|
+
* ROLLOUT PRECONDITION: only enable on a site whose DEPLOYED package forwards
|
|
305
|
+
* `_meta.turnstileToken` from EVERY form (widget rendered + `cf-turnstile-response`
|
|
306
|
+
* posted; check the site's `/health` → forms.turnstile:true). A site that never
|
|
307
|
+
* forwards tokens would silently bucket 100% of its real leads — and the form-e2e
|
|
308
|
+
* probe cannot catch that state (testMode bypasses the gate). */
|
|
309
|
+
requireTurnstile: boolean;
|
|
310
|
+
/** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */
|
|
311
|
+
renovateFailingCis: number | null;
|
|
312
|
+
defaultBranchCi: string | null;
|
|
313
|
+
lastCommitAt: string | null;
|
|
314
|
+
githubSignalsAt: string | null;
|
|
315
|
+
/** Per-site smoke-suite verdict (the `smoke` audit runs `pnpm test:smoke`).
|
|
316
|
+
* Single-select pass/fail; null = never ran. `lastSmokeAt` gates freshness. */
|
|
317
|
+
smokeOk: "pass" | "fail" | null;
|
|
318
|
+
lastSmokeAt: string | null;
|
|
319
|
+
/** Synthetic form end-to-end verdict (the `form-e2e` audit submits the real prod
|
|
320
|
+
* contact form in test-mode). Single-select pass/fail; null = never ran OR (with
|
|
321
|
+
* a fresh `formE2eCheckedAt`) no contact form → n/a. `formE2eCheckedAt` gates
|
|
322
|
+
* freshness AND encodes the n/a-vs-never-ran distinction. */
|
|
323
|
+
formE2eOk: "pass" | "fail" | null;
|
|
324
|
+
formE2eCheckedAt: string | null;
|
|
325
|
+
/**
|
|
326
|
+
* Nightly Prismic model drift sweep (`prismic-models --fleet --write-back`).
|
|
327
|
+
*
|
|
328
|
+
* FOUR-VALUED, unlike every other verdict column in this table, and the extra
|
|
329
|
+
* state is the point:
|
|
330
|
+
*
|
|
331
|
+
* - `pass` — the repo and its Prismic repository were compared and agree.
|
|
332
|
+
* - `fail` — they were compared and DIVERGE (a model to create/update, or
|
|
333
|
+
* one that exists only in Prismic). A finding for a human.
|
|
334
|
+
* - `unknown` — THE CHECK ITSELF FAILED. Unreadable checkout, broken config,
|
|
335
|
+
* missing/expired token, unreadable remote. Nothing at all was
|
|
336
|
+
* established about this site's models.
|
|
337
|
+
* - `null` — no verdict to hold: the sweep has never run for this site, or
|
|
338
|
+
* it ran and found no Prismic config at all (a repo that simply
|
|
339
|
+
* is not a Prismic site). `prismicModelsCheckedAt` separates
|
|
340
|
+
* those two — fresh timestamp, blank verdict = "checked, and
|
|
341
|
+
* there is nothing here to check".
|
|
342
|
+
*
|
|
343
|
+
* `unknown` exists because the alternatives are both wrong in the dangerous
|
|
344
|
+
* direction. Writing `pass` for a site nobody could read is a green tick over
|
|
345
|
+
* an outage; writing NOTHING (the obvious "leave the last known value alone")
|
|
346
|
+
* leaves yesterday's `pass` standing for a site that has been failing every
|
|
347
|
+
* night since — and nothing ages a stale `pass` out, because the digest's
|
|
348
|
+
* freshness gate only ever examines failures. Both make "I could not read this
|
|
349
|
+
* site" indistinguishable from "this site is fine", in the record the cockpit
|
|
350
|
+
* and the digest read.
|
|
351
|
+
*
|
|
352
|
+
* OPERATOR PRECONDITION: `Prismic Models` is a single select whose options are
|
|
353
|
+
* `pass`, `fail`, `unknown`. Until the three columns exist the sweep's writes
|
|
354
|
+
* fail with UNKNOWN_FIELD_NAME and are collected as soft failures — the feature
|
|
355
|
+
* ships dark rather than reddening the nightly.
|
|
356
|
+
*/
|
|
357
|
+
prismicModels: PrismicModelsVerdict | null;
|
|
358
|
+
/** When the sweep last ran for this site — written on EVERY outcome, including
|
|
359
|
+
* `unknown` and blank, so the age of the row is the age of the answer. */
|
|
360
|
+
prismicModelsCheckedAt: string | null;
|
|
361
|
+
/** What the sweep found, verbatim: the drift report for a `fail`, the reason
|
|
362
|
+
* nothing could be established for an `unknown`, the skip reason for a blank,
|
|
363
|
+
* and null for a `pass` (which clears yesterday's finding). */
|
|
364
|
+
prismicModelsDrift: string | null;
|
|
365
|
+
/**
|
|
366
|
+
* An expiry the operator sets to accept a KNOWN, EXPECTED `fail` — most often
|
|
367
|
+
* "I am modelling in Prismic on a branch that has not landed, so Prismic is
|
|
368
|
+
* legitimately ahead of `main`". While this timestamp is in the future the
|
|
369
|
+
* drift item is not raised.
|
|
370
|
+
*
|
|
371
|
+
* It expires on purpose, and nothing renews it automatically. A permanent ack
|
|
372
|
+
* would reproduce this column's own failure mode one step later: once the
|
|
373
|
+
* branch lands, the same acked cell would swallow real drift and "nobody is
|
|
374
|
+
* looking at this" would render as "this is fine". An expiry means the worst
|
|
375
|
+
* case is a silence that ends by itself.
|
|
376
|
+
*
|
|
377
|
+
* Narrow by construction — it mutes only `fail`, the finding the operator
|
|
378
|
+
* actually reviewed. `unknown` (the check could not run) and a verdict too old
|
|
379
|
+
* to be current are never muted by it; see `collectPrismicDriftAlerts`.
|
|
380
|
+
*
|
|
381
|
+
* OPERATOR PRECONDITION: `Prismic Ack Until` is a dateTime column. Until it
|
|
382
|
+
* exists this reads null and nothing is acked, which is the safe direction.
|
|
383
|
+
*/
|
|
384
|
+
prismicAckUntil: string | null;
|
|
385
|
+
notifyRouting: NotifyRouting | null;
|
|
386
|
+
/** The RAW `Notify Routing` cell, verbatim. Same reason `statusRaw` exists: the
|
|
387
|
+
* dashboard editor round-trips this JSON, and re-serializing the PARSED object
|
|
388
|
+
* would drop any key the parser ignores and reformat what the operator typed —
|
|
389
|
+
* a silent rewrite of a cell they only opened to look at. */
|
|
390
|
+
notifyRoutingRaw: string | null;
|
|
391
|
+
/** Read-back of the code-owned next-due dates (date-only, written by the
|
|
392
|
+
* nightly `writeNextDueDates`). These exist so the writer can DIFF the
|
|
393
|
+
* freshly-computed dates against what the row already holds and skip the
|
|
394
|
+
* ~31 no-op writes a night the fleet was paying for (#539 Phase 3). Null =
|
|
395
|
+
* cell blank or the operator-added `Next … at` column absent. */
|
|
396
|
+
nextMaintenanceAt: string | null;
|
|
397
|
+
nextTestingAt: string | null;
|
|
398
|
+
};
|
|
399
|
+
/** The `Prismic Models` cell — see {@link WebsiteRow.prismicModels} for why it
|
|
400
|
+
* carries a third state that no other verdict column in this table has. */
|
|
401
|
+
type PrismicModelsVerdict = "pass" | "fail" | "unknown";
|
|
402
|
+
type Severity = "low" | "moderate" | "high" | "critical";
|
|
403
|
+
/** One known vulnerability behind the security counts, as persisted/rendered. */
|
|
404
|
+
type SecurityAdvisory = {
|
|
405
|
+
module: string;
|
|
406
|
+
severity: Severity;
|
|
407
|
+
title: string;
|
|
408
|
+
cves: string[];
|
|
409
|
+
url: string | null;
|
|
410
|
+
/** GitHub Dependabot dependency scope ("runtime" | "development"), when known. Lets the
|
|
411
|
+
* dashboard flag build-time-only ("development") vulns. Omitted for advisories from the
|
|
412
|
+
* lockfile `pnpm audit` fallback, which carries no per-package graph scope. */
|
|
413
|
+
scope?: "runtime" | "development";
|
|
414
|
+
/** GitHub Dependabot dependency-graph relationship, when known. "transitive" means no
|
|
415
|
+
* direct-dep bump can fix it — Renovate's vuln alerts have no fix vehicle until the weekly
|
|
416
|
+
* lockfile-maintenance window. Omitted when GitHub reports "unknown" or for the
|
|
417
|
+
* `pnpm audit` fallback. Drives {@link allActionableVulnsTransitive}. */
|
|
418
|
+
relationship?: "direct" | "transitive";
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
type FleetEventType = "pr_automerged" | "vuln_cleared" | "ci_recovered" | "site_launched" | "fleet_swept" | "cert_renewed" | "report_sent_with_override";
|
|
422
|
+
/** One recorded fleet activity event. `id` is deterministic (see the detectors)
|
|
423
|
+
* so re-runs INSERT OR IGNORE without duplicating. `siteId`/`siteName` are null
|
|
424
|
+
* for fleet-wide rollups; `data` is optional structured detail (e.g. the PR url). */
|
|
425
|
+
type FleetEvent = {
|
|
426
|
+
id: string;
|
|
427
|
+
ts: string;
|
|
428
|
+
type: FleetEventType;
|
|
429
|
+
siteId: string | null;
|
|
430
|
+
siteName: string | null;
|
|
431
|
+
summary: string;
|
|
432
|
+
data: unknown | null;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
export type { AirtableBase as A, FleetEvent as F, WebsiteRow as W, FleetEventType as a };
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
REPORT_LIST_COLUMNS,
|
|
3
|
+
findReportByMessageId,
|
|
3
4
|
getReportById,
|
|
4
5
|
getReportHtml,
|
|
5
6
|
getSiteById,
|
|
6
7
|
getSiteBySlug,
|
|
8
|
+
insertReportRow,
|
|
9
|
+
insertSiteRows,
|
|
7
10
|
listAllReports,
|
|
8
11
|
listReportsForSite,
|
|
12
|
+
listSendableReports,
|
|
9
13
|
listSites,
|
|
10
14
|
mirrorHealthFields,
|
|
11
15
|
mirrorReportInsert,
|
|
@@ -15,20 +19,25 @@ import {
|
|
|
15
19
|
mirrorSiteFields,
|
|
16
20
|
mirrorSiteInsert,
|
|
17
21
|
siteRowExists,
|
|
18
|
-
storeRenderedHtml
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import "./chunk-
|
|
22
|
+
storeRenderedHtml,
|
|
23
|
+
updateSiteIdentity
|
|
24
|
+
} from "./chunk-BINFZQ2T.js";
|
|
25
|
+
import "./chunk-LF2TJN4X.js";
|
|
26
|
+
import "./chunk-ASLDCWBF.js";
|
|
22
27
|
import "./chunk-VAD43MOR.js";
|
|
23
|
-
import "./chunk-
|
|
28
|
+
import "./chunk-ICSRWW5Q.js";
|
|
24
29
|
export {
|
|
25
30
|
REPORT_LIST_COLUMNS,
|
|
31
|
+
findReportByMessageId,
|
|
26
32
|
getReportById,
|
|
27
33
|
getReportHtml,
|
|
28
34
|
getSiteById,
|
|
29
35
|
getSiteBySlug,
|
|
36
|
+
insertReportRow,
|
|
37
|
+
insertSiteRows,
|
|
30
38
|
listAllReports,
|
|
31
39
|
listReportsForSite,
|
|
40
|
+
listSendableReports,
|
|
32
41
|
listSites,
|
|
33
42
|
mirrorHealthFields,
|
|
34
43
|
mirrorReportInsert,
|
|
@@ -38,6 +47,7 @@ export {
|
|
|
38
47
|
mirrorSiteFields,
|
|
39
48
|
mirrorSiteInsert,
|
|
40
49
|
siteRowExists,
|
|
41
|
-
storeRenderedHtml
|
|
50
|
+
storeRenderedHtml,
|
|
51
|
+
updateSiteIdentity
|
|
42
52
|
};
|
|
43
|
-
//# sourceMappingURL=fleet-state-
|
|
53
|
+
//# sourceMappingURL=fleet-state-RJROYXKO.js.map
|