@reddoorla/maintenance 0.95.0 → 0.95.1

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.
Files changed (75) hide show
  1. package/dist/{announce-PGT4SZZB.js → announce-CLQYX5I4.js} +3 -3
  2. package/dist/{chunk-KQB2BX4V.js → chunk-2ZAZ7ETT.js} +2 -2
  3. package/dist/{chunk-DMPHP7UP.js → chunk-BI2VI6EM.js} +2 -2
  4. package/dist/{chunk-OCNSIBMP.js → chunk-C3IQQKXL.js} +26 -1
  5. package/dist/{chunk-OCNSIBMP.js.map → chunk-C3IQQKXL.js.map} +1 -1
  6. package/dist/{chunk-4AIYWWKV.js → chunk-KJRVMMM3.js} +2 -2
  7. package/dist/{chunk-AEUABPAJ.js → chunk-R5IMFAFR.js} +2 -2
  8. package/dist/{chunk-22GKJO5F.js → chunk-VSGMCCGQ.js} +2 -2
  9. package/dist/chunk-XPJQXSC4.js +112 -0
  10. package/dist/chunk-XPJQXSC4.js.map +1 -0
  11. package/dist/{chunk-6VKYQATC.js → chunk-ZCL7C5LH.js} +1115 -125
  12. package/dist/chunk-ZCL7C5LH.js.map +1 -0
  13. package/dist/cli/bin.js +15 -15
  14. package/dist/cli/commands/audit.js +5 -5
  15. package/dist/client-G2WGWPCA.js +11 -0
  16. package/dist/configs/eslint.js +27 -1
  17. package/dist/configs/eslint.js.map +1 -1
  18. package/dist/{db-BBEM7CBX.js → db-XKZYDCKM.js} +9 -9
  19. package/dist/{digest-65BOZ7AT.js → digest-7UBGUBRK.js} +13 -13
  20. package/dist/{digest-collectors-EHFY2OQC.js → digest-collectors-DJFN5ZDK.js} +7 -7
  21. package/dist/{ensure-site-WIDO6LGU.js → ensure-site-OAAE5YE2.js} +2 -2
  22. package/dist/{forms-notify-target-2LWF5C5B.js → forms-notify-target-UC6PF3AE.js} +2 -2
  23. package/dist/{github-signals-GAHNOAHS.js → github-signals-TX5FPYP6.js} +5 -5
  24. package/dist/{header-image-YN3AQRY3.js → header-image-PSPT7CUM.js} +2 -2
  25. package/dist/{health-mirror-EB76EERV.js → health-mirror-XJAKA3EY.js} +3 -3
  26. package/dist/index.js +8 -8
  27. package/dist/{launch-4RVWPZGI.js → launch-UD36AHUN.js} +3 -3
  28. package/dist/{match-harness-2Q6ISEES.js → match-harness-QRG6XZAR.js} +2 -2
  29. package/dist/migrate-Y7R4CESL.js +7 -0
  30. package/dist/{orchestrate-MWXENQOS.js → orchestrate-HYVQPRCE.js} +5 -5
  31. package/dist/{preflight-4VQP7JJL.js → preflight-RYIP54NY.js} +6 -6
  32. package/dist/{prismic-models-W6CNLTWH.js → prismic-models-P6FWWVRS.js} +2 -2
  33. package/dist/{prospect-audit-X4Z67LWW.js → prospect-audit-PRHU53HS.js} +3 -3
  34. package/dist/{prospect-audits-RVYEA4KH.js → prospect-audits-R3LWF6U7.js} +10 -4
  35. package/dist/{report-4GFD4MW2.js → report-LQS4ECTM.js} +11 -11
  36. package/dist/{report-mirror-IP64R7B3.js → report-mirror-HDTOJE64.js} +3 -3
  37. package/dist/{schema-2V7BGYVT.js → schema-A4WCPIF4.js} +1 -1
  38. package/dist/{schema-2V7BGYVT.js.map → schema-A4WCPIF4.js.map} +1 -1
  39. package/dist/{selftest-MAB2PBXQ.js → selftest-VXTHRPRN.js} +5 -5
  40. package/dist/{site-mirror-UKHREAD6.js → site-mirror-WON4FTJ2.js} +3 -3
  41. package/dist/{submissions-5GKJPCIH.js → submissions-OVZPVBYG.js} +2 -2
  42. package/package.json +1 -1
  43. package/dist/chunk-6VKYQATC.js.map +0 -1
  44. package/dist/chunk-7MKT4I5T.js +0 -62
  45. package/dist/chunk-7MKT4I5T.js.map +0 -1
  46. package/dist/client-M2V6FHH5.js +0 -11
  47. package/dist/migrate-K4JETR36.js +0 -7
  48. /package/dist/{announce-PGT4SZZB.js.map → announce-CLQYX5I4.js.map} +0 -0
  49. /package/dist/{chunk-KQB2BX4V.js.map → chunk-2ZAZ7ETT.js.map} +0 -0
  50. /package/dist/{chunk-DMPHP7UP.js.map → chunk-BI2VI6EM.js.map} +0 -0
  51. /package/dist/{chunk-4AIYWWKV.js.map → chunk-KJRVMMM3.js.map} +0 -0
  52. /package/dist/{chunk-AEUABPAJ.js.map → chunk-R5IMFAFR.js.map} +0 -0
  53. /package/dist/{chunk-22GKJO5F.js.map → chunk-VSGMCCGQ.js.map} +0 -0
  54. /package/dist/{client-M2V6FHH5.js.map → client-G2WGWPCA.js.map} +0 -0
  55. /package/dist/{db-BBEM7CBX.js.map → db-XKZYDCKM.js.map} +0 -0
  56. /package/dist/{digest-65BOZ7AT.js.map → digest-7UBGUBRK.js.map} +0 -0
  57. /package/dist/{digest-collectors-EHFY2OQC.js.map → digest-collectors-DJFN5ZDK.js.map} +0 -0
  58. /package/dist/{ensure-site-WIDO6LGU.js.map → ensure-site-OAAE5YE2.js.map} +0 -0
  59. /package/dist/{forms-notify-target-2LWF5C5B.js.map → forms-notify-target-UC6PF3AE.js.map} +0 -0
  60. /package/dist/{github-signals-GAHNOAHS.js.map → github-signals-TX5FPYP6.js.map} +0 -0
  61. /package/dist/{header-image-YN3AQRY3.js.map → header-image-PSPT7CUM.js.map} +0 -0
  62. /package/dist/{health-mirror-EB76EERV.js.map → health-mirror-XJAKA3EY.js.map} +0 -0
  63. /package/dist/{launch-4RVWPZGI.js.map → launch-UD36AHUN.js.map} +0 -0
  64. /package/dist/{match-harness-2Q6ISEES.js.map → match-harness-QRG6XZAR.js.map} +0 -0
  65. /package/dist/{migrate-K4JETR36.js.map → migrate-Y7R4CESL.js.map} +0 -0
  66. /package/dist/{orchestrate-MWXENQOS.js.map → orchestrate-HYVQPRCE.js.map} +0 -0
  67. /package/dist/{preflight-4VQP7JJL.js.map → preflight-RYIP54NY.js.map} +0 -0
  68. /package/dist/{prismic-models-W6CNLTWH.js.map → prismic-models-P6FWWVRS.js.map} +0 -0
  69. /package/dist/{prospect-audit-X4Z67LWW.js.map → prospect-audit-PRHU53HS.js.map} +0 -0
  70. /package/dist/{prospect-audits-RVYEA4KH.js.map → prospect-audits-R3LWF6U7.js.map} +0 -0
  71. /package/dist/{report-4GFD4MW2.js.map → report-LQS4ECTM.js.map} +0 -0
  72. /package/dist/{report-mirror-IP64R7B3.js.map → report-mirror-HDTOJE64.js.map} +0 -0
  73. /package/dist/{selftest-MAB2PBXQ.js.map → selftest-VXTHRPRN.js.map} +0 -0
  74. /package/dist/{site-mirror-UKHREAD6.js.map → site-mirror-WON4FTJ2.js.map} +0 -0
  75. /package/dist/{submissions-5GKJPCIH.js.map → submissions-OVZPVBYG.js.map} +0 -0
