@reddoorla/maintenance 0.86.0 → 0.87.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.
Files changed (167) hide show
  1. package/dist/airtable-ZBUPQJSC.js +9 -0
  2. package/dist/{announce-MXTUQ6C7.js → announce-I2Z4JN3I.js} +49 -28
  3. package/dist/announce-I2Z4JN3I.js.map +1 -0
  4. package/dist/attachments-NLZIJ5NU.js +9 -0
  5. package/dist/{bump-deps-EC74CPLS.js → bump-deps-CGVELRLK.js} +2 -2
  6. package/dist/{chunk-2ZPT7GLV.js → chunk-3JQ2FO3D.js} +3 -3
  7. package/dist/chunk-3JQ2FO3D.js.map +1 -0
  8. package/dist/{chunk-KTH5ZODA.js → chunk-7BZUOZPU.js} +2 -2
  9. package/dist/{chunk-LDWZSLSU.js → chunk-7NYN2P6I.js} +7 -4
  10. package/dist/{chunk-LDWZSLSU.js.map → chunk-7NYN2P6I.js.map} +1 -1
  11. package/dist/{chunk-3VQWSE3F.js → chunk-7UAYMAKR.js} +4 -4
  12. package/dist/{chunk-BYK66EWO.js → chunk-BBUSGEES.js} +52 -46
  13. package/dist/chunk-BBUSGEES.js.map +1 -0
  14. package/dist/{chunk-SLUQ7R6I.js → chunk-C6CLRKPV.js} +5 -5
  15. package/dist/{chunk-P2FE5AI6.js → chunk-CROKUHJB.js} +2 -2
  16. package/dist/{chunk-W7Z3BGCI.js → chunk-DEWJMMG3.js} +2 -2
  17. package/dist/{chunk-ACBRLLFT.js → chunk-F3JWOPTG.js} +7 -3
  18. package/dist/chunk-F3JWOPTG.js.map +1 -0
  19. package/dist/{chunk-TERE5SDF.js → chunk-HEWSRXYV.js} +4 -4
  20. package/dist/chunk-HHMMUR4P.js +335 -0
  21. package/dist/chunk-HHMMUR4P.js.map +1 -0
  22. package/dist/{chunk-X3B723J4.js → chunk-HSI6Q7MI.js} +38 -9
  23. package/dist/chunk-HSI6Q7MI.js.map +1 -0
  24. package/dist/{chunk-Z7AXYKQB.js → chunk-IY6256GK.js} +1 -1
  25. package/dist/{chunk-CQR4LIM7.js → chunk-MT2JINSS.js} +5 -1
  26. package/dist/{chunk-CQR4LIM7.js.map → chunk-MT2JINSS.js.map} +1 -1
  27. package/dist/{chunk-F5KM4LEQ.js → chunk-NCMZMLY7.js} +3 -3
  28. package/dist/{chunk-2NDRHXMR.js → chunk-NDWYJLRF.js} +17 -140
  29. package/dist/chunk-NDWYJLRF.js.map +1 -0
  30. package/dist/{chunk-PPE5KZJK.js → chunk-NXNPDCF3.js} +3 -3
  31. package/dist/{chunk-ITIOFN7A.js → chunk-P6AORG32.js} +29 -10
  32. package/dist/chunk-P6AORG32.js.map +1 -0
  33. package/dist/{chunk-N45M3FDK.js → chunk-SDDPUAWH.js} +4 -4
  34. package/dist/{chunk-ZKXSX4LO.js → chunk-TD2NNXLZ.js} +9 -9
  35. package/dist/{chunk-ILUNZHYV.js → chunk-YFAXOD7Z.js} +10 -6
  36. package/dist/chunk-YFAXOD7Z.js.map +1 -0
  37. package/dist/{chunk-2EVEFOZY.js → chunk-ZBDZTJUO.js} +2 -2
  38. package/dist/chunk-ZGBEIWSC.js +146 -0
  39. package/dist/chunk-ZGBEIWSC.js.map +1 -0
  40. package/dist/cli/bin.js +32 -27
  41. package/dist/cli/bin.js.map +1 -1
  42. package/dist/cli/commands/audit.js +15 -11
  43. package/dist/cli/commands/audit.js.map +1 -1
  44. package/dist/{convert-to-pnpm-6L6P74WR.js → convert-to-pnpm-QFJOEP5V.js} +2 -2
  45. package/dist/{db-B35OGV3G.js → db-UXNIZ2RG.js} +6 -6
  46. package/dist/{digest-BL6UJYKH.js → digest-FW6QMZZJ.js} +17 -16
  47. package/dist/{digest-BL6UJYKH.js.map → digest-FW6QMZZJ.js.map} +1 -1
  48. package/dist/{email-VTTTKKL5.js → email-KJXASVH5.js} +3 -3
  49. package/dist/{ensure-site-DFTB7MN3.js → ensure-site-4DCEBAPM.js} +19 -11
  50. package/dist/ensure-site-4DCEBAPM.js.map +1 -0
  51. package/dist/fleet-state-7TJ2GYT2.js +39 -0
  52. package/dist/{forms-notify-target-75U6OU7L.js → forms-notify-target-2EAWCWBW.js} +7 -4
  53. package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
  54. package/dist/{github-signals-IJUPGCUX.js → github-signals-E2WPYTBK.js} +9 -9
  55. package/dist/{header-image-AWLLQA4L.js → header-image-6IC53W27.js} +7 -7
  56. package/dist/{header-images-335L2AKB.js → header-images-SQBCE5OD.js} +21 -1
  57. package/dist/header-images-SQBCE5OD.js.map +1 -0
  58. package/dist/{health-endpoint-WJC2UMYS.js → health-endpoint-RFXL2F4A.js} +2 -2
  59. package/dist/health-mirror-L5JBEJVA.js +41 -0
  60. package/dist/health-mirror-L5JBEJVA.js.map +1 -0
  61. package/dist/{import-airtable-HE43FB4H.js → import-airtable-OHVY344G.js} +7 -5
  62. package/dist/index.d.ts +149 -0
  63. package/dist/index.js +147 -50
  64. package/dist/index.js.map +1 -1
  65. package/dist/{init-NJEOEO7I.js → init-XRAQZYID.js} +9 -9
  66. package/dist/{launch-XNAX427U.js → launch-IX4Y5JJC.js} +55 -26
  67. package/dist/launch-IX4Y5JJC.js.map +1 -0
  68. package/dist/{lighthouse-airtable-LBVIYYP5.js → lighthouse-airtable-3GUWHETW.js} +3 -3
  69. package/dist/{onboard-GK4PHPNI.js → onboard-O4IAHJMO.js} +2 -2
  70. package/dist/{orchestrate-77UO4BYJ.js → orchestrate-YLWQ6KK2.js} +10 -9
  71. package/dist/{parity-MWS24A3H.js → parity-56KA2DGR.js} +4 -4
  72. package/dist/{pipeline-S6CVHDGB.js → pipeline-3AANZFCR.js} +2 -2
  73. package/dist/{preflight-6WBNU2GZ.js → preflight-TNORKGCX.js} +11 -10
  74. package/dist/{preflight-6WBNU2GZ.js.map → preflight-TNORKGCX.js.map} +1 -1
  75. package/dist/{prismic-ci-3RHOZUUT.js → prismic-ci-G2AK53VL.js} +5 -5
  76. package/dist/{prismic-models-QBGUT2Q3.js → prismic-models-O6Z3OK3A.js} +13 -8
  77. package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
  78. package/dist/prospect/types.d.ts +267 -0
  79. package/dist/prospect/types.js +1 -0
  80. package/dist/{prospect-audit-IUNCJ2LV.js → prospect-audit-KSXUNQ5J.js} +4 -4
  81. package/dist/recipes/sync-configs.js +2 -2
  82. package/dist/{render-AQAVQDPZ.js → render-IFOGFB5T.js} +3 -3
  83. package/dist/render-IFOGFB5T.js.map +1 -0
  84. package/dist/render-from-row-BWSOIYDS.js +17 -0
  85. package/dist/render-from-row-BWSOIYDS.js.map +1 -0
  86. package/dist/{renovate-dispatch-QJY75ODR.js → renovate-dispatch-6GQWTRKX.js} +10 -10
  87. package/dist/{renovate-dispatch-QJY75ODR.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
  88. package/dist/{report-4EVNDHOZ.js → report-R3E3Y7HR.js} +63 -21
  89. package/dist/report-R3E3Y7HR.js.map +1 -0
  90. package/dist/report-mirror-4MNORYQ4.js +49 -0
  91. package/dist/report-mirror-4MNORYQ4.js.map +1 -0
  92. package/dist/rerender-3FM6DNRH.js +32 -0
  93. package/dist/rerender-3FM6DNRH.js.map +1 -0
  94. package/dist/{self-updating-6DWI2Z56.js → self-updating-UCIWESAX.js} +2 -2
  95. package/dist/{selftest-RC36MTKP.js → selftest-VGHWPN72.js} +22 -20
  96. package/dist/{selftest-RC36MTKP.js.map → selftest-VGHWPN72.js.map} +1 -1
  97. package/dist/site-mirror-NIREX5ME.js +52 -0
  98. package/dist/site-mirror-NIREX5ME.js.map +1 -0
  99. package/dist/{smoke-suite-VYY2LXM2.js → smoke-suite-U3UKD3NS.js} +2 -2
  100. package/dist/{submissions-Q7DRFOCD.js → submissions-IQEJX6FK.js} +2 -2
  101. package/dist/{svelte-codemods-25J2FO22.js → svelte-codemods-GOKXTBHW.js} +2 -2
  102. package/dist/{sync-34HNZZ56.js → sync-CTUVXMCK.js} +4 -4
  103. package/dist/{sync-configs-3SE6PSCO.js → sync-configs-SPTLFJCC.js} +4 -4
  104. package/dist/{upgrade-WBRQ62FX.js → upgrade-W6ZR3FJH.js} +2 -2
  105. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  106. package/dist/{websites-SXWTP72S.js → websites-D6WYFAES.js} +2 -2
  107. package/dist/websites-D6WYFAES.js.map +1 -0
  108. package/dist/{write-audits-to-airtable-MBEILLPU.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
  109. package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
  110. package/package.json +5 -1
  111. package/dist/airtable-G7WBQQMI.js +0 -9
  112. package/dist/announce-MXTUQ6C7.js.map +0 -1
  113. package/dist/chunk-2NDRHXMR.js.map +0 -1
  114. package/dist/chunk-2ZPT7GLV.js.map +0 -1
  115. package/dist/chunk-ACBRLLFT.js.map +0 -1
  116. package/dist/chunk-BYK66EWO.js.map +0 -1
  117. package/dist/chunk-ILUNZHYV.js.map +0 -1
  118. package/dist/chunk-ITIOFN7A.js.map +0 -1
  119. package/dist/chunk-X3B723J4.js.map +0 -1
  120. package/dist/ensure-site-DFTB7MN3.js.map +0 -1
  121. package/dist/forms-notify-target-75U6OU7L.js.map +0 -1
  122. package/dist/header-images-335L2AKB.js.map +0 -1
  123. package/dist/health-mirror-4B2L7T2Y.js +0 -75
  124. package/dist/health-mirror-4B2L7T2Y.js.map +0 -1
  125. package/dist/launch-XNAX427U.js.map +0 -1
  126. package/dist/prismic-models-QBGUT2Q3.js.map +0 -1
  127. package/dist/report-4EVNDHOZ.js.map +0 -1
  128. /package/dist/{airtable-G7WBQQMI.js.map → airtable-ZBUPQJSC.js.map} +0 -0
  129. /package/dist/{import-airtable-HE43FB4H.js.map → attachments-NLZIJ5NU.js.map} +0 -0
  130. /package/dist/{bump-deps-EC74CPLS.js.map → bump-deps-CGVELRLK.js.map} +0 -0
  131. /package/dist/{chunk-KTH5ZODA.js.map → chunk-7BZUOZPU.js.map} +0 -0
  132. /package/dist/{chunk-3VQWSE3F.js.map → chunk-7UAYMAKR.js.map} +0 -0
  133. /package/dist/{chunk-SLUQ7R6I.js.map → chunk-C6CLRKPV.js.map} +0 -0
  134. /package/dist/{chunk-P2FE5AI6.js.map → chunk-CROKUHJB.js.map} +0 -0
  135. /package/dist/{chunk-W7Z3BGCI.js.map → chunk-DEWJMMG3.js.map} +0 -0
  136. /package/dist/{chunk-TERE5SDF.js.map → chunk-HEWSRXYV.js.map} +0 -0
  137. /package/dist/{chunk-Z7AXYKQB.js.map → chunk-IY6256GK.js.map} +0 -0
  138. /package/dist/{chunk-F5KM4LEQ.js.map → chunk-NCMZMLY7.js.map} +0 -0
  139. /package/dist/{chunk-PPE5KZJK.js.map → chunk-NXNPDCF3.js.map} +0 -0
  140. /package/dist/{chunk-N45M3FDK.js.map → chunk-SDDPUAWH.js.map} +0 -0
  141. /package/dist/{chunk-ZKXSX4LO.js.map → chunk-TD2NNXLZ.js.map} +0 -0
  142. /package/dist/{chunk-2EVEFOZY.js.map → chunk-ZBDZTJUO.js.map} +0 -0
  143. /package/dist/{convert-to-pnpm-6L6P74WR.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
  144. /package/dist/{db-B35OGV3G.js.map → db-UXNIZ2RG.js.map} +0 -0
  145. /package/dist/{email-VTTTKKL5.js.map → email-KJXASVH5.js.map} +0 -0
  146. /package/dist/{lighthouse-airtable-LBVIYYP5.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
  147. /package/dist/{github-signals-IJUPGCUX.js.map → github-signals-E2WPYTBK.js.map} +0 -0
  148. /package/dist/{header-image-AWLLQA4L.js.map → header-image-6IC53W27.js.map} +0 -0
  149. /package/dist/{health-endpoint-WJC2UMYS.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
  150. /package/dist/{orchestrate-77UO4BYJ.js.map → import-airtable-OHVY344G.js.map} +0 -0
  151. /package/dist/{init-NJEOEO7I.js.map → init-XRAQZYID.js.map} +0 -0
  152. /package/dist/{parity-MWS24A3H.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
  153. /package/dist/{onboard-GK4PHPNI.js.map → onboard-O4IAHJMO.js.map} +0 -0
  154. /package/dist/{pipeline-S6CVHDGB.js.map → orchestrate-YLWQ6KK2.js.map} +0 -0
  155. /package/dist/{render-AQAVQDPZ.js.map → parity-56KA2DGR.js.map} +0 -0
  156. /package/dist/{websites-SXWTP72S.js.map → pipeline-3AANZFCR.js.map} +0 -0
  157. /package/dist/{prismic-ci-3RHOZUUT.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
  158. /package/dist/{write-audits-to-airtable-MBEILLPU.js.map → prospect/types.js.map} +0 -0
  159. /package/dist/{prospect-audit-IUNCJ2LV.js.map → prospect-audit-KSXUNQ5J.js.map} +0 -0
  160. /package/dist/{self-updating-6DWI2Z56.js.map → self-updating-UCIWESAX.js.map} +0 -0
  161. /package/dist/{smoke-suite-VYY2LXM2.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
  162. /package/dist/{submissions-Q7DRFOCD.js.map → submissions-IQEJX6FK.js.map} +0 -0
  163. /package/dist/{svelte-codemods-25J2FO22.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
  164. /package/dist/{sync-34HNZZ56.js.map → sync-CTUVXMCK.js.map} +0 -0
  165. /package/dist/{sync-configs-3SE6PSCO.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
  166. /package/dist/{upgrade-WBRQ62FX.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
  167. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
@@ -4,19 +4,19 @@ import {
4
4
  import "./chunk-BNM5POSJ.js";
5
5
  import "./chunk-SNSTJLQW.js";
6
6
  import "./chunk-5ML7FMBD.js";
7
- import "./chunk-DJZWYIPP.js";
8
7
  import {
9
8
  openBase,
10
9
  readAirtableConfig
11
10
  } from "./chunk-HN6I2XWI.js";
12
- import "./chunk-2OP4JKIQ.js";
11
+ import "./chunk-DJZWYIPP.js";
13
12
  import {
14
13
  canonicalizeStatus,
15
14
  listWebsites,
16
15
  siteSlug,
17
16
  toAirtableStatus,
18
17
  updateSiteField
19
- } from "./chunk-LDWZSLSU.js";
18
+ } from "./chunk-7NYN2P6I.js";
19
+ import "./chunk-2OP4JKIQ.js";
20
20
  import "./chunk-6VAL7XJT.js";
21
21
 
22
22
  // src/recipes/forms-notify-target.ts
@@ -72,6 +72,7 @@ async function formsNotifyTarget(deps) {
72
72
  }
73
73
  const cell = deps.set === "on" ? toAirtableStatus(VERIFY_STATUS) : restoreCell(restoreRaw);
74
74
  await updateSiteField(base, row.id, STATUS_COLUMN, cell);
75
+ await deps.siteMirror?.site(row.id, { [STATUS_COLUMN]: cell });
75
76
  const after = findSite(await listWebsites(base), deps.site);
76
77
  if (!after) {
77
78
  throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {
@@ -142,7 +143,9 @@ async function runFormsNotifyTargetCommand(site, opts) {
142
143
  return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };
143
144
  }
144
145
  try {
146
+ const { makeSiteMirror } = await import("./site-mirror-NIREX5ME.js");
145
147
  const result = await formsNotifyTarget({
148
+ siteMirror: await makeSiteMirror(),
146
149
  site: site.trim(),
147
150
  ...set ? { set } : {},
148
151
  ...opts.restore ? { restore: opts.restore } : {}
@@ -160,4 +163,4 @@ export {
160
163
  formatNotifyTarget,
161
164
  runFormsNotifyTargetCommand
162
165
  };
163
- //# sourceMappingURL=forms-notify-target-75U6OU7L.js.map
166
+ //# sourceMappingURL=forms-notify-target-2EAWCWBW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { SiteMirror } from \"../db/site-mirror.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus, toAirtableStatus } from \"../reports/airtable/site-status.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosted-only\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintained\";\nexport const VERIFY_STATUS: Status = \"launching\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n /** #539 Phase 5: Turso write-through for the Status cell this flips. Injected\n * at the CLI root, never defaulted — the unit suite calls this with a fake\n * base and must not open a real libSQL handle. */\n siteMirror?: SiteMirror;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\n/**\n * The exact Airtable cell to write for an operator-supplied `--restore` value:\n * the operator's own string, verbatim.\n *\n * This is the ONE non-revertible surface in the #539 Phase 4 stage-1 rename —\n * every other change is code, and `git revert` undoes code. It cannot undo a\n * rewritten Airtable cell. So the rule here is stricter than everywhere else:\n * substitute only when the substitution is provably lossless, i.e. when\n * canonicalizing and mapping back ROUND-TRIPS to the operator's own string.\n *\n * That condition is, today, never false in a way that changes the answer — which\n * is the point. `toAirtableStatus(canonicalizeStatus(raw))` either equals `raw`\n * (so writing it is writing `raw`) or it does not (so we must write `raw`). The\n * function therefore reduces to \"write raw\", and it is written this way so the\n * reduction is visible rather than assumed. The case that made it matter:\n * `--restore legacy` canonicalizes to `archived`, which maps back to\n * \"deprecated\" — a different, real, operator-visible Airtable option that nobody\n * asked for. `hosting` → `hosted-only` → \"hosting\" round-trips and is safe.\n *\n * Airtable, not this module, is the authority on which option strings the\n * \"Status\" single-select accepts. Writing verbatim delegates to it: a typo is\n * rejected loudly at the API, exactly as it was before the rename.\n */\nexport function restoreCell(raw: string): string {\n const canonical = canonicalizeStatus(raw);\n if (canonical === null) return raw;\n const roundTripped = toAirtableStatus(canonical);\n return roundTripped === raw ? roundTripped : raw;\n}\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n // `--restore` is operator free text. It is canonicalized for what this command\n // REPORTS (`flip.to`, and the predicates downstream), but NEVER for what it\n // WRITES — see `restoreCell` below.\n const restoreRaw = deps.restore?.trim();\n const to = deps.set === \"on\" ? VERIFY_STATUS : (canonicalizeStatus(restoreRaw) ?? undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosted-only\" or \"archived\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintained\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n // `--set on` writes a status this MODULE owns (VERIFY_STATUS), so mapping it to\n // the current Airtable vocabulary is correct. `--set off` writes the operator's\n // own string — see restoreCell.\n const cell = deps.set === \"on\" ? toAirtableStatus(VERIFY_STATUS) : restoreCell(restoreRaw!);\n await updateSiteField(base, row.id, STATUS_COLUMN, cell);\n // Mirror the same cell. The console reads Status from Turso, so without this a\n // site flipped into verify mode still reads as live for up to an hour — on the\n // one surface an operator would check to confirm the flip.\n await deps.siteMirror?.site(row.id, { [STATUS_COLUMN]: cell });\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n // `confirmed` compares the RAW cell against the exact string written, not\n // canonical-to-canonical. Canonical equality is too weak to be a read-back\n // guard here: `legacy` and `deprecated` are the same canonical status, so a\n // cell holding either would confirm a flip to the other. Comparing raw is the\n // only comparison that can actually catch \"the cell is not what I sent\". The\n // canonical check rides along so a reader that stopped canonicalizing is\n // caught too.\n flip: {\n from: row.status,\n to: to!,\n confirmed: after.statusRaw === cell && after.status === to,\n },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n // #539 Phase 5: the flip writes Status on the Websites row; mirror it so the\n // console (which reads Turso) shows the new routing immediately.\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const result = await formsNotifyTarget({\n siteMirror: await makeSiteMirror(),\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
@@ -3,8 +3,8 @@ import {
3
3
  } from "./chunk-PZQ3WED2.js";
4
4
  import {
5
5
  formatFleetWriteSummary
6
- } from "./chunk-PPE5KZJK.js";
7
- import "./chunk-KTH5ZODA.js";
6
+ } from "./chunk-NXNPDCF3.js";
7
+ import "./chunk-7BZUOZPU.js";
8
8
  import {
9
9
  detectSignalEvents,
10
10
  fleetSweptEvent
@@ -13,6 +13,11 @@ import {
13
13
  openBase,
14
14
  readAirtableConfig
15
15
  } from "./chunk-HN6I2XWI.js";
16
+ import {
17
+ listWebsites,
18
+ siteSlug,
19
+ updateGitHubSignals
20
+ } from "./chunk-7NYN2P6I.js";
16
21
  import {
17
22
  makeGitHub
18
23
  } from "./chunk-XHHMM5HX.js";
@@ -22,11 +27,6 @@ import {
22
27
  import "./chunk-JHGHLKFS.js";
23
28
  import "./chunk-3SF7D4DY.js";
24
29
  import "./chunk-LBYOLBW7.js";
25
- import {
26
- listWebsites,
27
- siteSlug,
28
- updateGitHubSignals
29
- } from "./chunk-LDWZSLSU.js";
30
30
  import "./chunk-6VAL7XJT.js";
31
31
 
32
32
  // src/audits/github-signals.ts
@@ -90,7 +90,7 @@ async function runGitHubSignalsCommand(opts, deps = {}) {
90
90
  );
91
91
  const sweptAt = (/* @__PURE__ */ new Date()).toISOString();
92
92
  const makeMirror = deps.makeMirror ?? (async () => {
93
- const { makeHealthMirrorBestEffort } = await import("./health-mirror-4B2L7T2Y.js");
93
+ const { makeHealthMirrorBestEffort } = await import("./health-mirror-L5JBEJVA.js");
94
94
  return makeHealthMirrorBestEffort();
95
95
  });
96
96
  const mirror = await makeMirror();
@@ -155,4 +155,4 @@ export {
155
155
  githubSignalsExitCode,
156
156
  runGitHubSignalsCommand
157
157
  };
158
- //# sourceMappingURL=github-signals-IJUPGCUX.js.map
158
+ //# sourceMappingURL=github-signals-E2WPYTBK.js.map
@@ -1,18 +1,18 @@
1
- import {
2
- generateHeaderImage
3
- } from "./chunk-5UINJNAJ.js";
4
1
  import {
5
2
  uploadAttachment
6
- } from "./chunk-Z7AXYKQB.js";
3
+ } from "./chunk-IY6256GK.js";
7
4
  import {
8
5
  openBase,
9
6
  readAirtableConfig
10
7
  } from "./chunk-HN6I2XWI.js";
8
+ import {
9
+ generateHeaderImage
10
+ } from "./chunk-5UINJNAJ.js";
11
11
  import {
12
12
  ACTIVE_STATUSES,
13
13
  listWebsites,
14
14
  siteSlug
15
- } from "./chunk-LDWZSLSU.js";
15
+ } from "./chunk-7NYN2P6I.js";
16
16
  import "./chunk-6VAL7XJT.js";
17
17
 
18
18
  // src/cli/commands/header-image.ts
@@ -103,7 +103,7 @@ async function runHeaderImageCommand(site, opts) {
103
103
  let withStore = opts;
104
104
  if (opts.writeAirtable && !opts.storeDb && process.env.TURSO_DATABASE_URL) {
105
105
  const { openDb, readDbConfig } = await import("./client-AUE3XUPO.js");
106
- const { storeHeaderImage } = await import("./header-images-335L2AKB.js");
106
+ const { storeHeaderImage } = await import("./header-images-SQBCE5OD.js");
107
107
  const db = await openDb(readDbConfig());
108
108
  withStore = { ...opts, storeDb: (id, img) => storeHeaderImage(db, id, img) };
109
109
  }
@@ -115,4 +115,4 @@ export {
115
115
  resolveTargets,
116
116
  runHeaderImageCommand
117
117
  };
118
- //# sourceMappingURL=header-image-AWLLQA4L.js.map
118
+ //# sourceMappingURL=header-image-6IC53W27.js.map
@@ -7,6 +7,25 @@ async function storeHeaderImage(db, siteId, image) {
7
7
  header_image_generated_at: image.generatedAt
8
8
  }).where("id", "=", siteId).execute();
9
9
  }
10
+ async function loadHeaderImage(db, siteId) {
11
+ const row = await db.selectFrom("sites").select([
12
+ "header_image",
13
+ "header_image_filename",
14
+ "header_image_type",
15
+ "header_image_generated_at"
16
+ ]).where("id", "=", siteId).executeTakeFirst();
17
+ if (!row) return null;
18
+ const raw = row.header_image;
19
+ if (raw === null || raw === void 0) return null;
20
+ const bytes = new Uint8Array(raw);
21
+ if (bytes.length === 0) return null;
22
+ return {
23
+ bytes,
24
+ filename: row.header_image_filename ?? "header-image",
25
+ contentType: row.header_image_type ?? "application/octet-stream",
26
+ generatedAt: row.header_image_generated_at ?? null
27
+ };
28
+ }
10
29
  function headerImageAttachment(rec) {
11
30
  const atts = rec.fields["Header image"];
12
31
  const a = atts?.at(-1);
@@ -63,6 +82,7 @@ export {
63
82
  backfillHeaderImages,
64
83
  formatBackfillResult,
65
84
  headerImageAttachment,
85
+ loadHeaderImage,
66
86
  storeHeaderImage
67
87
  };
68
- //# sourceMappingURL=header-images-335L2AKB.js.map
88
+ //# sourceMappingURL=header-images-SQBCE5OD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/header-images.ts"],"sourcesContent":["/** Header images move to Turso (#539, design D5 completed).\n *\n * Since Phase 1 the schema has reserved `sites.header_image*` as the image's\n * home — the importer deliberately never touches those columns — but nothing\n * wrote them, so the read layer's `headerImage` was null fleet-wide and both\n * cockpit preflight and approve-report stayed pinned to the Airtable reader.\n * Two writers and one reader close that:\n *\n * - `storeHeaderImage` — the shared write, used by the header-image CLI's\n * dual-write (Airtable upload + Turso store, until Phase 5 freezes Airtable)\n * and by the backfill.\n * - `loadHeaderImage` — the reader. Until it existed the bytes went in and could\n * not come out, so every consumer still fetched Airtable's signed URL.\n * - `backfillHeaderImages` — one-shot: copy every site's CURRENT Airtable\n * attachment into Turso. Idempotent — a site whose BLOB is already\n * populated is skipped, so re-runs never clobber a freshly generated image\n * with a stale Airtable copy. Emits its machine line on EVERY run.\n */\nimport type { Db } from \"./client.js\";\nimport type { RawRecord } from \"./import-airtable.js\";\n\nexport type StoredHeaderImage = {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n /** The generator's stamp; null for a backfilled copy (a backfill is not a\n * generation — the column keeps meaning \"when the generator made it\"). */\n generatedAt: string | null;\n};\n\nexport async function storeHeaderImage(\n db: Db,\n siteId: string,\n image: StoredHeaderImage,\n): Promise<void> {\n await db\n .updateTable(\"sites\")\n .set({\n header_image: image.bytes,\n header_image_filename: image.filename,\n header_image_type: image.contentType,\n header_image_generated_at: image.generatedAt,\n })\n .where(\"id\", \"=\", siteId)\n .execute();\n}\n\n/**\n * Read one site's stored header image back.\n *\n * A SEPARATE query from the site read, deliberately. `getSiteBySlug` excludes\n * the BLOB on purpose — it is 0.6–0.8 MB per site in production, so a selectAll\n * would haul megabytes into every dashboard GET and every form ingest — which\n * means reading the bytes has to be an explicit, per-site act rather than a\n * field that arrives for free.\n *\n * Null when the site has no image, when the id is unknown, and when the\n * metadata is present but the BLOB is not: handing a consumer an empty buffer\n * with a filename would render a report with a broken header instead of failing\n * loudly with \"no header image\".\n */\nexport async function loadHeaderImage(db: Db, siteId: string): Promise<StoredHeaderImage | null> {\n const row = await db\n .selectFrom(\"sites\")\n .select([\n \"header_image\",\n \"header_image_filename\",\n \"header_image_type\",\n \"header_image_generated_at\",\n ])\n .where(\"id\", \"=\", siteId)\n .executeTakeFirst();\n if (!row) return null;\n // Normalize BEFORE measuring. libSQL can hand the BLOB back as an ArrayBuffer\n // rather than a Uint8Array, and an ArrayBuffer has `byteLength`, not `length` —\n // so a naive `bytes.length === 0` compares `undefined` to 0, passes, and lets a\n // zero-length image through as if it were real. Mutation-testing the null check\n // is what exposed that; the guard did not do what its own name said.\n const raw = row.header_image;\n if (raw === null || raw === undefined) return null;\n // `raw` is a Uint8Array on some drivers and an ArrayBuffer on others; the\n // Uint8Array constructor accepts either, but the two do not share a TS type.\n const bytes = new Uint8Array(raw as unknown as ArrayBufferLike);\n if (bytes.length === 0) return null;\n\n return {\n bytes,\n filename: row.header_image_filename ?? \"header-image\",\n contentType: row.header_image_type ?? \"application/octet-stream\",\n generatedAt: row.header_image_generated_at ?? null,\n };\n}\n\ntype Attachment = { url: string; filename: string; type: string };\n\n/** NEWEST \"Header image\" attachment of a raw Websites record, or null.\n *\n * The tail, not the head: Airtable's uploadAttachment APPENDS, so a field that ever\n * held more than one file served its OLDEST image from `[0]` forever — see the note\n * in `reports/airtable/websites.ts` and #574/#577. Kept in step with that reader so\n * the Turso mirror and the send path can never disagree about which file is current. */\nexport function headerImageAttachment(rec: RawRecord): Attachment | null {\n const atts = rec.fields[\"Header image\"] as Array<Partial<Attachment>> | undefined;\n const a = atts?.at(-1);\n return a && typeof a.url === \"string\" && a.url.length > 0\n ? {\n url: a.url,\n filename: typeof a.filename === \"string\" ? a.filename : \"header-image\",\n type: typeof a.type === \"string\" ? a.type : \"application/octet-stream\",\n }\n : null;\n}\n\nexport type BackfillIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n /** Fetch attachment bytes; null on failure (named in the summary, not fatal). */\n fetchBytes: (url: string) => Promise<Uint8Array | null>;\n};\n\nexport type BackfillResult = {\n stored: number;\n /** Sites whose BLOB was already populated — never overwritten. */\n skipped: number;\n /** Sites with no Airtable attachment at all. */\n absent: number;\n /** Record ids whose attachment fetch failed — named, never silent. */\n failed: string[];\n};\n\nexport async function backfillHeaderImages(db: Db, io: BackfillIo): Promise<BackfillResult> {\n const records = await io.listWebsiteRecords();\n const populated = new Set(\n (await db.selectFrom(\"sites\").select(\"id\").where(\"header_image\", \"is not\", null).execute()).map(\n (r) => r.id,\n ),\n );\n const result: BackfillResult = { stored: 0, skipped: 0, absent: 0, failed: [] };\n for (const rec of records) {\n const att = headerImageAttachment(rec);\n if (!att) {\n result.absent++;\n continue;\n }\n if (populated.has(rec.id)) {\n result.skipped++;\n continue;\n }\n const bytes = await io.fetchBytes(att.url);\n if (bytes === null) {\n result.failed.push(rec.id);\n continue;\n }\n await storeHeaderImage(db, rec.id, {\n bytes,\n filename: att.filename,\n contentType: att.type,\n generatedAt: null,\n });\n result.stored++;\n }\n return result;\n}\n\n/** Human lines + the machine line, emitted on every run (count=0 included). */\nexport function formatBackfillResult(r: BackfillResult): string {\n const lines: string[] = [];\n if (r.failed.length > 0) {\n lines.push(`⚠ ${r.failed.length} attachment fetch(es) failed: ${r.failed.join(\", \")}`);\n }\n lines.push(\n `HEADER_IMAGE_BACKFILL stored=${r.stored} skipped=${r.skipped} absent=${r.absent} failed=${r.failed.length}`,\n );\n return lines.join(\"\\n\");\n}\n"],"mappings":";AA8BA,eAAsB,iBACpB,IACA,QACA,OACe;AACf,QAAM,GACH,YAAY,OAAO,EACnB,IAAI;AAAA,IACH,cAAc,MAAM;AAAA,IACpB,uBAAuB,MAAM;AAAA,IAC7B,mBAAmB,MAAM;AAAA,IACzB,2BAA2B,MAAM;AAAA,EACnC,CAAC,EACA,MAAM,MAAM,KAAK,MAAM,EACvB,QAAQ;AACb;AAgBA,eAAsB,gBAAgB,IAAQ,QAAmD;AAC/F,QAAM,MAAM,MAAM,GACf,WAAW,OAAO,EAClB,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,MAAM,MAAM,KAAK,MAAM,EACvB,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO;AAMjB,QAAM,MAAM,IAAI;AAChB,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAG9C,QAAM,QAAQ,IAAI,WAAW,GAAiC;AAC9D,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,SAAO;AAAA,IACL;AAAA,IACA,UAAU,IAAI,yBAAyB;AAAA,IACvC,aAAa,IAAI,qBAAqB;AAAA,IACtC,aAAa,IAAI,6BAA6B;AAAA,EAChD;AACF;AAUO,SAAS,sBAAsB,KAAmC;AACvE,QAAM,OAAO,IAAI,OAAO,cAAc;AACtC,QAAM,IAAI,MAAM,GAAG,EAAE;AACrB,SAAO,KAAK,OAAO,EAAE,QAAQ,YAAY,EAAE,IAAI,SAAS,IACpD;AAAA,IACE,KAAK,EAAE;AAAA,IACP,UAAU,OAAO,EAAE,aAAa,WAAW,EAAE,WAAW;AAAA,IACxD,MAAM,OAAO,EAAE,SAAS,WAAW,EAAE,OAAO;AAAA,EAC9C,IACA;AACN;AAkBA,eAAsB,qBAAqB,IAAQ,IAAyC;AAC1F,QAAM,UAAU,MAAM,GAAG,mBAAmB;AAC5C,QAAM,YAAY,IAAI;AAAA,KACnB,MAAM,GAAG,WAAW,OAAO,EAAE,OAAO,IAAI,EAAE,MAAM,gBAAgB,UAAU,IAAI,EAAE,QAAQ,GAAG;AAAA,MAC1F,CAAC,MAAM,EAAE;AAAA,IACX;AAAA,EACF;AACA,QAAM,SAAyB,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAC,EAAE;AAC9E,aAAW,OAAO,SAAS;AACzB,UAAM,MAAM,sBAAsB,GAAG;AACrC,QAAI,CAAC,KAAK;AACR,aAAO;AACP;AAAA,IACF;AACA,QAAI,UAAU,IAAI,IAAI,EAAE,GAAG;AACzB,aAAO;AACP;AAAA,IACF;AACA,UAAM,QAAQ,MAAM,GAAG,WAAW,IAAI,GAAG;AACzC,QAAI,UAAU,MAAM;AAClB,aAAO,OAAO,KAAK,IAAI,EAAE;AACzB;AAAA,IACF;AACA,UAAM,iBAAiB,IAAI,IAAI,IAAI;AAAA,MACjC;AAAA,MACA,UAAU,IAAI;AAAA,MACd,aAAa,IAAI;AAAA,MACjB,aAAa;AAAA,IACf,CAAC;AACD,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAGO,SAAS,qBAAqB,GAA2B;AAC9D,QAAM,QAAkB,CAAC;AACzB,MAAI,EAAE,OAAO,SAAS,GAAG;AACvB,UAAM,KAAK,UAAK,EAAE,OAAO,MAAM,iCAAiC,EAAE,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,EACvF;AACA,QAAM;AAAA,IACJ,gCAAgC,EAAE,MAAM,YAAY,EAAE,OAAO,WAAW,EAAE,MAAM,WAAW,EAAE,OAAO,MAAM;AAAA,EAC5G;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;","names":[]}
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-TVL5MVUF.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-P2FE5AI6.js";
11
+ } from "./chunk-CROKUHJB.js";
12
12
  import {
13
13
  healthEndpoint
14
14
  } from "./chunk-BQRFQSGY.js";
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
51
51
  export {
52
52
  runHealthEndpointCommand
53
53
  };
54
- //# sourceMappingURL=health-endpoint-WJC2UMYS.js.map
54
+ //# sourceMappingURL=health-endpoint-RFXL2F4A.js.map
@@ -0,0 +1,41 @@
1
+ import {
2
+ mirrorHealthFields,
3
+ mirrorScheduleFields
4
+ } from "./chunk-HHMMUR4P.js";
5
+ import "./chunk-P6AORG32.js";
6
+ import "./chunk-F3JWOPTG.js";
7
+ import "./chunk-VAD43MOR.js";
8
+ import "./chunk-7NYN2P6I.js";
9
+ import {
10
+ openDb,
11
+ readDbConfig
12
+ } from "./chunk-JHGHLKFS.js";
13
+ import "./chunk-3SF7D4DY.js";
14
+ import "./chunk-6VAL7XJT.js";
15
+
16
+ // src/audits/health-mirror.ts
17
+ async function makeHealthMirrorBestEffort(open = () => openDb(readDbConfig())) {
18
+ let db;
19
+ try {
20
+ db = await open();
21
+ } catch (e) {
22
+ console.error(`[health-mirror] mirroring disabled: no libSQL (${String(e)})`);
23
+ return null;
24
+ }
25
+ return (siteId, fields) => mirrorHealthFields(db, siteId, fields);
26
+ }
27
+ async function makeScheduleMirrorBestEffort(open = () => openDb(readDbConfig())) {
28
+ let db;
29
+ try {
30
+ db = await open();
31
+ } catch (e) {
32
+ console.error(`[schedule-mirror] mirroring disabled: no libSQL (${String(e)})`);
33
+ return null;
34
+ }
35
+ return (siteId, fields, computedAt) => mirrorScheduleFields(db, siteId, fields, computedAt);
36
+ }
37
+ export {
38
+ makeHealthMirrorBestEffort,
39
+ makeScheduleMirrorBestEffort
40
+ };
41
+ //# sourceMappingURL=health-mirror-L5JBEJVA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/audits/health-mirror.ts"],"sourcesContent":["import { openDb, readDbConfig, type Db } from \"../db/client.js\";\nimport { mirrorHealthFields, mirrorScheduleFields } from \"../db/fleet-state.js\";\n\n/** One site's just-written Airtable FieldSet, mirrored into site_health.\n * Resolves true when a site_health row matched; false when the UPDATE touched\n * 0 rows (site created in Airtable after the last hourly import) — callers\n * count that as mirror_missed, never as mirrored. */\nexport type HealthMirror = (siteId: string, fields: Record<string, unknown>) => Promise<boolean>;\n\n/** The site_schedule twin, for the nightly next-due write-back. Same\n * matched-row contract as {@link HealthMirror}: false = 0-row UPDATE →\n * mirror_missed. */\nexport type ScheduleMirror = (\n siteId: string,\n fields: Record<string, unknown>,\n computedAt: string,\n) => Promise<boolean>;\n\n/** Build the Turso write-through for the nightly writers (#539 Phase 3\n * dual-write), or null when libSQL creds are absent — mirroring NOT attempted\n * is a different outcome than mirroring failed, and the caller's summary line\n * reports mirror counts only when a mirror existed. Same contract as\n * recordFleetEventsBestEffort: the mirror must never fail the sweep that\n * produced the data (per-site failures are the caller's to count; Airtable\n * stays authoritative and the hourly sync converges whatever the mirror\n * missed). `open` is injectable for tests. */\nexport async function makeHealthMirrorBestEffort(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n): Promise<HealthMirror | null> {\n let db: Db;\n try {\n db = await open();\n } catch (e) {\n console.error(`[health-mirror] mirroring disabled: no libSQL (${String(e)})`);\n return null;\n }\n return (siteId, fields) => mirrorHealthFields(db, siteId, fields);\n}\n\n/** {@link makeHealthMirrorBestEffort}'s schedule twin — same null-without-creds\n * contract, for `writeNextDueDates`' site_schedule write-through. */\nexport async function makeScheduleMirrorBestEffort(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n): Promise<ScheduleMirror | null> {\n let db: Db;\n try {\n db = await open();\n } catch (e) {\n console.error(`[schedule-mirror] mirroring disabled: no libSQL (${String(e)})`);\n return null;\n }\n return (siteId, fields, computedAt) => mirrorScheduleFields(db, siteId, fields, computedAt);\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA0BA,eAAsB,2BACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GACvB;AAC9B,MAAI;AACJ,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,YAAQ,MAAM,kDAAkD,OAAO,CAAC,CAAC,GAAG;AAC5E,WAAO;AAAA,EACT;AACA,SAAO,CAAC,QAAQ,WAAW,mBAAmB,IAAI,QAAQ,MAAM;AAClE;AAIA,eAAsB,6BACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GACrB;AAChC,MAAI;AACJ,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,YAAQ,MAAM,oDAAoD,OAAO,CAAC,CAAC,GAAG;AAC9E,WAAO;AAAA,EACT;AACA,SAAO,CAAC,QAAQ,QAAQ,eAAe,qBAAqB,IAAI,QAAQ,QAAQ,UAAU;AAC5F;","names":[]}
@@ -12,10 +12,11 @@ import {
12
12
  reapAllowance,
13
13
  reapRefusal,
14
14
  renderedHtmlUrl,
15
- scheduleColumnFor
16
- } from "./chunk-ITIOFN7A.js";
15
+ scheduleColumnFor,
16
+ siteValueFor
17
+ } from "./chunk-P6AORG32.js";
17
18
  import "./chunk-VAD43MOR.js";
18
- import "./chunk-LDWZSLSU.js";
19
+ import "./chunk-7NYN2P6I.js";
19
20
  export {
20
21
  EXCLUDED_WEBSITE_FIELDS,
21
22
  HEALTH_BOOLEAN,
@@ -30,6 +31,7 @@ export {
30
31
  reapAllowance,
31
32
  reapRefusal,
32
33
  renderedHtmlUrl,
33
- scheduleColumnFor
34
+ scheduleColumnFor,
35
+ siteValueFor
34
36
  };
35
- //# sourceMappingURL=import-airtable-HE43FB4H.js.map
37
+ //# sourceMappingURL=import-airtable-OHVY344G.js.map
package/dist/index.d.ts CHANGED
@@ -952,6 +952,135 @@ type ReportRow = {
952
952
  overrideBy: string | null;
953
953
  overrideAt: string | null;
954
954
  };
955
+ /** Called with the record Airtable echoed back from the create, before it is
956
+ * mapped (#539 Phase 5). Structurally typed rather than importing the db
957
+ * layer's `RawRecord`, so this module stays the leaf it has always been. */
958
+ type CreatedDraftMirror = (rec: {
959
+ id: string;
960
+ fields: Record<string, unknown>;
961
+ }) => Promise<void>;
962
+
963
+ /** Report rows (migration 0007). PK = Airtable rec id. `checklist` is JSON
964
+ * keyed by the STABLE checklist key (src/reports/checklist.ts), not the
965
+ * Airtable column name; `rendered_html` is the downloaded attachment body. */
966
+ interface ReportsTable {
967
+ id: string;
968
+ site_id: string | null;
969
+ report_id: string | null;
970
+ report_type: string | null;
971
+ period: string | null;
972
+ period_start: string | null;
973
+ period_end: string | null;
974
+ completed_on: string | null;
975
+ lighthouse_performance: number | null;
976
+ lighthouse_accessibility: number | null;
977
+ lighthouse_best_practices: number | null;
978
+ lighthouse_seo: number | null;
979
+ ga_users_current: number | null;
980
+ ga_users_previous: number | null;
981
+ search_found_page1: number | null;
982
+ search_position: number | null;
983
+ last_tested_date: string | null;
984
+ commentary: string | null;
985
+ subject_override: string | null;
986
+ draft_ready: number;
987
+ approved_to_send: number;
988
+ approved_at: string | null;
989
+ approved_by: string | null;
990
+ send_override: number;
991
+ override_reason: string | null;
992
+ override_by: string | null;
993
+ override_at: string | null;
994
+ sent_at: string | null;
995
+ delivery_status: string | null;
996
+ resend_message_id: string | null;
997
+ checklist: string | null;
998
+ checklist_auto_evidence: string | null;
999
+ rendered_html: string | null;
1000
+ }
1001
+
1002
+ /** Columns a report writer mirrors after its Airtable write (same pattern as
1003
+ * mirrorSiteField): the approve/override flow, the resend-webhook's delivery
1004
+ * status, and — since #539 Phase 5 — the drafting path's queue flag and a
1005
+ * re-run's refreshed scores.
1006
+ *
1007
+ * `rendered_html` stays deliberately OUT even though the drafting path now
1008
+ * writes it: bodies go through `storeRenderedHtml`, so a request handler
1009
+ * holding a patch cannot accidentally write megabytes of HTML. `sent_at` and
1010
+ * `resend_message_id` stay out because nothing but the send batch writes them
1011
+ * and the hourly sync converges those. */
1012
+ type ReportMirrorPatch = Partial<Pick<ReportsTable, "approved_to_send" | "approved_at" | "approved_by" | "send_override" | "override_reason" | "override_by" | "override_at" | "delivery_status" | "commentary" | "draft_ready" | "lighthouse_performance" | "lighthouse_accessibility" | "lighthouse_best_practices" | "lighthouse_seo" | "ga_users_current" | "ga_users_previous" | "search_found_page1" | "search_position" | "completed_on">>;
1013
+
1014
+ /** #539 Phase 5: the Turso write-through for the Reports write surface.
1015
+ *
1016
+ * Before this, the only report mirrors were UPDATEs on the request path
1017
+ * (approve, override, delivery status, commentary). Everything the DRAFTING
1018
+ * path writes — the row itself, the rendered body, the queue flag, a re-run's
1019
+ * refreshed scores — reached Turso only via the hourly sync. Two of those are
1020
+ * visible to the operator today: a fresh draft's row does not exist, and its
1021
+ * preview route answers "No rendered body stored" for up to an hour. At the
1022
+ * freeze they stop being windows.
1023
+ *
1024
+ * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.
1025
+ * #585 is the reason: Phase 3's next-due mirror silently no-opped in production
1026
+ * for weeks because the factory returned null without creds, and a dead
1027
+ * dual-write then looked exactly like a healthy one — the only tell was an
1028
+ * ABSENT log suffix nobody was watching for. Here creds-absent is a state the
1029
+ * mirror REPORTS, so every write emits one REPORT_MIRROR line and an absent
1030
+ * line means the wiring is gone, not that conditions were quiet.
1031
+ */
1032
+
1033
+ /** The three shapes a report write takes. Injected as ONE object rather than
1034
+ * three parameters because they share a db handle and always travel together:
1035
+ * a caller holding `created` but not `body` produces exactly the half-mirrored
1036
+ * state (row present, preview 404) this module exists to prevent. */
1037
+ type ReportMirror = {
1038
+ /** A row Airtable just CREATED, as Airtable echoed it back. */
1039
+ created: CreatedDraftMirror;
1040
+ /** A freshly rendered body for an existing row. */
1041
+ body: (reportId: string, html: string) => Promise<void>;
1042
+ /** Columns just written to an existing row. */
1043
+ patch: (reportId: string, patch: ReportMirrorPatch) => Promise<void>;
1044
+ };
1045
+
1046
+ /** #539 Phase 5: the Turso write-through for the ONE-OFF Websites writers.
1047
+ *
1048
+ * Phase 3 mirrored the nightly sweep — the audit write-back, github-signals and
1049
+ * the next-due dates. It did not touch the writers that run on their own
1050
+ * schedule or on demand: the analytics soft-fail stamp, the Renovate
1051
+ * auto-fix counter, the prismic-models verdict, `updateLaunched`, the
1052
+ * forms notify target, and the SINGLE-SITE audit write-back (only the fleet
1053
+ * path ever passed a mirror). Each of those reached Turso solely via the hourly
1054
+ * sync, which stops existing at the freeze.
1055
+ *
1056
+ * It lives in `src/db` rather than a feature folder because its callers span
1057
+ * audits, recipes, CLI commands and the send path — no one feature owns it.
1058
+ *
1059
+ * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.
1060
+ * #585 is the reason: that factory returned null without creds and the
1061
+ * dual-write silently no-opped in production for weeks, because a dead mirror
1062
+ * and a healthy one produced identical output — the only tell was an ABSENT log
1063
+ * suffix nobody was watching for. Here creds-absent is a state the mirror
1064
+ * REPORTS, so a missing SITE_MIRROR line means the wiring is gone.
1065
+ */
1066
+
1067
+ /** Two ops because the Websites row is split across two Turso tables. Callers
1068
+ * pass the EXACT FieldSet the Airtable writer returned, so the mirror can never
1069
+ * carry a different payload than the write it shadows. */
1070
+ type SiteMirror = {
1071
+ /** A row Airtable just CREATED, as Airtable echoed it back (`ensure-site`).
1072
+ * Every other op is an UPDATE, which does nothing for a row that does not
1073
+ * exist yet — so without this a bootstrapped site is invisible to Turso and
1074
+ * every mirror the rest of the bootstrap fires reports `mirrored=missed`. */
1075
+ created: (rec: {
1076
+ id: string;
1077
+ fields: Record<string, unknown>;
1078
+ }) => Promise<void>;
1079
+ /** Columns that live in `site_health`. */
1080
+ health: (siteId: string, fields: Record<string, unknown>) => Promise<void>;
1081
+ /** Columns that live in `sites`. */
1082
+ site: (siteId: string, fields: Record<string, unknown>) => Promise<void>;
1083
+ };
955
1084
 
956
1085
  type ShootOptions = {
957
1086
  url: string;
@@ -1028,6 +1157,22 @@ type DraftOptions = {
1028
1157
  * entirely. Tests set `false` (or a stub) so a unit suite never launches a
1029
1158
  * browser or resolves DNS. Production leaves it unset and gets the real thing. */
1030
1159
  refreshHeader?: RefreshHeaderDeps | false;
1160
+ /** #539 Phase 5: Turso write-through for everything this function writes to
1161
+ * Airtable — the created row, the rendered body, and the queue flag — so a
1162
+ * fresh draft is fully readable in the Turso-backed console immediately
1163
+ * instead of after the next hourly sync.
1164
+ *
1165
+ * Deliberately NOT defaulted here. Defaulting would open a real libSQL handle
1166
+ * from inside `draftReportForSite`, which every unit test calls — and on a
1167
+ * machine with TURSO_* exported that means a test suite writing rows into
1168
+ * production. The wiring lives at the composition roots (cli/commands/report.ts,
1169
+ * recipes) where it is pinned by test, the same division `runFleetWriteBack`
1170
+ * uses for the health mirror. */
1171
+ reportMirror?: ReportMirror;
1172
+ /** #539 Phase 5: the Websites-row twin of `reportMirror`. Drafting stamps
1173
+ * `Analytics soft-fail at` on the SITE row, which is a different Turso table
1174
+ * and a different mirror. Injected for the same reason — see reportMirror. */
1175
+ siteMirror?: SiteMirror;
1031
1176
  };
1032
1177
  /** An enrichment fetch that *errored* (not one that was legitimately skipped
1033
1178
  * because it isn't configured / the site lacks the inputs). Surfaced so a
@@ -1106,6 +1251,10 @@ type ResendClient = {
1106
1251
 
1107
1252
  type OrchestrateOptions = {
1108
1253
  resend?: ResendClient;
1254
+ /** #539 Phase 5: Turso write-through for the Websites row a Launch send
1255
+ * updates. Injected rather than defaulted — this function is called directly
1256
+ * by tests, and a default would open a real libSQL handle inside the suite. */
1257
+ siteMirror?: SiteMirror;
1109
1258
  };
1110
1259
  declare function sendApprovedReports(options?: OrchestrateOptions): Promise<{
1111
1260
  output: string;