@@ -203,8 +203,8 @@ function formatAnnounceResult(result) {
203
203
  return result.results.map(formatSiteResult).join("\n");
204
204
  }
205
205
  async function runAnnounceCommand(site, _opts) {
206
- const { makeReportMirror } = await import("./report-mirror-IP64R7B3.js");
207
- const { makeSiteMirror } = await import("./site-mirror-UKHREAD6.js");
206
+ const { makeReportMirror } = await import("./report-mirror-HDTOJE64.js");
207
+ const { makeSiteMirror } = await import("./site-mirror-WON4FTJ2.js");
208
208
  const reportMirror = await makeReportMirror();
209
209
  const siteMirror = await makeSiteMirror();
210
210
  const result = await announce({ ...site ? { site } : {}, reportMirror, siteMirror });
@@ -215,4 +215,4 @@ export {
215
215
  formatAnnounceResult,
216
216
  runAnnounceCommand
217
217
  };
218
- //# sourceMappingURL=announce-PGT4SZZB.js.map
218
+ //# sourceMappingURL=announce-CLQYX5I4.js.map
@@ -27,7 +27,7 @@ import {
27
27
  } from "./chunk-WEKL2HYJ.js";
28
28
  import {
29
29
  recordFleetEventsBestEffort
30
- } from "./chunk-DMPHP7UP.js";
30
+ } from "./chunk-BI2VI6EM.js";
31
31
 
32
32
  // src/reports/send/idempotency.ts
33
33
  function isIdempotencyConflict(err) {
@@ -227,4 +227,4 @@ export {
227
227
  parseAddresses,
228
228
  isProbablyEmail
229
229
  };
230
- //# sourceMappingURL=chunk-KQB2BX4V.js.map
230
+ //# sourceMappingURL=chunk-2ZAZ7ETT.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  openDb,
3
3
  readDbConfig
4
- } from "./chunk-22GKJO5F.js";
4
+ } from "./chunk-VSGMCCGQ.js";
5
5
 
6
6
  // src/db/fleet-events.ts
7
7
  async function recordFleetEvent(db, e) {
@@ -47,4 +47,4 @@ async function recordFleetEventsBestEffort(events, now, open = () => openDb(read
47
47
  export {
48
48
  recordFleetEventsBestEffort
49
49
  };
50
- //# sourceMappingURL=chunk-DMPHP7UP.js.map
50
+ //# sourceMappingURL=chunk-BI2VI6EM.js.map
@@ -383,6 +383,31 @@ var MIGRATIONS = [
383
383
  CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key
384
384
  ON prospect_audits (site_key, created_at DESC);
385
385
  `
386
+ },
387
+ {
388
+ // Operator edits on a prospect report. One JSON blob rather than a side
389
+ // table: the map is always read and written whole, and no query wants a
390
+ // single override in isolation. Same shape of decision as `result_json`.
391
+ //
392
+ // THREE MIGRATIONS, NOT ONE. Verified against migrate.ts: it runs
393
+ // `executeMultiple(m.sql)` inside a try, swallows any "duplicate column
394
+ // name" error, then records the marker unconditionally. That recovery is
395
+ // sound for ONE statement. With three, a crash partway through leaves the
396
+ // marker unwritten; the re-run's first statement then throws duplicate,
397
+ // executeMultiple aborts before the later statements, the catch swallows
398
+ // it, and the marker IS written — leaving the remaining columns
399
+ // permanently missing on a migration recorded as applied. Same rule as
400
+ // 0003 and 0013.
401
+ id: "0015_prospect_audits_overrides",
402
+ sql: `ALTER TABLE prospect_audits ADD COLUMN overrides_json TEXT;`
403
+ },
404
+ {
405
+ id: "0016_prospect_audits_edited_at",
406
+ sql: `ALTER TABLE prospect_audits ADD COLUMN edited_at TEXT;`
407
+ },
408
+ {
409
+ id: "0017_prospect_audits_opened_at",
410
+ sql: `ALTER TABLE prospect_audits ADD COLUMN opened_at TEXT;`
386
411
  }
387
412
  ];
388
413
 
@@ -417,4 +442,4 @@ async function runMigrations(client) {
417
442
  export {
418
443
  runMigrations
419
444
  };
420
- //# sourceMappingURL=chunk-OCNSIBMP.js.map
445
+ //# sourceMappingURL=chunk-C3IQQKXL.js.map
@@ -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","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","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":";AAUO,IAAM,aAA0B;AAAA,EACrC;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA+BP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaP;AAAA,EACA;AAAA,IACE,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAME,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAgBE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAyBE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAoIP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAcE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA,IAKE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA,EAIP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA,EAIP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAgBE,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA,EAIP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAcE,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AACF;;;AC3ZA,SAAS,sBAAsB,KAAuB;AACpD,QAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,SAAO,yBAAyB,KAAK,OAAO;AAC9C;AASA,eAAsB,cAAc,QAAmC;AACrE,QAAM,OAAO;AAAA,IACX;AAAA,EACF;AACA,QAAM,WAAW,MAAM,OAAO,QAAQ,4BAA4B;AAClE,QAAM,UAAU,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC,MAAM,OAAO,EAAE,EAAE,CAAC,CAAC;AAC9D,QAAM,MAAgB,CAAC;AACvB,aAAW,KAAK,YAAY;AAC1B,QAAI,QAAQ,IAAI,EAAE,EAAE,EAAG;AACvB,QAAI;AACF,YAAM,OAAO,gBAAgB,EAAE,GAAG;AAAA,IACpC,SAAS,KAAK;AAIZ,UAAI,CAAC,sBAAsB,GAAG,EAAG,OAAM;AAAA,IACzC;AACA,UAAM,OAAO,QAAQ;AAAA,MACnB,KAAK;AAAA,MACL,MAAM,CAAC,EAAE,KAAI,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,IACvC,CAAC;AACD,QAAI,KAAK,EAAE,EAAE;AAAA,EACf;AACA,SAAO;AACT;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  approveBlockers
3
- } from "./chunk-AEUABPAJ.js";
3
+ } from "./chunk-R5IMFAFR.js";
4
4
  import {
5
5
  ACTIVE_STATUSES,
6
6
  allActionableVulnsTransitive,
@@ -306,4 +306,4 @@ export {
306
306
  prismicAckIsLive,
307
307
  collectPrismicDriftAlerts
308
308
  };
309
- //# sourceMappingURL=chunk-4AIYWWKV.js.map
309
+ //# sourceMappingURL=chunk-KJRVMMM3.js.map
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-KQB2BX4V.js";
8
+ } from "./chunk-2ZAZ7ETT.js";
9
9
  import {
10
10
  listAllReports
11
11
  } from "./chunk-F3JWOPTG.js";
@@ -341,4 +341,4 @@ export {
341
341
  preflight,
342
342
  approveBlockers
343
343
  };
344
- //# sourceMappingURL=chunk-AEUABPAJ.js.map
344
+ //# sourceMappingURL=chunk-R5IMFAFR.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runMigrations
3
- } from "./chunk-OCNSIBMP.js";
3
+ } from "./chunk-C3IQQKXL.js";
4
4
  import {
5
5
  defaultCredentialsPath
6
6
  } from "./chunk-6VAL7XJT.js";
@@ -48,4 +48,4 @@ export {
48
48
  readDbConfig,
49
49
  openDb
50
50
  };
51
- //# sourceMappingURL=chunk-22GKJO5F.js.map
51
+ //# sourceMappingURL=chunk-VSGMCCGQ.js.map
@@ -0,0 +1,112 @@
1
+ // src/db/prospect-audits.ts
2
+ import { randomBytes } from "crypto";
3
+ function generateToken() {
4
+ return randomBytes(16).toString("base64url");
5
+ }
6
+ function isValidToken(s) {
7
+ return /^[A-Za-z0-9_-]{22}$/.test(s);
8
+ }
9
+ function newProspectAuditId() {
10
+ return `pa_${crypto.randomUUID()}`;
11
+ }
12
+ function siteKey(url) {
13
+ const raw = url.trim();
14
+ if (raw === "") return "";
15
+ try {
16
+ const u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
17
+ const host = u.hostname.toLowerCase().replace(/^www\./, "");
18
+ const path = u.pathname.replace(/\/+$/, "");
19
+ return `${host}${path}`;
20
+ } catch {
21
+ return raw;
22
+ }
23
+ }
24
+ async function createProspectAudit(db, audit) {
25
+ const id = newProspectAuditId();
26
+ const token = generateToken();
27
+ await db.insertInto("prospect_audits").values({
28
+ id,
29
+ token,
30
+ url: audit.url,
31
+ site_key: siteKey(audit.url),
32
+ business: audit.business,
33
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
34
+ status: audit.status ?? "complete",
35
+ result_json: audit.resultJson
36
+ }).execute();
37
+ return { id, token };
38
+ }
39
+ async function getProspectAuditByToken(db, token) {
40
+ const row = await db.selectFrom("prospect_audits").select([
41
+ "id",
42
+ "url",
43
+ "business",
44
+ "created_at",
45
+ "status",
46
+ "result_json",
47
+ "overrides_json",
48
+ "edited_at",
49
+ "opened_at"
50
+ ]).where("token", "=", token).executeTakeFirst();
51
+ return row ?? null;
52
+ }
53
+ var MAX_RECENT_PROSPECT_AUDITS = 100;
54
+ function clampLimit(limit) {
55
+ const n = Number.isFinite(limit) ? Math.trunc(limit) : 1;
56
+ return Math.min(Math.max(n, 1), MAX_RECENT_PROSPECT_AUDITS);
57
+ }
58
+ async function listRecentProspectAudits(db, limit) {
59
+ return db.selectFrom("prospect_audits").select(["id", "token", "url", "business", "status", "created_at", "edited_at", "opened_at"]).orderBy("created_at", "desc").limit(clampLimit(limit)).execute();
60
+ }
61
+ var OVERRIDES_MAX_LEN = 512e3;
62
+ function buildOverrideMap(v) {
63
+ if (v === null || typeof v !== "object") return null;
64
+ const proto = Object.getPrototypeOf(v);
65
+ if (proto !== Object.prototype && proto !== null) return null;
66
+ if (Object.hasOwn(v, "__proto__")) return null;
67
+ const built = {};
68
+ for (const [key, entry] of Object.entries(v)) {
69
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null;
70
+ const { original, text } = entry;
71
+ if (typeof original !== "string" || typeof text !== "string") return null;
72
+ built[key] = { original, text };
73
+ }
74
+ return built;
75
+ }
76
+ function serialiseOverrides(overrides) {
77
+ let json;
78
+ try {
79
+ const built = buildOverrideMap(overrides);
80
+ if (built === null) return null;
81
+ json = JSON.stringify(built);
82
+ } catch {
83
+ return null;
84
+ }
85
+ return json.length > OVERRIDES_MAX_LEN ? null : json;
86
+ }
87
+ async function setProspectAuditOverrides(db, token, overrides) {
88
+ const serialised = serialiseOverrides(overrides);
89
+ if (serialised === null) return { status: "invalid", token };
90
+ const existing = await getProspectAuditByToken(db, token);
91
+ if (!existing) return { status: "not-found", token };
92
+ await db.updateTable("prospect_audits").set({ overrides_json: serialised, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).where("token", "=", token).execute();
93
+ return { status: "updated", token };
94
+ }
95
+ async function touchProspectAuditOpened(db, token) {
96
+ await db.updateTable("prospect_audits").set({ opened_at: (/* @__PURE__ */ new Date()).toISOString() }).where("token", "=", token).execute();
97
+ }
98
+
99
+ export {
100
+ generateToken,
101
+ isValidToken,
102
+ newProspectAuditId,
103
+ siteKey,
104
+ createProspectAudit,
105
+ getProspectAuditByToken,
106
+ MAX_RECENT_PROSPECT_AUDITS,
107
+ listRecentProspectAudits,
108
+ OVERRIDES_MAX_LEN,
109
+ setProspectAuditOverrides,
110
+ touchProspectAuditOpened
111
+ };
112
+ //# sourceMappingURL=chunk-XPJQXSC4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/prospect-audits.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport type { Db } from \"./client.js\";\n\n/** 16 random bytes → 22-char base64url string: unguessable, URL-safe. */\nexport function generateToken(): string {\n return randomBytes(16).toString(\"base64url\");\n}\n\n/** Anything not exactly generateToken()-shaped is a probe, not a report. */\nexport function isValidToken(s: string): boolean {\n return /^[A-Za-z0-9_-]{22}$/.test(s);\n}\n\n/** Opaque, collision-free id (mirrors newSubmissionId / newDeadLetterId).\n * crypto is a Node 20 global — no new dep. Nothing reads this format; `token`\n * is the public handle. */\nexport function newProspectAuditId(): string {\n return `pa_${crypto.randomUUID()}`;\n}\n\n/** \"complete\" when every pipeline stage succeeded; \"partial\" when any stage\n * failed or was deliberately skipped (StageResult's `ok: false` covers\n * both — the pipeline doesn't distinguish them at this layer). The report\n * itself already degrades each failed section to \"not measured\"; this is\n * what lets a future dashboard tell complete from partial without\n * deserializing the whole result_json. */\nexport type ProspectAuditStatus = \"complete\" | \"partial\";\n\n/**\n * The lineage handle for a site: one key for every way of writing its address.\n *\n * `url` is kept exactly as it was given, because what we audited is a fact\n * about the run. It is the wrong thing to group by, though, and the corpus\n * already proves it — `beachfrontdentistry.com` and `beachfrontdentistry.com/`\n * are two separate histories in the table today, as are the two spellings of\n * ludlowkingsley.com. Nothing has broken only because nothing yet compares two\n * audits of one site. The moment the report offers a before and an after, a\n * trailing slash turns a returning client into a stranger.\n *\n * Scheme, a leading \"www.\", a trailing slash, a query string and a fragment all\n * go: none of them distinguish one site from another. The path stays, because a\n * site living in a subdirectory genuinely is a different site.\n *\n * Never throws. A key we cannot compute must not take an audit down with it —\n * an unparseable input falls back to its trimmed self, which groups nothing and\n * so is no worse than having no key at all.\n */\nexport function siteKey(url: string): string {\n const raw = url.trim();\n if (raw === \"\") return \"\";\n try {\n const u = new URL(/^[a-z][a-z0-9+.-]*:\\/\\//i.test(raw) ? raw : `https://${raw}`);\n const host = u.hostname.toLowerCase().replace(/^www\\./, \"\");\n const path = u.pathname.replace(/\\/+$/, \"\");\n return `${host}${path}`;\n } catch {\n return raw;\n }\n}\n\nexport type NewProspectAudit = {\n url: string;\n business: string | null;\n resultJson: string;\n /** Defaults to \"complete\" — the column's own SQL default — for callers\n * (tests, ad-hoc scripts) that don't track per-stage outcomes. The\n * prospect-audit CLI, the only real writer, always computes and passes\n * this explicitly. */\n status?: ProspectAuditStatus;\n};\n\nexport async function createProspectAudit(\n db: Db,\n audit: NewProspectAudit,\n): Promise<{ id: string; token: string }> {\n const id = newProspectAuditId();\n const token = generateToken();\n await db\n .insertInto(\"prospect_audits\")\n .values({\n id,\n token,\n url: audit.url,\n site_key: siteKey(audit.url),\n business: audit.business,\n created_at: new Date().toISOString(),\n status: audit.status ?? \"complete\",\n result_json: audit.resultJson,\n })\n .execute();\n return { id, token };\n}\n\nexport type ProspectAuditRow = {\n id: string;\n url: string;\n business: string | null;\n created_at: string;\n status: string;\n result_json: string;\n /** The operator's edits as stored JSON, or null when this report has never\n * been edited. Deliberately NOT parsed here: the JSON route will place this\n * string into its response body as-is, exactly as it already does with\n * `result_json`, so the row hands back what was validated on the way in. */\n overrides_json: string | null;\n edited_at: string | null;\n opened_at: string | null;\n};\n\nexport async function getProspectAuditByToken(\n db: Db,\n token: string,\n): Promise<ProspectAuditRow | null> {\n const row = await db\n .selectFrom(\"prospect_audits\")\n .select([\n \"id\",\n \"url\",\n \"business\",\n \"created_at\",\n \"status\",\n \"result_json\",\n \"overrides_json\",\n \"edited_at\",\n \"opened_at\",\n ])\n .where(\"token\", \"=\", token)\n .executeTakeFirst();\n return row ?? null;\n}\n\n/** The columns the cockpit's /audits listing page shows. Deliberately NOT\n * `result_json` — large, and useless to a list — but `token` IS selected:\n * the listing needs it to build each row's `/r/{token}` link, so it's\n * rendered only into an href, never displayed as a raw value. */\nexport type ProspectAuditListItem = {\n id: string;\n token: string;\n url: string;\n business: string | null;\n status: string;\n created_at: string;\n /** Whether an operator has edited this report, and when someone last opened\n * it — both cheap TEXT columns, and the two facts the listing needs to show\n * an \"edited\" marker without reading `overrides_json`. */\n edited_at: string | null;\n opened_at: string | null;\n};\n\n/** Ceiling on `listRecentProspectAudits`' `limit`, enforced defensively (a\n * caller passing 10_000 must not get 10_000 rows) — well past anything a\n * listing page would ever render on one screen. */\nexport const MAX_RECENT_PROSPECT_AUDITS = 100;\n\n/** Clamp a caller-supplied limit into [1, MAX_RECENT_PROSPECT_AUDITS], with a\n * safe fallback of 1 for anything non-finite (NaN, ±Infinity) rather than\n * letting a bad input reach the query unclamped. */\nfunction clampLimit(limit: number): number {\n const n = Number.isFinite(limit) ? Math.trunc(limit) : 1;\n return Math.min(Math.max(n, 1), MAX_RECENT_PROSPECT_AUDITS);\n}\n\n/** Newest-first audits for the cockpit's /audits listing page, capped both\n * defensively (see clampLimit) and by an index (migration 0010) so the\n * ORDER BY never falls back to a raw scan + a temp b-tree sort — see the\n * EXPLAIN-query-plan gate, tests/db/query-plans.test.ts. */\nexport async function listRecentProspectAudits(\n db: Db,\n limit: number,\n): Promise<ProspectAuditListItem[]> {\n return db\n .selectFrom(\"prospect_audits\")\n .select([\"id\", \"token\", \"url\", \"business\", \"status\", \"created_at\", \"edited_at\", \"opened_at\"])\n .orderBy(\"created_at\", \"desc\")\n .limit(clampLimit(limit))\n .execute();\n}\n\n/** One replaced string and the generated text it replaced. */\nexport type Override = { original: string; text: string };\nexport type OverrideMap = Record<string, Override>;\n\n/**\n * Ceiling on one report's override map, measured on the SERIALISED JSON rather\n * than on the entry count — one entry holding a megabyte is the case that\n * matters, and the string is what actually gets stored and re-served.\n *\n * The arithmetic, not a round number: the report carries roughly 227 editable\n * strings (the check battery alone, before fixes, claims and goal copy). An\n * operator who replaced every one of them, at around 300 characters of\n * `original` plus 300 of `text` each, writes on the order of 136 KB. This is\n * about four times that worst realistic case, which leaves the honest editor\n * unbounded in practice while still bounding a hand-crafted POST.\n *\n * The bound matters more here than it does for the sibling `setReportCommentary`\n * (2 000 characters): commentary is read when a report is composed, whereas this\n * value is served back inline on every fetch of the report, out of a metered\n * store. 2 288 891 characters were accepted in a single write before this cap.\n */\nexport const OVERRIDES_MAX_LEN = 512_000;\n\nexport type SetOverridesResult =\n | { status: \"updated\"; token: string }\n | { status: \"invalid\"; token: string }\n | { status: \"not-found\"; token: string };\n\n/**\n * The map that will actually be stored — a fresh `{ original, text }` pair per\n * entry, built out of the validated strings themselves — or null for anything\n * that is not a flat map of `{ original, text }` string pairs.\n *\n * Validating and BUILDING are one step deliberately, and this function returns\n * the map rather than a type guard for that reason. Every defect this code has\n * had was the same shape: one object was checked and a different one was handed\n * to `JSON.stringify`. `stringify` honours a `toJSON` method, so the entry\n * `{ original: \"a\", text: \"b\", toJSON: () => 5 }` validated as a pair and stored\n * `{\"k\":5}` — bytes this function never saw, in the one place that claims to be\n * the only gate they can be caught at. Reading each field once and keeping what\n * was read closes the gap by construction: what is stored IS what was approved,\n * and a getter that would answer differently on a second read has no second\n * read. Key order comes straight from `Object.entries`, which is the order\n * `JSON.stringify` would have used, so a round trip is stable and diffs stay\n * legible.\n *\n * EXTRA KEYS ON AN ENTRY ARE DROPPED, and that is a POLICY, not something\n * building the map here forces. A third design satisfies both halves at once:\n * build from the validated fields AND reject any entry carrying a key other than\n * `original` and `text`. Under it the `toJSON` gap, the double-read gap and the\n * extra keys all close by construction exactly as they do now, and a cycle or a\n * BigInt in a third key comes back `invalid` — because the third key is itself\n * the rejection reason, not because `JSON.stringify` threw on it. Dropping and\n * rejecting are therefore independent of building; the choice is DROP, and this\n * is the argument for it rather than a consequence of the rewrite.\n *\n * The consumer reads `original` and `text` and nothing else — the website\n * matches `original` against the generated string before it substitutes `text`.\n * An entry key nobody reads is dead weight re-served on every fetch of the\n * report out of a metered store, which is the same cost `OVERRIDES_MAX_LEN`\n * exists to bound; so the answer to a third key is to stop storing it, not to\n * refuse the operator's words over it. Dropping also keeps the cheap\n * forward-compatible case working: an editor that starts sending a field this\n * schema has not learned yet still saves what was typed.\n *\n * The line that drop stops at is the one this file already draws: it is the\n * ENTRY's own keys that are dropped. An unknown TOP-LEVEL key is a whole\n * override — operator content — and is validated like any other, never silently\n * discarded. The `__proto__` rule below is that same distinction reaching the\n * opposite verdict, for the same reason.\n *\n * The drop IS silent, and that is the cost being accepted: a map is stored that\n * is not byte-identical to the one sent, and the caller is told `updated` all\n * the same. It is acceptable only because what differs is always fields nobody\n * reads — it would not be acceptable for anything a reader could miss.\n *\n * This runs BEFORE the read, the same order `setReportCommentary` uses, so a\n * malformed body costs no round trip.\n *\n * It is also the ONLY place a malformed value can be caught. The JSON route\n * never parses what it serves — today it returns `result_json` untouched, and\n * once it carries overrides too it will place this string into the body the\n * same way, for the reason its own comment gives: parsing and re-serialising\n * there would add a failure mode between the database and the consumer for no\n * gain. Nothing downstream will notice a non-JSON value here; it would simply\n * break every subsequent fetch of that report. So: validate on the way in,\n * once, and never store anything this function has not approved.\n *\n * \"Only place\" is about MALFORMEDNESS, and is not a claim that no other limit is\n * needed. `OVERRIDES_MAX_LEN` bounds the string that gets STORED, never the\n * request body: a POST carrying megabytes of junk in keys nobody reads is parsed\n * in full and walked in full, and then stores 33 bytes and answers `updated`.\n * Bounding the body is the HTTP route's job, with a body limit there — not a\n * second check here.\n *\n * Both null guards are load-bearing, because `typeof null === \"object\"`: the\n * outer one keeps `Object.entries(null)` from throwing, and the inner one keeps\n * a null ENTRY from being read for `.original` — the exact input that crashed\n * the website's own override layer during its review.\n *\n * The PROTOTYPE check is the one that stops this function passing vacuously.\n * `Object.entries()` on a Map, a Set or a Date returns `[]`, so an entry walk\n * never runs its body and approved all three: a Map stored `{}` and silently\n * discarded every override in it, and a Date stored a bare JSON string where a\n * map belongs — each reporting \"updated\" and stamping `edited_at`.\n * Arrays fall out of the same check (`Array.prototype !== Object.prototype`), so\n * there is no separate `Array.isArray` guard on the outer value.\n *\n * `Object.create(null)` IS accepted: a null-prototype object serialises exactly\n * like `{}`, which is the only property this validator's consumer cares about,\n * and refusing it would reject a legitimate map for a reason invisible in JSON.\n *\n * A CROSS-REALM plain object is rejected, though, because its prototype is the\n * OTHER realm's `Object.prototype` and an identity comparison sees a stranger.\n * Nothing produces one today — a route handler's `JSON.parse` builds its objects\n * in this realm — and rejecting is the safe direction, but it would be a mystery\n * to a future worker-thread or `vm` consumer handed a map across the boundary.\n *\n * A `__proto__` KEY is rejected rather than stripped. `JSON.parse` makes it an\n * ordinary own property, and stored verbatim a consumer doing\n * `Object.assign({}, parsed)` does get its prototype replaced — the consumer\n * here is the website, a separate codebase, and this function's whole premise is\n * that nothing downstream will check.\n *\n * It is rejected rather than stripped for the distinction above, not for any\n * general rule against silent drops — such a rule would condemn the entry-key\n * drop in the same breath. `__proto__` at the TOP level is a whole override, so\n * stripping it would discard operator content and still report `updated`, on a\n * report that quietly lost an edit. An entry key is read by nobody, so dropping\n * one loses nothing. One rule, applied consistently, with opposite outcomes for\n * a stated reason.\n */\nfunction buildOverrideMap(v: unknown): OverrideMap | null {\n if (v === null || typeof v !== \"object\") return null;\n const proto = Object.getPrototypeOf(v) as unknown;\n if (proto !== Object.prototype && proto !== null) return null;\n if (Object.hasOwn(v, \"__proto__\")) return null;\n const built: OverrideMap = {};\n for (const [key, entry] of Object.entries(v as Record<string, unknown>)) {\n if (entry === null || typeof entry !== \"object\" || Array.isArray(entry)) return null;\n // One read each, and the values that were read are the values that are kept.\n const { original, text } = entry as Partial<Override>;\n if (typeof original !== \"string\" || typeof text !== \"string\") return null;\n built[key] = { original, text };\n }\n return built;\n}\n\n/**\n * The exact string that will be stored, or null when the input is malformed.\n *\n * Building and serialising are one step, inside one try, because READING the\n * caller's object can throw on an input that is otherwise well-formed. There are\n * three such paths through `buildOverrideMap`, and the try covers all of them:\n *\n * 1. `Object.entries(v)` — an enumerable getter on the map itself, or a\n * Proxy's `ownKeys` / `getOwnPropertyDescriptor` trap.\n * 2. `const { original, text } = entry` — a getter on the ENTRY, which throws\n * after the map has been walked and before either field is validated.\n * 3. `built[key] = { original, text }` — `built` starts as `{}`, so the\n * assignment walks `Object.prototype`, and a setter poisoned there under\n * that key name throws on the WRITE rather than on any read.\n *\n * `Object.getPrototypeOf(v)` and `Object.hasOwn(v, \"__proto__\")` are two more,\n * both trappable on a Proxy and both ahead of the walk.\n *\n * `setProspectAuditOverrides` is typed to return one of three statuses, and an\n * escaping TypeError is a fourth outcome its callers have no branch for. A value\n * that cannot be read is malformed input, not an exceptional condition — and the\n * caller already has a branch for malformed input.\n *\n * `JSON.stringify` itself can no longer throw here: it is handed a plain object\n * of plain `{ original, text }` string pairs, which has no cycle, no BigInt and\n * no `toJSON` to honour. That used to be the other half of this try — a circular\n * reference or a BigInt in a valid-looking entry threw — and both are now simply\n * dropped with the rest of the entry's extra keys. The try stays wrapped around\n * the whole thing anyway: the day a field that is not a string is added, this\n * still returns `invalid` rather than throwing past the return type.\n *\n * The size cap is measured HERE, on the CONSTRUCTED string: that is the string\n * that gets stored and re-served, an entry count says nothing about it, and the\n * caller's object may be arbitrarily larger than the pairs taken out of it.\n * Which is also what it is NOT: a request-body limit. An oversized body is\n * parsed and walked in full before 33 bytes are stored and `updated` returned,\n * so whoever writes the HTTP route still owes it a body limit of its own.\n */\nfunction serialiseOverrides(overrides: OverrideMap): string | null {\n let json: string;\n try {\n const built = buildOverrideMap(overrides);\n if (built === null) return null;\n json = JSON.stringify(built);\n } catch {\n return null;\n }\n return json.length > OVERRIDES_MAX_LEN ? null : json;\n}\n\n/**\n * Replace one report's override map wholesale and stamp `edited_at`.\n *\n * Whole-map rather than per-key: the caller always holds the complete map, and\n * a partial write has no way to express a deletion. An empty map is legitimate\n * and clears every override — an editor that can set but not unset traps the\n * operator in whatever they first typed.\n *\n * What is stored is REBUILT from the validated strings, not the object passed\n * in, so an entry keeps its `original` and its `text` and nothing else: any\n * other key on it is dropped rather than written through. See\n * `buildOverrideMap` for why that is the contract.\n *\n * LAST WRITE WINS, and two writers silently clobber each other: the second\n * write replaces the first's whole map, with nothing to tell either of them it\n * happened. That is accepted rather than overlooked — there is one operator\n * team, and an editor is open for minutes, so the window is small and the cost\n * of losing it is a paragraph retyped. If a second editor ever exists, the\n * column an optimistic check would key on is `edited_at`: read it with the\n * report, send it back with the write, refuse the write if it moved.\n *\n * `result_json` stays untouched. There is still no way to write it.\n */\nexport async function setProspectAuditOverrides(\n db: Db,\n token: string,\n overrides: OverrideMap,\n): Promise<SetOverridesResult> {\n const serialised = serialiseOverrides(overrides);\n if (serialised === null) return { status: \"invalid\", token };\n\n const existing = await getProspectAuditByToken(db, token);\n if (!existing) return { status: \"not-found\", token };\n\n await db\n .updateTable(\"prospect_audits\")\n .set({ overrides_json: serialised, edited_at: new Date().toISOString() })\n .where(\"token\", \"=\", token)\n .execute();\n return { status: \"updated\", token };\n}\n\n/**\n * Record that a report was opened by someone who is not editing it.\n *\n * Best effort by contract: the caller must not let a failure here fail the\n * response. Knowing when a prospect last looked is useful; it is not worth\n * turning a read route into one that can 500.\n */\nexport async function touchProspectAuditOpened(db: Db, token: string): Promise<void> {\n await db\n .updateTable(\"prospect_audits\")\n .set({ opened_at: new Date().toISOString() })\n .where(\"token\", \"=\", token)\n .execute();\n}\n"],"mappings":";AAAA,SAAS,mBAAmB;AAIrB,SAAS,gBAAwB;AACtC,SAAO,YAAY,EAAE,EAAE,SAAS,WAAW;AAC7C;AAGO,SAAS,aAAa,GAAoB;AAC/C,SAAO,sBAAsB,KAAK,CAAC;AACrC;AAKO,SAAS,qBAA6B;AAC3C,SAAO,MAAM,OAAO,WAAW,CAAC;AAClC;AA6BO,SAAS,QAAQ,KAAqB;AAC3C,QAAM,MAAM,IAAI,KAAK;AACrB,MAAI,QAAQ,GAAI,QAAO;AACvB,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,2BAA2B,KAAK,GAAG,IAAI,MAAM,WAAW,GAAG,EAAE;AAC/E,UAAM,OAAO,EAAE,SAAS,YAAY,EAAE,QAAQ,UAAU,EAAE;AAC1D,UAAM,OAAO,EAAE,SAAS,QAAQ,QAAQ,EAAE;AAC1C,WAAO,GAAG,IAAI,GAAG,IAAI;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAaA,eAAsB,oBACpB,IACA,OACwC;AACxC,QAAM,KAAK,mBAAmB;AAC9B,QAAM,QAAQ,cAAc;AAC5B,QAAM,GACH,WAAW,iBAAiB,EAC5B,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA,KAAK,MAAM;AAAA,IACX,UAAU,QAAQ,MAAM,GAAG;AAAA,IAC3B,UAAU,MAAM;AAAA,IAChB,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,IACnC,QAAQ,MAAM,UAAU;AAAA,IACxB,aAAa,MAAM;AAAA,EACrB,CAAC,EACA,QAAQ;AACX,SAAO,EAAE,IAAI,MAAM;AACrB;AAkBA,eAAsB,wBACpB,IACA,OACkC;AAClC,QAAM,MAAM,MAAM,GACf,WAAW,iBAAiB,EAC5B,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,MAAM,SAAS,KAAK,KAAK,EACzB,iBAAiB;AACpB,SAAO,OAAO;AAChB;AAuBO,IAAM,6BAA6B;AAK1C,SAAS,WAAW,OAAuB;AACzC,QAAM,IAAI,OAAO,SAAS,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI;AACvD,SAAO,KAAK,IAAI,KAAK,IAAI,GAAG,CAAC,GAAG,0BAA0B;AAC5D;AAMA,eAAsB,yBACpB,IACA,OACkC;AAClC,SAAO,GACJ,WAAW,iBAAiB,EAC5B,OAAO,CAAC,MAAM,SAAS,OAAO,YAAY,UAAU,cAAc,aAAa,WAAW,CAAC,EAC3F,QAAQ,cAAc,MAAM,EAC5B,MAAM,WAAW,KAAK,CAAC,EACvB,QAAQ;AACb;AAuBO,IAAM,oBAAoB;AA+GjC,SAAS,iBAAiB,GAAgC;AACxD,MAAI,MAAM,QAAQ,OAAO,MAAM,SAAU,QAAO;AAChD,QAAM,QAAQ,OAAO,eAAe,CAAC;AACrC,MAAI,UAAU,OAAO,aAAa,UAAU,KAAM,QAAO;AACzD,MAAI,OAAO,OAAO,GAAG,WAAW,EAAG,QAAO;AAC1C,QAAM,QAAqB,CAAC;AAC5B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,CAA4B,GAAG;AACvE,QAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,MAAM,QAAQ,KAAK,EAAG,QAAO;AAEhF,UAAM,EAAE,UAAU,KAAK,IAAI;AAC3B,QAAI,OAAO,aAAa,YAAY,OAAO,SAAS,SAAU,QAAO;AACrE,UAAM,GAAG,IAAI,EAAE,UAAU,KAAK;AAAA,EAChC;AACA,SAAO;AACT;AAwCA,SAAS,mBAAmB,WAAuC;AACjE,MAAI;AACJ,MAAI;AACF,UAAM,QAAQ,iBAAiB,SAAS;AACxC,QAAI,UAAU,KAAM,QAAO;AAC3B,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO,KAAK,SAAS,oBAAoB,OAAO;AAClD;AAyBA,eAAsB,0BACpB,IACA,OACA,WAC6B;AAC7B,QAAM,aAAa,mBAAmB,SAAS;AAC/C,MAAI,eAAe,KAAM,QAAO,EAAE,QAAQ,WAAW,MAAM;AAE3D,QAAM,WAAW,MAAM,wBAAwB,IAAI,KAAK;AACxD,MAAI,CAAC,SAAU,QAAO,EAAE,QAAQ,aAAa,MAAM;AAEnD,QAAM,GACH,YAAY,iBAAiB,EAC7B,IAAI,EAAE,gBAAgB,YAAY,YAAW,oBAAI,KAAK,GAAE,YAAY,EAAE,CAAC,EACvE,MAAM,SAAS,KAAK,KAAK,EACzB,QAAQ;AACX,SAAO,EAAE,QAAQ,WAAW,MAAM;AACpC;AASA,eAAsB,yBAAyB,IAAQ,OAA8B;AACnF,QAAM,GACH,YAAY,iBAAiB,EAC7B,IAAI,EAAE,YAAW,oBAAI,KAAK,GAAE,YAAY,EAAE,CAAC,EAC3C,MAAM,SAAS,KAAK,KAAK,EACzB,QAAQ;AACb;","names":[]}