@rebasepro/server 0.23.0 → 0.24.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 (181) hide show
  1. package/README.md +1 -1
  2. package/bin/rebase-server.js +4 -2
  3. package/dist/{GCSStorageController-CjrA4PMo.js → GCSStorageController-BSiP1c-f.js} +22 -8
  4. package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
  5. package/dist/{S3StorageController-B6pKDNVj.js → S3StorageController-CAwFRgjV.js} +19 -7
  6. package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
  7. package/dist/api/ast-schema-editor.d.ts +92 -1
  8. package/dist/api/errors.d.ts +9 -0
  9. package/dist/api/live-schema-routes.d.ts +38 -8
  10. package/dist/api/logs-routes.d.ts +39 -1
  11. package/dist/api/openapi-generator.d.ts +17 -0
  12. package/dist/api/rest/api-generator.d.ts +44 -10
  13. package/dist/api/rest/write-validation.d.ts +2 -2
  14. package/dist/api/types.d.ts +17 -1
  15. package/dist/{ast-schema-editor-Mvr50v_S.js → ast-schema-editor-CWqS_sLJ.js} +309 -11
  16. package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
  17. package/dist/auth/access.d.ts +105 -0
  18. package/dist/auth/adapter-middleware.d.ts +2 -1
  19. package/dist/auth/address-ownership.d.ts +16 -1
  20. package/dist/auth/admin-roles-route.d.ts +4 -2
  21. package/dist/auth/admin-roles.d.ts +17 -20
  22. package/dist/auth/admin-users-route.d.ts +1 -0
  23. package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
  24. package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
  25. package/dist/auth/api-keys/api-key-store.d.ts +31 -8
  26. package/dist/auth/api-keys/api-key-types.d.ts +14 -16
  27. package/dist/auth/api-keys/http-operation.d.ts +19 -0
  28. package/dist/auth/api-keys/index.d.ts +11 -11
  29. package/dist/auth/api-keys/key-grant.d.ts +41 -0
  30. package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
  31. package/dist/auth/auth-hooks.d.ts +46 -7
  32. package/dist/auth/builtin-auth-adapter.d.ts +8 -0
  33. package/dist/auth/cookie-utils.d.ts +7 -0
  34. package/dist/auth/deliverable-address.d.ts +6 -0
  35. package/dist/auth/email-change-routes.d.ts +41 -0
  36. package/dist/auth/expired-token-sweep.d.ts +67 -0
  37. package/dist/auth/impersonation.d.ts +110 -0
  38. package/dist/auth/index.d.ts +4 -2
  39. package/dist/auth/interfaces.d.ts +110 -59
  40. package/dist/auth/jwt.d.ts +49 -3
  41. package/dist/auth/magic-link-routes.d.ts +2 -6
  42. package/dist/auth/mfa-routes.d.ts +2 -9
  43. package/dist/auth/middleware.d.ts +17 -5
  44. package/dist/auth/otp-routes.d.ts +2 -6
  45. package/dist/auth/passwordless-signup.d.ts +27 -0
  46. package/dist/auth/platform-token.d.ts +122 -0
  47. package/dist/auth/rate-limiter.d.ts +41 -0
  48. package/dist/auth/routes.d.ts +45 -0
  49. package/dist/auth/scope-routes.d.ts +22 -0
  50. package/dist/auth/session-routes.d.ts +11 -6
  51. package/dist/auth/token-revocation.d.ts +50 -1
  52. package/dist/auth/verify-credential.d.ts +28 -0
  53. package/dist/{auth-B-GIMpDG.js → auth-DMLngxn_.js} +2159 -569
  54. package/dist/auth-DMLngxn_.js.map +1 -0
  55. package/dist/backend-DTAOsLQc.js.map +1 -1
  56. package/dist/backup/backup-common.d.ts +10 -0
  57. package/dist/backup/backup-routes.d.ts +24 -4
  58. package/dist/backup/backup-schedule.d.ts +33 -0
  59. package/dist/backup/backup-storage.d.ts +14 -0
  60. package/dist/backup/index.d.ts +2 -0
  61. package/dist/backup-CN0s50D2.js +444 -0
  62. package/dist/backup-CN0s50D2.js.map +1 -0
  63. package/dist/boot/bundle.d.ts +19 -0
  64. package/dist/boot/env.d.ts +49 -4
  65. package/dist/boot/security-headers.d.ts +26 -0
  66. package/dist/boot/static-routing.d.ts +56 -0
  67. package/dist/collection_patch-BRu-BvDv.js +472 -0
  68. package/dist/collection_patch-BRu-BvDv.js.map +1 -0
  69. package/dist/{contract-routes-CbFjuBwa.js → contract-routes-fz8i4pxs.js} +17 -4
  70. package/dist/contract-routes-fz8i4pxs.js.map +1 -0
  71. package/dist/cron/cron-scheduler.d.ts +25 -20
  72. package/dist/cron/cron-store.d.ts +6 -2
  73. package/dist/{cron-loader-DfTj2Hbi.js → cron-loader-CwaANlOG.js} +4 -4
  74. package/dist/cron-loader-CwaANlOG.js.map +1 -0
  75. package/dist/{cron-routes-eE8nif_b.js → cron-routes-Bc-SB0Se.js} +10 -7
  76. package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
  77. package/dist/{cron-scheduler-B0pLfAix.js → cron-scheduler-CYQgco86.js} +52 -34
  78. package/dist/cron-scheduler-CYQgco86.js.map +1 -0
  79. package/dist/{cron-store-TcoGz-xS.js → cron-store-D2Q9-Aco.js} +10 -15
  80. package/dist/cron-store-D2Q9-Aco.js.map +1 -0
  81. package/dist/{ddl-bootstrap-C6mo0Kmz.js → ddl-bootstrap-BaqMSa4Y.js} +2 -2
  82. package/dist/{ddl-bootstrap-C6mo0Kmz.js.map → ddl-bootstrap-BaqMSa4Y.js.map} +1 -1
  83. package/dist/email/index.d.ts +2 -2
  84. package/dist/email/templates.d.ts +22 -0
  85. package/dist/email/types.d.ts +26 -0
  86. package/dist/env.d.ts +1 -2
  87. package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
  88. package/dist/errors-D6_y86c5.js.map +1 -0
  89. package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
  90. package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
  91. package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
  92. package/dist/function-routes-CaNG4waN.js.map +1 -0
  93. package/dist/functions/context.d.ts +17 -6
  94. package/dist/functions/guards.d.ts +22 -5
  95. package/dist/functions/index.d.ts +2 -2
  96. package/dist/functions/index.js +90 -36
  97. package/dist/functions/index.js.map +1 -1
  98. package/dist/{history-recorder-B4MpJfJK.js → history-recorder-Nr8zLvoU.js} +4 -4
  99. package/dist/{history-recorder-B4MpJfJK.js.map → history-recorder-Nr8zLvoU.js.map} +1 -1
  100. package/dist/{history-store-BhxWOuz9.js → history-store-rcAm_xFR.js} +2 -2
  101. package/dist/{history-store-BhxWOuz9.js.map → history-store-rcAm_xFR.js.map} +1 -1
  102. package/dist/index.d.ts +8 -2
  103. package/dist/index.es.js +3084 -753
  104. package/dist/index.es.js.map +1 -1
  105. package/dist/init/health.d.ts +17 -2
  106. package/dist/init/shutdown.d.ts +10 -0
  107. package/dist/init.d.ts +54 -0
  108. package/dist/{jobs-CazMYhyy.js → jobs-DqYNfquG.js} +5 -5
  109. package/dist/{jobs-CazMYhyy.js.map → jobs-DqYNfquG.js.map} +1 -1
  110. package/dist/{jwt-DnQHNFCl.js → jwt-R6bSPMjk.js} +39 -15
  111. package/dist/{jwt-DnQHNFCl.js.map → jwt-R6bSPMjk.js.map} +1 -1
  112. package/dist/{keys-CogCQpxG.js → keys-GAVZqbqx.js} +3 -17
  113. package/dist/{keys-CogCQpxG.js.map → keys-GAVZqbqx.js.map} +1 -1
  114. package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
  115. package/dist/logger-D-S-hO5e.js.map +1 -0
  116. package/dist/{logs-routes-Bj4TYYUl.js → logs-routes-DAdv37GI.js} +48 -8
  117. package/dist/logs-routes-DAdv37GI.js.map +1 -0
  118. package/dist/mcp/consent-page.d.ts +1 -1
  119. package/dist/mcp/mcp-routes.d.ts +7 -0
  120. package/dist/mcp/mcp-tools.d.ts +15 -9
  121. package/dist/mcp/oauth-metadata.d.ts +21 -16
  122. package/dist/mcp/oauth-routes.d.ts +7 -1
  123. package/dist/{openapi-generator-O_O24MAT.js → openapi-generator-DAq_XVDu.js} +104 -13
  124. package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
  125. package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
  126. package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
  127. package/dist/{query-parser-DGRVFNM3.js → query-parser-BgiKJKvc.js} +6 -56
  128. package/dist/query-parser-BgiKJKvc.js.map +1 -0
  129. package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
  130. package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
  131. package/dist/schema-edit/apply-schema-change.d.ts +63 -3
  132. package/dist/schema-edit/project-root.d.ts +3 -2
  133. package/dist/schema-edit/remote-source.d.ts +9 -4
  134. package/dist/{schema-editor-routes-C5-lh_jO.js → schema-editor-routes-oIyuWl3L.js} +12 -7
  135. package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
  136. package/dist/serve-spa.d.ts +58 -0
  137. package/dist/services/routed-realtime-service.d.ts +11 -0
  138. package/dist/soft-delete-params-BWPilMPF.js +59 -0
  139. package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
  140. package/dist/{src-vkcwKXbT.js → src-CatHFUym.js} +439 -20
  141. package/dist/src-CatHFUym.js.map +1 -0
  142. package/dist/{src-pmvW7BFx.js → src-I3aG1PcY.js} +252 -70
  143. package/dist/src-I3aG1PcY.js.map +1 -0
  144. package/dist/storage/GCSStorageController.d.ts +2 -0
  145. package/dist/storage/LocalStorageController.d.ts +2 -0
  146. package/dist/storage/S3StorageController.d.ts +2 -0
  147. package/dist/storage/index.d.ts +2 -2
  148. package/dist/storage/property-limits.d.ts +41 -6
  149. package/dist/storage/request-keys.d.ts +15 -0
  150. package/dist/storage/requested-object.d.ts +74 -0
  151. package/dist/storage/routes.d.ts +36 -18
  152. package/dist/storage/tus-handler.d.ts +30 -5
  153. package/dist/storage/types.d.ts +19 -0
  154. package/dist/types-BfKcm9do.js.map +1 -1
  155. package/dist/utils/logger.d.ts +12 -0
  156. package/package.json +5 -5
  157. package/dist/GCSStorageController-CjrA4PMo.js.map +0 -1
  158. package/dist/S3StorageController-B6pKDNVj.js.map +0 -1
  159. package/dist/admin-roles-vYdp_Pil.js +0 -36
  160. package/dist/admin-roles-vYdp_Pil.js.map +0 -1
  161. package/dist/admin_block-DxKLmdiv.js +0 -206
  162. package/dist/admin_block-DxKLmdiv.js.map +0 -1
  163. package/dist/ast-schema-editor-Mvr50v_S.js.map +0 -1
  164. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
  165. package/dist/auth-B-GIMpDG.js.map +0 -1
  166. package/dist/backup-D7YR94N3.js +0 -253
  167. package/dist/backup-D7YR94N3.js.map +0 -1
  168. package/dist/contract-routes-CbFjuBwa.js.map +0 -1
  169. package/dist/cron-loader-DfTj2Hbi.js.map +0 -1
  170. package/dist/cron-routes-eE8nif_b.js.map +0 -1
  171. package/dist/cron-scheduler-B0pLfAix.js.map +0 -1
  172. package/dist/cron-store-TcoGz-xS.js.map +0 -1
  173. package/dist/errors-DWsX4yTd.js.map +0 -1
  174. package/dist/function-routes-Chet4-lB.js.map +0 -1
  175. package/dist/logger-DO2PZc4i.js.map +0 -1
  176. package/dist/logs-routes-Bj4TYYUl.js.map +0 -1
  177. package/dist/openapi-generator-O_O24MAT.js.map +0 -1
  178. package/dist/query-parser-DGRVFNM3.js.map +0 -1
  179. package/dist/schema-editor-routes-C5-lh_jO.js.map +0 -1
  180. package/dist/src-pmvW7BFx.js.map +0 -1
  181. package/dist/src-vkcwKXbT.js.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"file":"cron-scheduler-B0pLfAix.js","names":[],"sources":["../../types/src/types/env_boolean.ts","../src/cron/scale-to-zero.ts","../src/cron/cron-scheduler.ts"],"sourcesContent":["const YES = new Set([\"true\", \"1\", \"yes\", \"on\"]);\nconst NO = new Set([\"false\", \"0\", \"no\", \"off\"]);\n\n/**\n * Read a boolean environment variable — one spelling rule for every reader.\n *\n * `true`, `1`, `yes` and `on` are true; `false`, `0`, `no` and `off` are\n * false. Case and surrounding whitespace are ignored. Anything else — unset,\n * blank, or a value that spells neither — is `undefined`, so the caller's\n * default decides and a typo lands on the side the caller chose:\n *\n * ```ts\n * parseEnvBoolean(env.FORCE_LOCAL_STORAGE) === true // off unless said\n * parseEnvBoolean(env.REBASE_MCP_OPEN_REGISTRATION) !== false // on unless said\n * ```\n *\n * It exists because every reader used to spell this itself, and they\n * disagreed. The reader that mattered tested the raw string for truthiness —\n * `!process.env.FORCE_LOCAL_STORAGE` — and every non-empty string is truthy,\n * so `FORCE_LOCAL_STORAGE=false`, written to say \"there is no durable volume\n * here\", switched the production storage guard off and sent uploads to a\n * container filesystem the next redeploy erased. Beside it, `=== \"true\"` made\n * `DISABLE_DB_ROLE_SWITCHING=1` do nothing and `!== \"false\"` made\n * `REBASE_MCP_OPEN_REGISTRATION=0` leave registration open.\n *\n * The boot schemas in `@rebasepro/server` (`loadEnv`, `loadBootEnv`) accept a\n * strict subset — `true`, `false` and blank — and refuse any other value\n * before the server starts. On that subset they read exactly what this reads,\n * so a variable a schema declares and something else reads lazily cannot mean\n * two different things.\n *\n * It lives in this package, with no dependencies, for the reason\n * {@link storageEnvSuffix} does: the runtime, its drivers and the CLI all read\n * these variables, and a second parser is a second chance to disagree.\n *\n * @group Models\n */\nexport function parseEnvBoolean(value: string | undefined): boolean | undefined {\n if (value === undefined) return undefined;\n const spelled = value.trim().toLowerCase();\n if (YES.has(spelled)) return true;\n if (NO.has(spelled)) return false;\n return undefined;\n}\n","/**\n * Scale-to-zero detection for the cron scheduler.\n *\n * The scheduler drives jobs with in-process `setTimeout`. That works on any\n * always-running instance, but on a platform that freezes or evicts the\n * container between requests (Cloud Run with `--min-instances=0`, AWS Lambda,\n * Vercel functions) the timers simply never fire — the process boots, logs the\n * jobs as registered, and silently runs nothing.\n *\n * None of these platforms expose their scaling floor to the container, so this\n * detection is a heuristic: it identifies the *platform*, not the setting. It\n * is a warning only — it must never influence boot.\n *\n * Environment variables used here were verified against vendor documentation:\n * - `K_SERVICE` / `K_REVISION` / `K_CONFIGURATION` — Cloud Run services\n * (Cloud Run container contract; no variable exposes min-instances).\n * - `CLOUD_RUN_JOB` — Cloud Run jobs (same contract).\n * - `AWS_LAMBDA_FUNCTION_NAME` — reserved AWS Lambda runtime variable.\n * - `VERCEL=1` — Vercel system environment variable, available at runtime.\n * - `KUBERNETES_SERVICE_HOST` — injected into every pod by the kubelet. Used\n * as an *exclusion*: a Deployment pod runs continuously, and Knative on\n * Kubernetes also sets `K_SERVICE`, so a pod is never warned about.\n */\n\nimport { parseEnvBoolean } from \"@rebasepro/types\";\n\n/** Environment variable that permanently silences the scale-to-zero warning. */\nexport const CRON_ALWAYS_ON_ENV = \"REBASE_CRON_ALWAYS_ON\";\n\n/** The subset of `process.env` this module reads. */\nexport type EnvLike = Record<string, string | undefined>;\n\nexport interface FreezableRuntime {\n /** Human-readable platform name, used verbatim in the warning. */\n platform: string;\n /** Names of the environment variables that identified the platform. */\n signals: string[];\n}\n\n/** Minimal shape of a registered job needed to build the warning. */\nexport interface WarnableJob {\n id: string;\n enabled: boolean;\n}\n\nexport interface ScaleToZeroWarning {\n message: string;\n data: Record<string, unknown>;\n}\n\n/**\n * Identify a runtime whose instances can be frozen or torn down between\n * requests. Returns `undefined` when the platform is unknown or known to run\n * continuously.\n */\nexport function detectFreezableRuntime(env: EnvLike = process.env): FreezableRuntime | undefined {\n // A Kubernetes pod (GKE, EKS, self-hosted) runs continuously. Knative and\n // Cloud Run for Anthos set K_SERVICE *inside* a pod, so this exclusion has\n // to come first or every Knative pod would be a false positive.\n if (env.KUBERNETES_SERVICE_HOST) return undefined;\n\n if (env.K_SERVICE) {\n const signals = [\"K_SERVICE\"];\n if (env.K_REVISION) signals.push(\"K_REVISION\");\n if (env.K_CONFIGURATION) signals.push(\"K_CONFIGURATION\");\n return { platform: \"Cloud Run\", signals };\n }\n\n if (env.CLOUD_RUN_JOB) {\n return { platform: \"Cloud Run Jobs\", signals: [\"CLOUD_RUN_JOB\"] };\n }\n\n if (env.AWS_LAMBDA_FUNCTION_NAME) {\n return { platform: \"AWS Lambda\", signals: [\"AWS_LAMBDA_FUNCTION_NAME\"] };\n }\n\n if (parseEnvBoolean(env.VERCEL) === true) {\n return { platform: \"Vercel\", signals: [\"VERCEL\"] };\n }\n\n return undefined;\n}\n\n/** How many job ids to name before collapsing the rest into \"+N more\". */\nconst MAX_NAMED_JOBS = 10;\n\n/**\n * Build the boot-time warning, or `undefined` when it does not apply.\n *\n * Fires only when all of the following hold:\n * 1. `NODE_ENV=production` — a laptop or CI run is not at risk.\n * 2. At least one *enabled* job is registered — nothing to lose otherwise.\n * 3. The environment looks like a freezable platform (see above).\n * 4. `REBASE_CRON_ALWAYS_ON` is not set to a truthy value.\n */\nexport function buildScaleToZeroWarning(\n jobs: WarnableJob[],\n env: EnvLike = process.env\n): ScaleToZeroWarning | undefined {\n if (env.NODE_ENV !== \"production\") return undefined;\n if (parseEnvBoolean(env[CRON_ALWAYS_ON_ENV]) === true) return undefined;\n\n const enabled = jobs.filter((job) => job.enabled).map((job) => job.id);\n if (enabled.length === 0) return undefined;\n\n const runtime = detectFreezableRuntime(env);\n if (!runtime) return undefined;\n\n const named = enabled.slice(0, MAX_NAMED_JOBS);\n const list = enabled.length > named.length\n ? `${named.join(\", \")} (+${enabled.length - named.length} more)`\n : named.join(\", \");\n\n const message = `[cron] ${runtime.platform} detected — in-process timers do not fire while an instance is frozen or scaled to zero, so ${enabled.length} enabled job(s) may never run: ${list}; drive them from an external scheduler instead (POST /api/cron/:id/trigger, e.g. Cloud Scheduler). ${runtime.platform} does not expose its scaling floor to the container, so an always-warm deployment cannot be confirmed from inside the process — set ${CRON_ALWAYS_ON_ENV}=1 to silence this if at least one instance is pinned warm.`;\n\n return {\n message,\n data: {\n platform: runtime.platform,\n signals: runtime.signals,\n jobs: enabled\n }\n };\n}\n","import type {\n CronJobDefinition,\n CronJobStatus,\n CronJobLogEntry,\n CronJobRunState,\n CronJobContext\n} from \"@rebasepro/types\";\nimport type { RebaseServerClient } from \"@rebasepro/types\";\nimport { hostname } from \"node:os\";\nimport { randomUUID } from \"node:crypto\";\nimport type { LoadedCronJob } from \"./cron-loader\";\nimport type { CronJobRunSummary, CronStore } from \"./cron-store\";\nimport { logger, redactSensitiveText } from \"../utils/logger.js\";\nimport { buildScaleToZeroWarning } from \"./scale-to-zero.js\";\n\n// ─── Cron expression parser (minimal, no external dependency) ────────\n// Supports standard 5-field cron (minute hour dom month dow).\n// Returns the next Date after `after` that matches the expression.\n\n/**\n * Expand a single cron field into an ordered array of allowed values.\n * Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*\\/S`, and comma-separated combinations.\n */\nfunction expandCronField(field: string, min: number, max: number): number[] {\n const results = new Set<number>();\n for (const segment of field.split(\",\")) {\n const trimmed = segment.trim();\n if (trimmed === \"*\") {\n for (let i = min; i <= max; i++) results.add(i);\n } else if (trimmed.includes(\"/\")) {\n const [rangeStr, stepStr] = trimmed.split(\"/\");\n const step = parseInt(stepStr, 10);\n if (isNaN(step) || step <= 0) {\n throw new Error(`Invalid step value \"${stepStr}\" in cron field \"${field}\"`);\n }\n let start = min;\n let end = max;\n if (rangeStr !== \"*\") {\n if (rangeStr.includes(\"-\")) {\n const [a, b] = rangeStr.split(\"-\").map(Number);\n start = a;\n end = b;\n } else {\n start = parseInt(rangeStr, 10);\n }\n }\n for (let i = start; i <= end; i += step) results.add(i);\n } else if (trimmed.includes(\"-\")) {\n const [a, b] = trimmed.split(\"-\").map(Number);\n for (let i = a; i <= b; i++) results.add(i);\n } else {\n const val = parseInt(trimmed, 10);\n if (isNaN(val)) {\n throw new Error(`Invalid value \"${trimmed}\" in cron field \"${field}\"`);\n }\n results.add(val);\n }\n }\n return [...results].sort((a, b) => a - b);\n}\n\n/**\n * Validates a standard 5-field cron expression structurally and semantically.\n * Returns `{ valid: true }` or `{ valid: false, reason: string }`.\n */\nexport function validateCronExpression(schedule: string): { valid: true } | { valid: false; reason: string } {\n if (!schedule || typeof schedule !== \"string\") {\n return { valid: false,\nreason: \"Schedule must be a non-empty string\" };\n }\n const parts = schedule.trim().split(/\\s+/);\n if (parts.length !== 5) {\n return { valid: false,\nreason: `Expected 5 fields, got ${parts.length}` };\n }\n const fieldRanges: [string, number, number][] = [\n [\"minute\", 0, 59],\n [\"hour\", 0, 23],\n [\"day of month\", 1, 31],\n [\"month\", 1, 12],\n [\"day of week\", 0, 6]\n ];\n for (let i = 0; i < 5; i++) {\n const [name, min, max] = fieldRanges[i];\n try {\n const values = expandCronField(parts[i], min, max);\n if (values.length === 0) {\n return { valid: false,\nreason: `${name} field \"${parts[i]}\" produces no values` };\n }\n for (const v of values) {\n if (v < min || v > max) {\n return { valid: false,\nreason: `${name} field value ${v} out of range [${min}–${max}]` };\n }\n }\n } catch (err) {\n return { valid: false,\nreason: `${name} field: ${err instanceof Error ? err.message : String(err)}` };\n }\n }\n return { valid: true };\n}\n\n/** The five cron fields, pre-expanded into the values each one allows. */\ninterface CronFields {\n minutes: number[];\n hours: number[];\n doms: number[];\n months: number[];\n dows: number[];\n}\n\n/** Expand all five fields of an expression. Throws on invalid expressions. */\nfunction parseCronFields(expression: string): CronFields {\n const parts = expression.trim().split(/\\s+/);\n if (parts.length < 5) {\n throw new Error(`Invalid cron expression: \"${expression}\". Expected 5 fields.`);\n }\n const [minField, hourField, domField, monField, dowField] = parts;\n return {\n minutes: expandCronField(minField, 0, 59),\n hours: expandCronField(hourField, 0, 23),\n doms: expandCronField(domField, 1, 31),\n months: expandCronField(monField, 1, 12),\n dows: expandCronField(dowField, 0, 6) // 0=Sunday\n };\n}\n\n/**\n * Whether an IANA zone name is one this runtime can read a schedule in.\n *\n * `Intl` is the authority: it throws a RangeError on a name it does not know,\n * which is the only check that tracks the tz database the process actually\n * ships. A misspelled zone must fail when the job loads, not silently read\n * the schedule as local time.\n */\nexport function isValidTimeZone(zone: string): boolean {\n try {\n new Intl.DateTimeFormat(\"en-US\", { timeZone: zone });\n return true;\n } catch {\n return false;\n }\n}\n\n/** Wall-clock parts of an instant, in a zone, as the cron fields see them. */\ninterface WallClock {\n minute: number;\n hour: number;\n dom: number;\n month: number;\n dow: number;\n}\n\nconst formatters = new Map<string, Intl.DateTimeFormat>();\nconst DOW: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };\n\nfunction wallClockIn(candidate: Date, zone: string): WallClock {\n let formatter = formatters.get(zone);\n if (!formatter) {\n formatter = new Intl.DateTimeFormat(\"en-US\", {\n timeZone: zone,\n hourCycle: \"h23\",\n weekday: \"short\",\n month: \"numeric\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"numeric\"\n });\n formatters.set(zone, formatter);\n }\n const parts: Record<string, string> = {};\n for (const part of formatter.formatToParts(candidate)) parts[part.type] = part.value;\n return {\n minute: Number(parts.minute),\n hour: Number(parts.hour),\n dom: Number(parts.day),\n month: Number(parts.month),\n dow: DOW[parts.weekday] ?? candidate.getDay()\n };\n}\n\n/**\n * Whether a minute-precision instant matches every field of the expression.\n *\n * Read in `zone` when one is named, else in the process's own zone — which is\n * whatever the host is set to, UTC in nearly every container. A schedule that\n * names its zone means the same wall-clock hour on every host it runs on.\n */\nfunction matchesCronFields(candidate: Date, fields: CronFields, zone?: string): boolean {\n const wall: WallClock = zone\n ? wallClockIn(candidate, zone)\n : {\n minute: candidate.getMinutes(),\n hour: candidate.getHours(),\n dom: candidate.getDate(),\n month: candidate.getMonth() + 1, // getMonth is 0-11\n dow: candidate.getDay()\n };\n return fields.months.includes(wall.month)\n && fields.doms.includes(wall.dom)\n && fields.dows.includes(wall.dow)\n && fields.hours.includes(wall.hour)\n && fields.minutes.includes(wall.minute);\n}\n\n/**\n * How far forward to look for the next matching slot.\n *\n * Four years and a day, not one year. `0 0 29 2 *` — run on 29 February — is a\n * legitimate expression whose slot can be almost four years out, and a one-year\n * search never found it.\n */\nconst MAX_SLOT_SEARCH_MINUTES = 4 * 525960 + 1440;\n\nconst MINUTE_MS = 60_000;\n\n/**\n * Calculate the next Date after `after` that matches the cron expression.\n * Throws on invalid expressions.\n */\nexport function parseCronExpression(expression: string, after: Date, timezone?: string): Date {\n const fields = parseCronFields(expression);\n\n // Forward-search from `after + 1 minute`, stepping real minutes. The local\n // setters step wall-clock minutes, and across a daylight-saving change\n // those are not the same: falling back, `setMinutes(+1)` from 02:59 summer\n // time lands on 03:00 winter time, skipping the repeated hour, and inside\n // it the result can be an hour in the past. Only the matching reads the\n // wall clock.\n const candidate = new Date(after);\n candidate.setUTCSeconds(0, 0);\n candidate.setTime(candidate.getTime() + MINUTE_MS);\n\n for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES; i++) {\n if (matchesCronFields(candidate, fields, timezone)) {\n return candidate;\n }\n candidate.setTime(candidate.getTime() + MINUTE_MS);\n }\n\n // No slot inside the window. Refuse rather than invent one.\n //\n // This used to return `after + 1 minute`, which is indistinguishable from a\n // schedule that really does fire every minute — so an expression with no\n // reachable slot ran sixty times an hour, forever. `0 0 29 2 *` was caught\n // by it while the search window was a single year: a job meant to run once\n // every four years became the busiest job on the deployment.\n //\n // The caller schedules inside a `try` and reports a job it could not\n // schedule, which is the correct outcome for an expression that names no\n // time. Genuinely impossible dates (`0 0 31 2 *`) land here too, and should.\n throw new Error(\n `Cron expression \"${expression}\" has no matching time within ` +\n `${Math.round(MAX_SLOT_SEARCH_MINUTES / 525960)} years of ${after.toISOString()}. ` +\n \"Check the day-of-month and month fields — a date such as 31 February never occurs.\"\n );\n}\n\n/**\n * The latest slot matching `expression` within the inclusive window\n * `[from, to]`, or `undefined` when the expression has no slot in it.\n *\n * Walks backwards a minute at a time from `to`, so the first hit is already\n * the answer — in the common case (a job that ran normally moments ago) that\n * is a handful of iterations, not a scan of the whole window.\n *\n * `to`'s own minute is included: an instance booting at 06:00:30 has *not* run\n * the 06:00 slot — `parseCronExpression` already skipped past it to tomorrow —\n * so that slot is genuinely missed and must be a candidate.\n *\n * Seconds and milliseconds are zeroed to match how `parseCronExpression`\n * builds a slot, so the same wall-clock slot serialises to a byte-identical\n * ISO string down either path. The claim key depends on that.\n */\nexport function findMostRecentSlot(expression: string, from: Date, to: Date, timezone?: string): Date | undefined {\n const fields = parseCronFields(expression);\n\n // Real minutes, as in `parseCronExpression`: a local-time step back from\n // 02:00 winter time lands on 01:59 summer time and walks over the repeated\n // hour.\n const candidate = new Date(to);\n candidate.setUTCSeconds(0, 0);\n\n for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES && candidate.getTime() >= from.getTime(); i++) {\n if (matchesCronFields(candidate, fields, timezone)) {\n return candidate;\n }\n candidate.setTime(candidate.getTime() - MINUTE_MS);\n }\n\n return undefined;\n}\n\n// ─── In-memory ring buffer for logs ──────────────────────────────────\n\nconst MAX_LOGS_PER_JOB = 50;\n\n/**\n * Minimum milliseconds between scheduled executions of the same job.\n * Prevents tight re-execution loops caused by jitter or clock drift.\n */\nconst MIN_SCHEDULE_INTERVAL_MS = 5_000; // 5 seconds\n\n/**\n * Largest delay setTimeout can hold. Node stores it in a 32-bit signed int;\n * anything larger silently clamps to 1ms and fires immediately, so a slot\n * further out than this must be reached in hops rather than one timer.\n */\nconst MAX_TIMER_DELAY_MS = 2_147_483_647; // 2^31 - 1, ~24.8 days\n\n/**\n * `setTimeout` for a delay that may exceed {@link MAX_TIMER_DELAY_MS}: armed in\n * hops no longer than the ceiling, re-measured against the deadline on each\n * wake. Returns the function that cancels it.\n */\nfunction setLongTimeout(callback: () => void, delayMs: number): () => void {\n const deadline = Date.now() + delayMs;\n let handle: ReturnType<typeof setTimeout>;\n const arm = () => {\n handle = setTimeout(() => {\n if (Date.now() < deadline) arm();\n else callback();\n }, Math.min(Math.max(deadline - Date.now(), 0), MAX_TIMER_DELAY_MS));\n };\n arm();\n return () => clearTimeout(handle);\n}\n\n/** A run's timeout when its definition names none. */\nconst DEFAULT_TIMEOUT_SECONDS = 300;\n\n/**\n * How long a run lease outlasts the run's own timeout.\n *\n * The lease is timed on the database's clock from the moment it is taken, the\n * timeout on this process's from a moment later; without a margin a lease\n * could lapse a beat before its run is aborted and released, and another\n * process start the job alongside it.\n */\nconst LEASE_GRACE_SECONDS = 30;\n\n/**\n * The lease a run with no timeout (`timeoutSeconds: Infinity`) holds.\n *\n * A lease is released when its run ends, so this only matters in two cases.\n * The holder crashed: the job is blocked — every trigger answered \"already\n * executing\", every slot skipped — until the lease lapses, so it must lapse\n * within an hour rather than never. The run is still going after an hour:\n * from then on another process may start the job beside it. A job that\n * legitimately runs for hours should say so with a finite `timeoutSeconds`,\n * which its lease then follows.\n */\nconst MAX_UNBOUNDED_LEASE_SECONDS = 3600;\n\n/**\n * Whether a log entry records a run that did not happen because one was\n * already executing — on this process, or on another that holds the job's run\n * lease. The shape is the documented marker of a skip, the one the Studio\n * panel reads.\n */\nexport function isAlreadyExecutingSkip(entry: CronJobLogEntry): boolean {\n const result = entry.result;\n return typeof result === \"object\" && result !== null\n && \"skipped\" in result && result.skipped === true\n && \"reason\" in result && result.reason === \"already_executing\";\n}\n\n/**\n * Why `timeoutSeconds` cannot be used, or `undefined` when it can. Infinity is\n * a timeout that never fires; zero, a negative number or NaN would fail every\n * run the moment it started.\n */\nfunction invalidTimeoutReason(timeoutSeconds: number | undefined): string | undefined {\n if (timeoutSeconds === undefined || timeoutSeconds === Infinity) return undefined;\n if (typeof timeoutSeconds === \"number\" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) return undefined;\n return `timeoutSeconds must be a positive number of seconds, or Infinity for none — got ${String(timeoutSeconds)}`;\n}\n\n// ─── CronScheduler ───────────────────────────────────────────────────\n\ninterface RegisteredJob {\n id: string;\n definition: CronJobDefinition;\n enabled: boolean;\n state: CronJobRunState;\n lastRunAt?: Date;\n nextRunAt?: Date;\n lastDurationMs?: number;\n lastError?: string;\n totalRuns: number;\n totalFailures: number;\n timerId?: ReturnType<typeof setTimeout>;\n logs: CronJobLogEntry[];\n /** True while a handler is actively executing (prevents concurrent runs). */\n executing: boolean;\n}\n\n/**\n * What this process learned from the store about a job, for a listing: another\n * process running it, and — on a process whose scheduler is not started — its\n * run history and the slot the scheduler will fire next.\n */\ninterface FleetView {\n runningElsewhere?: boolean;\n summary?: CronJobRunSummary;\n nextRunAt?: Date;\n}\n\n/**\n * Whether a run may go ahead. `token` is the lease to release when it ends —\n * absent when no lease was taken, because the store has none or could not\n * answer.\n */\ntype LeaseOutcome =\n | { acquired: true; token?: string }\n | { acquired: false; holder?: string };\n\n/**\n * A job the scheduler refused, and why.\n *\n * It is not a `CronJobStatus`: it has no state, no next run and no counters,\n * because it was never registered. Reporting it as a job with `state: \"error\"`\n * would be a lie in the other direction — nothing is going to run it.\n */\nexport interface RejectedCronJob {\n id: string;\n name: string;\n schedule: string;\n reason: string;\n}\n\nexport class CronScheduler {\n private jobs = new Map<string, RegisteredJob>();\n private rejected = new Map<string, RejectedCronJob>();\n private started = false;\n private store?: CronStore;\n private client?: RebaseServerClient;\n /**\n * Work `stop()` has to wait for: every run executing right now, and every\n * run's log write still on its way to the store.\n */\n private inFlight = new Set<Promise<unknown>>();\n /** The controller that ends each executing run — what `stop()` aborts. */\n private runControllers = new Set<AbortController>();\n /**\n * This process, as a run lease names it: what \"already executing on …\"\n * tells an operator. Each run adds its own suffix, so a release can only\n * ever clear the lease that run took.\n */\n private readonly instanceLabel = `${hostname()}:${process.pid}`;\n\n /**\n * Set the server singleton to make it available to cron job handlers.\n *\n * `RebaseServerClient`, not `RebaseClient`: the object `init.ts` passes is\n * the same one it registers as the singleton, so this was always the true\n * type — and the wider annotation is what let `ctx.client.data` look like a\n * user-scoped plane inside a cron, when it is the admin-scoped one.\n */\n setClient(client: RebaseServerClient): void {\n this.client = client;\n }\n\n /**\n * Attach a persistence store for cron logs.\n * When set, execution logs are written to the database after each run,\n * and counters are seeded from the database on start.\n */\n setStore(store: CronStore): void {\n this.store = store;\n }\n\n /**\n * Register a batch of loaded cron jobs.\n *\n * If the scheduler is already started, newly registered jobs are\n * automatically scheduled (so late-registered jobs don't sit idle).\n *\n * Validates the cron schedule on registration — invalid schedules\n * are rejected with a warning and the job is NOT registered.\n */\n registerJobs(loadedJobs: LoadedCronJob[]): void {\n for (const loaded of loadedJobs) {\n // Validate schedule up-front — reject invalid schedules\n const validation = validateCronExpression(loaded.definition.schedule);\n if (!validation.valid) {\n logger.error(`[cron] Rejecting job \"${loaded.id}\": invalid schedule \"${loaded.definition.schedule}\" — ${validation.reason}`);\n // Kept, not just logged. A rejected job is absent from\n // `listJobs()`, so from the Studio panel it is indistinguishable\n // from a file that was never written — and the commonest reason\n // to land here is a 6-field expression copied from a tool that\n // supports seconds, which is a one-character fix nobody could\n // see without boot-log access.\n this.rejected.set(loaded.id, {\n id: loaded.id,\n name: loaded.definition.name ?? loaded.id,\n schedule: loaded.definition.schedule,\n reason: validation.reason\n });\n continue;\n }\n // Rejected, not read as local time: a misspelled zone that fell\n // back silently would fire at the wrong hour on every host and\n // look like a scheduler bug. Kept in `rejected` for the same reason\n // a bad schedule is — otherwise the panel cannot tell it from a\n // file nobody wrote.\n if (loaded.definition.timezone !== undefined && !isValidTimeZone(loaded.definition.timezone)) {\n const reason =\n `unknown timezone \"${loaded.definition.timezone}\" — ` +\n 'use an IANA name such as \"Europe/Madrid\" or \"UTC\"';\n logger.error(`[cron] Rejecting job \"${loaded.id}\": ${reason}.`);\n this.rejected.set(loaded.id, {\n id: loaded.id,\n name: loaded.definition.name ?? loaded.id,\n schedule: loaded.definition.schedule,\n reason\n });\n continue;\n }\n // Same treatment: a timeout that fails every run the moment it\n // starts is a configuration error, not a run-time one.\n const timeoutProblem = invalidTimeoutReason(loaded.definition.timeoutSeconds);\n if (timeoutProblem) {\n logger.error(`[cron] Rejecting job \"${loaded.id}\": ${timeoutProblem}.`);\n this.rejected.set(loaded.id, {\n id: loaded.id,\n name: loaded.definition.name ?? loaded.id,\n schedule: loaded.definition.schedule,\n reason: timeoutProblem\n });\n continue;\n }\n // A re-register that now validates clears the earlier complaint.\n this.rejected.delete(loaded.id);\n\n const existing = this.jobs.get(loaded.id);\n if (existing) {\n logger.warn(`[cron] Duplicate cron job id: \"${loaded.id}\". Overwriting.`);\n this.stopJob(loaded.id);\n }\n\n const enabled = loaded.definition.enabled !== false;\n\n const job: RegisteredJob = {\n id: loaded.id,\n definition: loaded.definition,\n enabled,\n state: enabled ? \"idle\" : \"disabled\",\n totalRuns: 0,\n totalFailures: 0,\n logs: [],\n executing: false\n };\n this.jobs.set(loaded.id, job);\n\n // If the scheduler is already running, auto-schedule new jobs\n if (this.started && this.shouldArm(job)) {\n this.scheduleNext(loaded.id);\n }\n }\n }\n\n /**\n * Start the scheduler — begins ticking all enabled jobs.\n */\n start(): void {\n if (this.started) return;\n this.started = true;\n\n // Seed counters from DB (non-blocking — scheduler starts immediately)\n if (this.store) {\n this.store.fetchJobStats().then((stats) => {\n for (const [jobId, data] of stats) {\n const job = this.jobs.get(jobId);\n if (job) {\n job.totalRuns = data.totalRuns;\n job.totalFailures = data.totalFailures;\n if (data.lastRunAt) {\n job.lastRunAt = new Date(data.lastRunAt);\n }\n }\n }\n }).catch((err) => {\n logger.warn(\"[cron] Failed to seed job stats from database\", { error: err });\n });\n }\n\n for (const [id, job] of this.jobs) {\n if (this.shouldArm(job)) {\n this.scheduleNext(id);\n }\n }\n if (!this.store) {\n logger.warn(\"[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job\");\n }\n this.warnIfScaleToZero();\n\n // Recover slots that elapsed while nothing was ticking. Deliberately\n // not awaited: catch-up reaches the database and runs handlers, and\n // boot must not wait on either. Its own errors are contained inside.\n void this.catchUpMissedSlots();\n\n logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);\n }\n\n /**\n * Stop the scheduler: clear every timer, then wait for the runs executing\n * right now — for at most `timeoutMs` when given.\n *\n * A run still going when the budget runs out has its `ctx.signal` aborted\n * and ends there, recorded as a failure that says why. Its slot stays\n * claimed, so no other instance re-runs it; a handler that must not be cut\n * short should watch its signal and leave its work resumable.\n *\n * Nothing is scheduled after `stop()`, whether or not the returned promise\n * is awaited: the timers are cleared before it first yields.\n */\n async stop(timeoutMs?: number): Promise<void> {\n this.started = false;\n for (const [id] of this.jobs) {\n this.stopJob(id);\n }\n if (this.inFlight.size === 0) return;\n\n const settled = Promise.allSettled([...this.inFlight]).then(() => true);\n if (timeoutMs === undefined) {\n await settled;\n return;\n }\n let budget: ReturnType<typeof setTimeout> | undefined;\n const finished = await Promise.race([\n settled,\n new Promise<false>((resolve) => {\n budget = setTimeout(() => resolve(false), timeoutMs);\n if (budget && typeof budget === \"object\" && \"unref\" in budget) {\n budget.unref();\n }\n })\n ]);\n clearTimeout(budget);\n if (finished || this.runControllers.size === 0) return;\n\n logger.warn(`[cron] ${this.runControllers.size} run(s) still executing after ${timeoutMs}ms — aborting them`);\n for (const controller of this.runControllers) {\n controller.abort(new Error(\"The server is shutting down; the run was stopped before it finished\"));\n }\n }\n\n /**\n * List all registered jobs with their current status.\n */\n listJobs(): CronJobStatus[] {\n return [...this.jobs.values()].map((job) => this.toStatus(job));\n }\n\n /**\n * Jobs that loaded but whose schedule the scheduler refused.\n *\n * Kept apart from {@link listJobs} because they are not jobs — nothing will\n * run them — but they must be reachable, or \"my cron is missing\" and \"my\n * cron will never fire\" look identical from the admin panel.\n */\n listRejectedJobs(): RejectedCronJob[] {\n return [...this.rejected.values()];\n }\n\n /**\n * Get a single job status by ID.\n */\n getJob(id: string): CronJobStatus | undefined {\n const job = this.jobs.get(id);\n return job ? this.toStatus(job) : undefined;\n }\n\n /**\n * {@link listJobs}, as the fleet sees it rather than this process alone.\n *\n * Reads the store: every job's enabled state — a pause another replica\n * made — and whether another process holds its run lease. On a process\n * whose scheduler is not started (the `api` role) it also reads each job's\n * run count and last run from `cron_logs`, since that process runs nothing\n * and its own counters would say 0 runs for a job the worker has run a\n * thousand times.\n *\n * A store that cannot answer leaves this process's own view, with a\n * warning: a listing is not worth failing.\n */\n async fetchJobs(): Promise<CronJobStatus[]> {\n const jobs = [...this.jobs.values()];\n const views = await this.readFleet(jobs);\n return jobs.map(job => this.toStatus(job, views.get(job.id)));\n }\n\n /** {@link getJob}, as {@link fetchJobs} reads it. */\n async fetchJob(id: string): Promise<CronJobStatus | undefined> {\n const job = this.jobs.get(id);\n if (!job) return undefined;\n const views = await this.readFleet([job]);\n return this.toStatus(job, views.get(id));\n }\n\n /**\n * Get log entries for a job.\n */\n getJobLogs(id: string, limit?: number): CronJobLogEntry[] {\n const job = this.jobs.get(id);\n if (!job) return [];\n const logs = [...job.logs].reverse(); // newest first\n return limit ? logs.slice(0, limit) : logs;\n }\n\n /**\n * Get log entries for a job from the database (if store is available).\n * Falls back to in-memory logs if no store is configured.\n */\n async getJobLogsFromDb(id: string, limit?: number): Promise<CronJobLogEntry[]> {\n if (this.store) {\n const dbLogs = await this.store.fetchLogs(id, limit);\n if (dbLogs.length > 0) return dbLogs;\n }\n // Fallback to in-memory\n return this.getJobLogs(id, limit);\n }\n\n /**\n * Enable or disable a job in this process only. `null` returns it to what\n * its code declares.\n *\n * {@link persistJobEnabled} is what the admin API calls: this alone is\n * forgotten on restart and reaches no other replica.\n */\n setJobEnabled(id: string, enabled: boolean | null): CronJobStatus | undefined {\n const job = this.jobs.get(id);\n if (!job) return undefined;\n\n const effective = enabled ?? this.codeEnabled(job);\n this.applyEnabled(job, effective);\n\n if (effective && this.started) {\n this.scheduleNext(id);\n } else if (!this.shouldArm(job)) {\n this.stopJob(id);\n }\n\n return this.toStatus(job);\n }\n\n /**\n * Enable or disable a job for every process that runs it, and keep it that\n * way across restarts and redeploys: `true` or `false` overrides the code's\n * `enabled`, `null` hands the decision back to it.\n *\n * The override is written to the store first, and only then applied here,\n * so a write that fails throws and changes nothing — a pause that only the\n * replica serving the request would honour is the failure this exists to\n * end. Every scheduler reads the override when a slot comes due, before it\n * claims the slot.\n *\n * Without a store that keeps the state (a driver with no SQL, persistence\n * switched off, a custom store) this is {@link setJobEnabled}: local, and\n * gone on restart.\n */\n async persistJobEnabled(id: string, enabled: boolean | null, updatedBy?: string): Promise<CronJobStatus | undefined> {\n if (!this.jobs.has(id)) return undefined;\n if (this.store?.saveEnabledOverride) {\n await this.store.saveEnabledOverride(id, enabled, updatedBy);\n }\n return this.setJobEnabled(id, enabled);\n }\n\n /**\n * Manually trigger a job execution immediately.\n *\n * Returns `undefined` if the job doesn't exist.\n * If the job is currently executing — here, or on another process that\n * holds its run lease — returns the log entry with a `skipped: true`\n * result rather than running concurrently; {@link isAlreadyExecutingSkip}\n * recognises it.\n */\n async triggerJob(id: string): Promise<CronJobLogEntry | undefined> {\n const job = this.jobs.get(id);\n if (!job) return undefined;\n\n // Concurrency guard — don't run two instances simultaneously\n if (job.executing) {\n logger.warn(`[cron] Skipping manual trigger of \"${id}\" — already executing`);\n return this.recordSkip(job, \"already_executing\", true);\n }\n\n return this.executeJob(job, true);\n }\n\n /**\n * Record a run that did not happen because the previous one had not\n * finished.\n *\n * Written to `cron_logs`, not only to the in-memory ring. An overlap is the\n * signature of a job that has outgrown its schedule — the one thing you want\n * to see in the history rather than infer from a gap in it — and until now\n * the scheduled path left no trace at all beyond a warning in the process\n * log, which is gone by the time anyone asks. The manual path wrote a ring\n * entry that vanished on restart.\n *\n * `success: true` is deliberate: nothing failed. The `result.skipped` flag\n * and the reason are what distinguishes it, and the Studio panel reads them.\n */\n private recordSkip(job: RegisteredJob, reason: string, manual: boolean, runningOn?: string): CronJobLogEntry {\n const now = new Date().toISOString();\n const why = reason === \"already_executing\" ? \"the previous run has not finished\" : reason;\n const logEntry: CronJobLogEntry = {\n jobId: job.id,\n startedAt: now,\n finishedAt: now,\n durationMs: 0,\n success: true,\n result: { skipped: true, reason },\n logs: [`Skipped: ${why}${runningOn ? ` — it is running on ${runningOn}` : \"\"}`],\n manual\n };\n\n job.logs.push(logEntry);\n if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();\n\n // Tracked like a run's own write, so a shutdown does not close the pool\n // under it.\n if (this.store) {\n this.track(this.store.insertLog(logEntry).catch((persistErr) => {\n logger.error(`[cron] Failed to persist skip for \"${job.id}\"`, { error: persistErr });\n }));\n }\n\n return logEntry;\n }\n\n // ─── Internal ────────────────────────────────────────────────────\n\n /** What the job's own definition says, before any override. */\n private codeEnabled(job: RegisteredJob): boolean {\n return job.definition.enabled !== false;\n }\n\n /**\n * Whether the enabled state lives in the store, where every process reads\n * it, rather than in this process alone.\n */\n private sharesEnabledState(): boolean {\n return Boolean(this.store?.fetchJobStates && this.store.saveEnabledOverride);\n }\n\n /**\n * Whether a job gets a timer here. A job paused in this process's memory\n * needs none — nothing but this process can resume it. A job whose state\n * the store keeps does, paused or not: another replica may resume it, and\n * reading the state when its slot comes due is how this one finds out.\n */\n private shouldArm(job: RegisteredJob): boolean {\n return job.enabled || this.sharesEnabledState();\n }\n\n /** Set a job's enabled flag, and its state with it unless a run is in progress. */\n private applyEnabled(job: RegisteredJob, enabled: boolean): void {\n job.enabled = enabled;\n if (job.executing) return;\n if (!enabled) job.state = \"disabled\";\n else if (job.state === \"disabled\") job.state = \"idle\";\n }\n\n /**\n * Whether a job may run now: the store's override when it keeps one, else\n * the code's `enabled`.\n *\n * One query per fire, before the slot is claimed — a paused job does not\n * spend its slot. A store that cannot answer falls back to the code's\n * `enabled`, with a warning: a scheduled run fails open, as it does when\n * the claims table cannot answer, because a broken state table must not\n * silently stop every job.\n */\n private async isEnabledNow(job: RegisteredJob): Promise<boolean> {\n if (!this.store?.fetchJobStates || !this.sharesEnabledState()) return job.enabled;\n try {\n const state = (await this.store.fetchJobStates([job.id])).get(job.id);\n const enabled = state?.enabled ?? this.codeEnabled(job);\n this.applyEnabled(job, enabled);\n return enabled;\n } catch (err) {\n const enabled = this.codeEnabled(job);\n logger.warn(\n `[cron] Could not read the enabled state of \"${job.id}\" — falling back to the code's ` +\n `enabled (${enabled}); a pause made from the admin API is not being honoured`,\n { error: err }\n );\n return enabled;\n }\n }\n\n /**\n * How long a run's lease lasts: the run's timeout and a grace, so that a\n * crashed holder frees the job once its run would have been aborted\n * anyway. No timeout gets {@link MAX_UNBOUNDED_LEASE_SECONDS}.\n */\n private leaseSeconds(job: RegisteredJob): number {\n const timeout = job.definition.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;\n return Number.isFinite(timeout) ? timeout + LEASE_GRACE_SECONDS : MAX_UNBOUNDED_LEASE_SECONDS;\n }\n\n /**\n * Take the job's run lease, the cross-process half of \"already\n * executing\". A store without leases, or one that cannot answer, lets the\n * run go ahead unleased — with a warning in the second case — the same way\n * a claim that cannot answer does.\n */\n private async acquireLease(job: RegisteredJob): Promise<LeaseOutcome> {\n if (!this.store?.tryAcquireRunLease) return { acquired: true };\n const token = `${this.instanceLabel}#${randomUUID().slice(0, 8)}`;\n try {\n const lease = await this.store.tryAcquireRunLease(job.id, token, this.leaseSeconds(job));\n return lease.acquired ? { acquired: true, token } : lease;\n } catch (err) {\n logger.warn(\n `[cron] Could not take the run lease for \"${job.id}\" — running without it, ` +\n \"so another process may run it at the same time\",\n { error: err }\n );\n return { acquired: true };\n }\n }\n\n /** Release a lease this run took. A release that fails leaves it to lapse. */\n private async releaseLease(job: RegisteredJob, token: string): Promise<void> {\n try {\n await this.store?.releaseRunLease?.(job.id, token);\n } catch (err) {\n logger.warn(\n `[cron] Could not release the run lease for \"${job.id}\" — it lapses on its own ` +\n `within ${this.leaseSeconds(job)}s`,\n { error: err }\n );\n }\n }\n\n /**\n * What the store says about `jobs` beyond this process — see\n * {@link fetchJobs}. Applies the enabled state it reads, and returns the\n * rest as a view for {@link toStatus}. Never throws.\n */\n private async readFleet(jobs: RegisteredJob[]): Promise<Map<string, FleetView>> {\n const views = new Map<string, FleetView>();\n const viewOf = (id: string): FleetView => {\n let view = views.get(id);\n if (!view) {\n view = {};\n views.set(id, view);\n }\n return view;\n };\n const store = this.store;\n const ids = jobs.map(job => job.id);\n\n if (store?.fetchJobStates && this.sharesEnabledState() && ids.length > 0) {\n try {\n const states = await store.fetchJobStates(ids);\n for (const job of jobs) {\n const state = states.get(job.id);\n this.applyEnabled(job, state?.enabled ?? this.codeEnabled(job));\n if (state?.runningBy && !job.executing) viewOf(job.id).runningElsewhere = true;\n }\n } catch (err) {\n logger.warn(\"[cron] Could not read the jobs' shared state — listing what this process knows\", { error: err });\n }\n }\n\n // Only where nothing is scheduled: a started scheduler has its own next\n // slots, and counts its own runs, seeded from the same table at start.\n if (!this.started) {\n const now = new Date();\n for (const job of jobs) {\n try {\n viewOf(job.id).nextRunAt = parseCronExpression(job.definition.schedule, now, job.definition.timezone);\n } catch {\n // A schedule with no reachable slot has no next run to show.\n }\n }\n if (store?.fetchRunSummaries && ids.length > 0) {\n try {\n const summaries = await store.fetchRunSummaries(ids);\n for (const [id, summary] of summaries) viewOf(id).summary = summary;\n } catch (err) {\n logger.warn(\"[cron] Could not read the jobs' run history — listing what this process knows\", { error: err });\n }\n }\n }\n return views;\n }\n\n /**\n * Warn once at start when the process looks like it is running on a\n * platform that freezes or evicts instances between requests, where the\n * in-process timers this scheduler relies on never fire.\n *\n * Advisory only: any failure here is swallowed so a detection bug can\n * never take a production boot down.\n */\n private warnIfScaleToZero(): void {\n try {\n const warning = buildScaleToZeroWarning(\n [...this.jobs.values()].map((job) => ({ id: job.id, enabled: job.enabled }))\n );\n if (warning) {\n logger.warn(warning.message, warning.data);\n }\n } catch {\n // Never let the advisory check affect startup.\n }\n }\n\n /**\n * Schedule the next execution for a job.\n *\n * Safety guarantees:\n * 1. Clears any existing timer first (prevents leaked/duplicate timers)\n * 2. Enforces a minimum delay to prevent tight loops from jitter\n * 3. Unref's the timer so it doesn't prevent process exit\n * 4. Re-checks enabled & started state before executing\n * 5. Concurrency guard prevents overlapping handler executions\n */\n private scheduleNext(id: string): void {\n const job = this.jobs.get(id);\n if (!job || !this.started || !this.shouldArm(job)) return;\n\n // Clear any previously scheduled timer to prevent double-firing\n this.stopJob(id);\n\n try {\n const now = new Date();\n const nextRun = parseCronExpression(job.definition.schedule, now, job.definition.timezone);\n job.nextRunAt = nextRun;\n\n const rawDelay = nextRun.getTime() - now.getTime();\n // Enforce a minimum delay to prevent tight re-execution loops\n // from event loop jitter or near-zero setTimeout drift\n const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);\n\n // A slot past the 32-bit timer ceiling cannot be armed directly:\n // setTimeout would clamp it to 1ms and fire at once, and since the\n // slot is already claimed by then, every wake re-schedules the same\n // overflowing delay — a permanent hot loop, not a late job. Sleep\n // to the ceiling and re-derive the delay on waking instead; the\n // cron expression stays the source of truth across the hops.\n if (delay > MAX_TIMER_DELAY_MS) {\n const hop = setTimeout(() => {\n if (this.started && this.shouldArm(job)) this.scheduleNext(id);\n }, MAX_TIMER_DELAY_MS);\n if (hop && typeof hop === \"object\" && \"unref\" in hop) {\n hop.unref();\n }\n job.timerId = hop;\n return;\n }\n\n const timer = setTimeout(async () => {\n // Re-check state: the scheduler may have been stopped between\n // when we scheduled and when we fire\n if (!this.started) return;\n\n // A timer can wake before its slot: a delay past the 32-bit\n // ceiling, a clock stepped backwards by NTP, a VM resuming from\n // suspend. Claiming on an early wake is unrecoverable — claims\n // are permanent, so the slot would be burned and the real run\n // silently skipped when it came due. Re-derive from the wall\n // clock and re-arm instead; only the fire that is genuinely due\n // may claim.\n if (Date.now() < nextRun.getTime()) {\n this.scheduleNext(id);\n return;\n }\n\n // Paused or resumed — perhaps by another replica, perhaps\n // before this process started. Asked of the store at every\n // fire, and before the claim, so a paused job leaves its slot\n // unspent.\n if (!(await this.isEnabledNow(job))) {\n if (this.started && this.shouldArm(job)) this.scheduleNext(id);\n return;\n }\n if (!this.started) return;\n\n // Concurrency guard: if somehow we're already executing, skip\n if (job.executing) {\n logger.warn(`[cron] Skipping scheduled run of \"${id}\" — still executing from previous run`);\n // Recorded as well as logged: a job that keeps overlapping\n // has outgrown its schedule, and that is visible in the run\n // history or nowhere.\n this.recordSkip(job, \"already_executing\", false);\n // Re-schedule to try again later\n this.scheduleNext(id);\n return;\n }\n\n // Cross-instance guard: claim the scheduled slot in the store.\n // The slot is the scheduled fire time — deterministic across\n // instances — so exactly one instance wins each (job, slot) pair.\n // A store without tryClaimRun (pre-claims custom implementation)\n // runs uncoordinated; a throwing store fails open — either way\n // this callback must never reject, or the job would silently\n // stop rescheduling.\n if (this.store?.tryClaimRun) {\n let claimed = true;\n try {\n claimed = await this.store.tryClaimRun(id, nextRun.toISOString());\n } catch (err) {\n logger.warn(`[cron] Claim check threw for \"${id}\" — running uncoordinated`, { error: err });\n }\n if (!claimed) {\n logger.info(`[cron] Slot ${nextRun.toISOString()} for \"${id}\" claimed by another instance — skipping`);\n if (this.started && this.shouldArm(job)) {\n this.scheduleNext(id);\n }\n return;\n }\n }\n\n // Takes the run lease: a manual trigger in progress on another\n // process — the api role's, say — turns this slot into a\n // recorded skip rather than a second run beside it.\n await this.executeJob(job, false);\n\n // Schedule the next tick (only if still started + armed)\n if (this.started && this.shouldArm(job)) {\n this.scheduleNext(id);\n }\n }, delay);\n\n // Unref the timer so it doesn't prevent Node.js from exiting\n // during graceful shutdown\n if (timer && typeof timer === \"object\" && \"unref\" in timer) {\n timer.unref();\n }\n\n job.timerId = timer;\n } catch (err: unknown) {\n logger.error(`[cron] Failed to schedule \"${id}\"`, { error: err });\n job.state = \"error\";\n job.lastError = err instanceof Error ? err.message : String(err);\n }\n }\n\n /**\n * Run any slot that elapsed while no instance was holding a timer for it.\n *\n * Only jobs that opted in via `catchUpWindowSeconds` are considered, and\n * only their single most recent missed slot — see the field's docs for why\n * both limits are deliberate.\n *\n * The claim is what makes this safe. In the ordinary case — an instance\n * restarting minutes after a slot ran normally — the most recent slot is\n * already claimed, so this costs one `tryClaimRun` per job per boot and\n * does nothing. A slot is only executed when no instance, past or present,\n * ever claimed it.\n *\n * Never throws: a failure here must not take down a scheduler that is\n * otherwise ticking correctly.\n */\n private async catchUpMissedSlots(): Promise<void> {\n // Every job with a window whose state the store may override, not only\n // the ones enabled here: a job the code disables can be resumed from\n // the admin API, and one it enables can be paused there. Each is asked\n // below, before its claim.\n const candidates = [...this.jobs.values()].filter(\n job => this.shouldArm(job) && (job.definition.catchUpWindowSeconds ?? 0) > 0\n );\n if (candidates.length === 0) return;\n\n // A claims-capable store is the whole safety mechanism. Without one,\n // every boot would look like \"this slot never ran\" and an instance\n // recycled twice an hour would re-run the same hourly job twice an\n // hour. Refusing to catch up is the correct degradation.\n if (!this.store?.tryClaimRun) {\n logger.warn(\n `[cron] Catch-up is configured on ${candidates.length} job(s) but no claims-capable store is attached — skipping. ` +\n \"Without claims a restart cannot tell an unrun slot from one the previous instance already ran.\"\n );\n return;\n }\n\n for (const job of candidates) {\n try {\n if (!this.started || job.executing) continue;\n // The persisted pause outlives the deploy that is catching up.\n if (!(await this.isEnabledNow(job))) continue;\n if (!this.started || job.executing) continue;\n\n // Read per job, not once for the pass: the jobs ahead of this\n // one ran their catch-ups first, and this job's own timer may\n // have fired newer slots meanwhile. Its most recent slot is the\n // one as of now, or a stale one runs after newer ones already did.\n const now = new Date();\n const windowSeconds = job.definition.catchUpWindowSeconds!;\n const from = new Date(now.getTime() - windowSeconds * 1000);\n const slot = findMostRecentSlot(job.definition.schedule, from, now, job.definition.timezone);\n if (!slot) continue;\n\n const slotIso = slot.toISOString();\n\n // Same key the scheduled path claims with, so a slot that fired\n // normally is already taken and this is a no-op.\n let claimed: boolean;\n try {\n claimed = await this.store.tryClaimRun(job.id, slotIso);\n } catch (err) {\n // Fail closed, unlike the scheduled path. A missed slot is\n // a recovery, not an obligation — running it against a\n // store that cannot tell us whether it already ran risks a\n // duplicate on every boot.\n logger.warn(`[cron] Catch-up claim threw for \"${job.id}\" — skipping catch-up`, { error: err });\n continue;\n }\n\n if (!claimed) continue;\n\n const lateBy = Math.round((now.getTime() - slot.getTime()) / 1000);\n logger.info(`[cron] Catching up missed slot ${slotIso} for \"${job.id}\" (${lateBy}s late)`);\n\n await this.executeJob(job, false, `⏰ Catch-up run for missed slot ${slotIso} (${lateBy}s late)`);\n } catch (err) {\n logger.error(`[cron] Catch-up failed for \"${job.id}\"`, { error: err });\n }\n }\n }\n\n /**\n * Stop a single job's timer and clear its next run state.\n */\n private stopJob(id: string): void {\n const job = this.jobs.get(id);\n if (job?.timerId) {\n clearTimeout(job.timerId);\n job.timerId = undefined;\n job.nextRunAt = undefined;\n }\n }\n\n /**\n * Execute a job's handler with full isolation and safety.\n *\n * - Sets a concurrency flag to prevent overlapping runs\n * - Wraps handler in a timeout race\n * - Captures all logs, errors, and results\n * - Persists to store (non-blocking) if available\n * - Always restores state even on catastrophic errors\n */\n private executeJob(\n job: RegisteredJob,\n manual: boolean,\n seedLog?: string\n ): Promise<CronJobLogEntry> {\n // Aborted when the run is over before its handler is: the timeout\n // below, or `stop()` giving up on it. Without it the timeout only\n // stopped the scheduler waiting: the handler's `fetch` kept its socket,\n // so a job whose timeout matches its interval leaked one abandoned\n // request per tick while every run was already marked failed.\n const abort = new AbortController();\n this.runControllers.add(abort);\n const run = this.runJob(job, manual, abort, seedLog);\n this.track(run);\n return run.finally(() => {\n this.runControllers.delete(abort);\n });\n }\n\n /** Hold `stop()` until `work` settles. */\n private track(work: Promise<unknown>): void {\n this.inFlight.add(work);\n const release = () => { this.inFlight.delete(work); };\n work.then(release, release);\n }\n\n private async runJob(\n job: RegisteredJob,\n manual: boolean,\n abort: AbortController,\n seedLog?: string\n ): Promise<CronJobLogEntry> {\n // Checked here as well as by each caller, because each caller awaits\n // something (a claim, a state read) between its own check and this.\n if (job.executing) {\n return this.recordSkip(job, \"already_executing\", manual);\n }\n // Set executing flag — prevents concurrent runs. Taken before the lease\n // is awaited, so a second trigger on this process cannot slip in.\n job.executing = true;\n\n // The lease is what keeps the other processes off: the worker's\n // scheduled run and the api role's manual trigger never saw each\n // other's in-process flag.\n const lease = await this.acquireLease(job);\n if (!lease.acquired) {\n job.executing = false;\n logger.warn(`[cron] Skipping ${manual ? \"manual trigger\" : \"scheduled run\"} of \"${job.id}\" — it is running on ${lease.holder ?? \"another process\"}`);\n return this.recordSkip(job, \"already_executing\", manual, lease.holder ?? \"another process\");\n }\n\n const startedAt = new Date();\n // A caller-supplied first line, stored with the run's own output. A\n // catch-up uses it to say so in the persisted log, where an operator\n // reading `cron_logs` will actually see it — `manual` is the only other\n // provenance the entry carries, and a catch-up is not manual.\n const capturedLogs: string[] = seedLog ? [seedLog] : [];\n\n const ctx: CronJobContext = {\n jobId: job.id,\n scheduledAt: startedAt,\n signal: abort.signal,\n log: (...args: unknown[]) => {\n const line = args.map((a) =>\n typeof a === \"string\" ? a : JSON.stringify(a)\n ).join(\" \");\n capturedLogs.push(line);\n },\n // `rebase`, and only `rebase`: it matches the singleton import and\n // `defineFunction`'s context. The old `client` alias re-exposed\n // `client.data`, the name `RebaseServerClient` deliberately omits so\n // that the RLS-bypassing plane is spelled `dataAsAdmin` everywhere.\n rebase: this.client!\n };\n\n job.state = \"running\";\n job.lastRunAt = startedAt;\n job.totalRuns++;\n\n let success = true;\n let error: string | undefined;\n let result: unknown;\n\n // The run ends when the handler settles or when `abort` fires,\n // whichever is first — so an abort ends the run even for a handler\n // that ignores its signal, and the run is recorded rather than left\n // \"running\" forever.\n const aborted = new Promise<never>((_, reject) => {\n const end = () => reject(abort.signal.reason);\n if (abort.signal.aborted) end();\n else abort.signal.addEventListener(\"abort\", end, { once: true });\n });\n // Handled here as well as by the race: a handler that throws before\n // the race is entered leaves nothing else listening.\n aborted.catch(() => undefined);\n\n try {\n // Race with timeout. Past the 32-bit timer ceiling a plain\n // setTimeout fires after 1ms, so a month-long timeout would fail\n // every run at once; Infinity is no timeout at all.\n const timeout = (job.definition.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000;\n const cancelTimeout = Number.isFinite(timeout)\n ? setLongTimeout(\n () => abort.abort(new Error(`Cron job \"${job.id}\" timed out after ${timeout}ms`)),\n timeout\n )\n : undefined;\n\n try {\n result = await Promise.race([Promise.resolve(job.definition.handler(ctx)), aborted]);\n } finally {\n cancelTimeout?.();\n }\n } catch (err: unknown) {\n success = false;\n // Redacted at the point of capture, not just on the way to the log:\n // this string is persisted into `cron_logs` and rendered in the\n // Studio cron panel, and a job that fails on a query would\n // otherwise store `Failed query: <sql>\\nparams: <values>` — the\n // statement and every bound value — in a table, indefinitely.\n error = redactSensitiveText(err instanceof Error ? err.message : String(err));\n job.totalFailures++;\n } finally {\n // The lease before the flag: in between, a trigger on this process\n // would pass the flag and then find its own process's lease held.\n if (lease.token) await this.releaseLease(job, lease.token);\n // Always clear executing flag — even on catastrophic errors\n job.executing = false;\n }\n\n const finishedAt = new Date();\n const durationMs = finishedAt.getTime() - startedAt.getTime();\n\n job.state = success ? (job.enabled ? \"idle\" : \"disabled\") : \"error\";\n job.lastDurationMs = durationMs;\n job.lastError = error;\n\n const logEntry: CronJobLogEntry = {\n jobId: job.id,\n startedAt: startedAt.toISOString(),\n finishedAt: finishedAt.toISOString(),\n durationMs,\n success,\n error,\n result: result !== undefined ? result : undefined,\n logs: capturedLogs,\n manual\n };\n\n // Push to ring buffer\n job.logs.push(logEntry);\n if (job.logs.length > MAX_LOGS_PER_JOB) {\n job.logs.shift();\n }\n\n // Persist to database (non-blocking for the run, but tracked, so a\n // shutdown does not close the pool under the write)\n if (this.store) {\n this.track(this.store.insertLog(logEntry).catch((persistErr) => {\n logger.error(`[cron] Failed to persist log for \"${job.id}\"`, { error: persistErr });\n }));\n }\n\n if (success) {\n logger.info(`✅ [cron] \"${job.id}\" completed in ${durationMs}ms`);\n } else {\n logger.error(`❌ [cron] \"${job.id}\" failed in ${durationMs}ms: ${error}`);\n }\n\n return logEntry;\n }\n\n /**\n * A job's status: this process's own record, overlaid with what `view`\n * read from the store. A paused job shows no next run, even where its\n * timer stays armed to read the state again at that slot.\n */\n private toStatus(job: RegisteredJob, view: FleetView = {}): CronJobStatus {\n const summary = view.summary;\n const nextRunAt = job.enabled ? (job.nextRunAt ?? view.nextRunAt) : undefined;\n let state = job.state;\n if (!job.executing) {\n if (view.runningElsewhere) state = \"running\";\n else if (summary && job.enabled) state = summary.lastSuccess === false ? \"error\" : \"idle\";\n }\n return {\n id: job.id,\n // Same fallback the two `listJobs` paths already applied: `name` is\n // optional and the id is the file's own name.\n name: job.definition.name ?? job.id,\n description: job.definition.description,\n schedule: job.definition.schedule,\n enabled: job.enabled,\n state,\n lastRunAt: summary ? summary.lastRunAt : job.lastRunAt?.toISOString(),\n nextRunAt: nextRunAt?.toISOString(),\n lastDurationMs: summary ? summary.lastDurationMs : job.lastDurationMs,\n lastError: summary ? (summary.lastSuccess === false ? summary.lastError : undefined) : job.lastError,\n totalRuns: summary ? summary.totalRuns : job.totalRuns,\n totalFailures: summary ? summary.totalFailures : job.totalFailures\n };\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"cron-store-TcoGz-xS.js","names":[],"sources":["../src/cron/cron-store.ts"],"sourcesContent":["import type { CronJobLogEntry } from \"@rebasepro/types\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain } from \"../boot/ddl-bootstrap.js\";\n\n/**\n * Persistence layer for cron: the run history (`rebase.cron_logs`), the slot\n * claims that keep replicas from running the same slot twice\n * (`rebase.cron_claims`), and each job's shared state — the enabled override\n * and the run lease — in `rebase.cron_job_state`.\n *\n * Uses the DataDriver's `admin.executeSql` capability. Falls back gracefully if\n * the driver doesn't support SQL (e.g. MongoDB) — in that case, no persistence\n * occurs, and a pause or a trigger's \"already executing\" is per process.\n */\nexport interface CronStore {\n /** Ensure the backing table exists. Called once on startup. */\n ensureTable(): Promise<void>;\n\n /** Persist a single log entry after execution. */\n insertLog(entry: CronJobLogEntry): Promise<void>;\n\n /**\n * Fetch the most recent logs for a job.\n * @param jobId The job identifier\n * @param limit Max entries to return (default 50)\n * @returns Logs sorted newest-first\n */\n fetchLogs(jobId: string, limit?: number): Promise<CronJobLogEntry[]>;\n\n /**\n * Fetch aggregate stats for all jobs (totalRuns, totalFailures, lastRunAt).\n * Used to seed in-memory counters on startup.\n */\n fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>>;\n\n /**\n * Atomically claim a scheduled run slot for a job.\n *\n * `slot` is the *scheduled* fire time (ISO string) derived from the cron\n * expression — deterministic across instances regardless of timer drift,\n * so all instances contend on the same (jobId, slot) key. Exactly one\n * caller wins the insert against the unique constraint and executes;\n * the rest skip.\n *\n * Throws when the store cannot tell — a missing or unreadable claims\n * table, a dropped connection. What that means is the caller's decision,\n * and the scheduler's two callers decide differently: a scheduled run\n * fails open, so a broken claims table degrades to uncoordinated execution\n * rather than silently never running jobs; a catch-up fails closed, so it\n * does not re-run the last slot on every boot of every replica.\n *\n * Optional so custom stores written against the pre-claims interface\n * keep working — the scheduler treats a missing implementation as\n * uncoordinated (always run).\n */\n tryClaimRun?(jobId: string, slot: string): Promise<boolean>;\n\n // ─── Fleet-wide job state ────────────────────────────────────────\n //\n // Everything below lives in `rebase.cron_job_state`, one row per job that\n // has ever been paused, resumed or run under a lease. It exists because the\n // alternative — each process's memory — is per process: a pause served by\n // one replica stopped only that replica, and on a split deployment the\n // process serving the admin API is not the one whose timers fire, so a\n // pause there stopped nothing at all.\n //\n // Optional, like `tryClaimRun`, so a custom store written against the\n // older interface keeps working: without them a pause is local to the\n // process that served it and a manual trigger is guarded only in-process.\n\n /**\n * Persist a job's enabled override: `true` or `false`, or `null` to hand the\n * decision back to the code's own `enabled`. Survives restarts and\n * redeploys — that is the point. Throws when the write fails.\n */\n saveEnabledOverride?(jobId: string, enabled: boolean | null, updatedBy?: string): Promise<void>;\n\n /**\n * The persisted state of the jobs asked about. A job with no row has no\n * override. `runningBy` is set only while a lease is live.\n *\n * Throws when the store cannot tell. The scheduler falls back to the code's\n * `enabled` then — a scheduled run fails open — and a listing falls back to\n * what this process knows.\n */\n fetchJobStates?(jobIds: readonly string[]): Promise<Map<string, CronJobPersistedState>>;\n\n /**\n * Take the job's run lease unless another process holds a live one.\n *\n * The cross-process half of \"already executing\": a manual trigger, a\n * scheduled run and a catch-up all take it, so none of them overlaps a run\n * another replica — or the worker, from the api role — is making. The lease\n * expires on its own after `ttlSeconds`, which is what frees a job whose\n * holder crashed.\n *\n * Throws when the store cannot tell.\n */\n tryAcquireRunLease?(jobId: string, holder: string, ttlSeconds: number): Promise<CronRunLease>;\n\n /** Release a lease taken with {@link tryAcquireRunLease} — only if `holder` still holds it. */\n releaseRunLease?(jobId: string, holder: string): Promise<void>;\n\n /**\n * Run count, failure count and last run of each job, read from\n * `cron_logs` — for a process whose scheduler is not started (the `api`\n * role), which runs nothing and so counts nothing of its own.\n */\n fetchRunSummaries?(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>>;\n}\n\n/** One job's row in `rebase.cron_job_state`, as the scheduler reads it. */\nexport interface CronJobPersistedState {\n /** The override, or `null` to follow the code's `enabled`. */\n enabled: boolean | null;\n /** Who holds the run lease, when a live one is held. */\n runningBy?: string;\n}\n\n/** The outcome of {@link CronStore.tryAcquireRunLease}. */\nexport type CronRunLease =\n | { acquired: true }\n | { acquired: false; holder?: string };\n\n/** A job's run history in brief, read from `cron_logs`. */\nexport interface CronJobRunSummary {\n totalRuns: number;\n totalFailures: number;\n lastRunAt?: string;\n lastDurationMs?: number;\n lastSuccess?: boolean;\n lastError?: string;\n}\n\n// ─── SQL-based implementation ────────────────────────────────────────\n\nconst TABLE = \"rebase.cron_logs\";\nconst CLAIMS_TABLE = \"rebase.cron_claims\";\nconst STATE_TABLE = \"rebase.cron_job_state\";\n\n/** `$1, $2, …` for `count` values, starting at `$first`. */\nfunction placeholders(count: number, first = 1): string {\n return Array.from({ length: count }, (_, i) => `$${first + i}`).join(\", \");\n}\n\n/**\n * Claims older than this are garbage-collected on startup — all but each\n * job's most recent one, which is kept however old it is; see the sweep in\n * `ensureTable`.\n */\nconst CLAIM_RETENTION_DAYS = 7;\n\n/**\n * How far ahead a claim may legitimately sit. Slots are claimed as they fire,\n * so anything beyond this is a clock-skewed peer at best and a stranded claim\n * at worst; see the sweep in `ensureTable`.\n */\nconst FUTURE_CLAIM_SKEW_MINUTES = 2;\n\n/**\n * Detect a unique-constraint violation anywhere in an error's cause chain.\n * Match the SQLSTATE code, never message text. Also covers SQLite\n * (\"UNIQUE constraint failed\") and MySQL (ER_DUP_ENTRY 1062) for future SQL\n * drivers.\n *\n * Distinct from `isConcurrentDdlRace` in `boot/ddl-bootstrap.ts`, which shares\n * the 23505 code but asks a different question — that one is about a losing\n * `CREATE`, this one is about a losing claim, and only the latter means\n * \"another instance already has this slot\".\n */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n e.code === \"23505\" ||\n e.errno === 1062 ||\n (typeof e.message === \"string\" && e.message.includes(\"UNIQUE constraint failed\"))\n );\n}\n\nexport function createCronStore(driver: DataDriver): Required<CronStore> | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\"⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.\");\n return undefined;\n }\n\n const exec = (sqlText: string, options?: { params?: unknown[] }) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n return {\n async ensureTable(): Promise<void> {\n // Creation. Every statement here is idempotent, so losing the race\n // to a peer that booted at the same moment is survivable — but only\n // if the loser retries rather than abandoning everything below it.\n // One step each, so a hard failure on any one of them does not take\n // the others with it. The claims table in particular must not be\n // lost because an index on the *logs* table could not be built.\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n job_id TEXT NOT NULL,\n started_at TIMESTAMPTZ NOT NULL,\n finished_at TIMESTAMPTZ NOT NULL,\n duration_ms INTEGER NOT NULL,\n success BOOLEAN NOT NULL DEFAULT true,\n error TEXT,\n result JSONB,\n logs JSONB,\n manual BOOLEAN NOT NULL DEFAULT false\n )\n `);\n\n await ddl.ensureObject(\"Creating idx_cron_logs_job\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job\n ON ${TABLE}(job_id, started_at DESC)\n `);\n\n // What a process whose scheduler is not started reads to show a\n // job's failure count. Partial, so it holds only the failed runs:\n // the count reads those and nothing else, and a healthy job's\n // inserts never touch it.\n await ddl.ensureObject(\"Creating idx_cron_logs_job_failures\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job_failures\n ON ${TABLE}(job_id) WHERE NOT success\n `);\n\n await ddl.ensureObject(`Creating ${CLAIMS_TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (\n job_id TEXT NOT NULL,\n slot TIMESTAMPTZ NOT NULL,\n claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n PRIMARY KEY (job_id, slot)\n )\n `);\n\n // One row per job that has been paused, resumed or run under a\n // lease. `enabled` NULL means \"follow the code\"; `updated_at` and\n // `updated_by` say who last set it, and stay NULL on a row that has\n // only ever held a lease. Created here rather than by a migration,\n // so a database that predates it gains it on its next boot.\n await ddl.ensureObject(`Creating ${STATE_TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${STATE_TABLE} (\n job_id TEXT PRIMARY KEY,\n enabled BOOLEAN,\n updated_at TIMESTAMPTZ,\n updated_by TEXT,\n running_until TIMESTAMPTZ,\n running_by TEXT\n )\n `);\n\n // Everything from here is keyed on what actually exists, not on\n // whether *this* instance is the one that created it. A single\n // failure above used to abandon the rest of this method, which meant\n // the loser of a boot race skipped the sweeps and — far worse — the\n // privilege revocation, leaving the claims table writable by end\n // users on an instance that reported nothing but a warning about\n // log persistence.\n const [logsReady, claimsReady, stateReady] = await Promise.all([\n ddl.isReadable(TABLE),\n ddl.isReadable(CLAIMS_TABLE),\n ddl.isReadable(STATE_TABLE)\n ]);\n\n if (claimsReady) {\n // Drop claims for slots that have not happened yet. A slot is\n // claimed at the moment it fires, so a future one can only come\n // from a timer that woke early — and because claims are\n // permanent, that claim would silently skip the real run when it\n // finally came due. The margin keeps a legitimate claim made\n // moments early by a clock-skewed peer.\n await ddl.step(\"Future-slot claim sweep\", async () => {\n const stranded = await exec(\n `DELETE FROM ${CLAIMS_TABLE}\n WHERE slot > now() + make_interval(mins => $1)\n RETURNING job_id, slot`,\n { params: [FUTURE_CLAIM_SKEW_MINUTES] }\n );\n // A driver that does not honour RETURNING gives back\n // nothing; the rows are only used to report, so treat that\n // as \"none\".\n for (const row of (stranded ?? []) as { job_id: string; slot: string }[]) {\n logger.warn(\n `[cron-store] Released a claim on the future slot ${new Date(row.slot).toISOString()} ` +\n `for \"${row.job_id}\" — it was claimed by a timer that fired early, and would ` +\n \"otherwise have skipped that run\"\n );\n }\n });\n\n // Garbage-collect old claims — except each job's most recent\n // one. A claim is also the record that a slot already ran, and\n // the catch-up at boot asks exactly that about the job's latest\n // slot, as far back as its `catchUpWindowSeconds` reaches —\n // which can be a month. Sweeping that claim after seven days\n // would make the boot that swept it re-run the slot. A claim\n // behind the latest answers for a slot no catch-up looks at,\n // since a catch-up considers only the most recent one.\n //\n // After the future-slot sweep, not before: a stranded future\n // claim would otherwise count as the job's latest, and the real\n // one behind it would be swept here and then released there.\n await ddl.step(\"Claim retention sweep\", async () => {\n await exec(\n `DELETE FROM ${CLAIMS_TABLE} AS c\n WHERE c.claimed_at < now() - make_interval(days => $1)\n AND c.slot < (\n SELECT max(latest.slot) FROM ${CLAIMS_TABLE} AS latest\n WHERE latest.job_id = c.job_id\n )`,\n { params: [CLAIM_RETENTION_DAYS] }\n );\n });\n }\n\n // Neither table is a collection, so neither carries RLS, while the\n // Postgres driver's schema-wide grant reaches both. Cron logs hold\n // job output — arbitrary application data — and a writable\n // `cron_claims` lets any signed-in user suppress a scheduled run by\n // claiming its slot. This is a security control, so it is re-applied\n // by every instance on every boot, whatever else went wrong.\n if (logsReady) {\n await ddl.step(\"Revoking end-user access to cron_logs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_logs\")));\n }\n if (claimsReady) {\n await ddl.step(\"Revoking end-user access to cron_claims\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_claims\")));\n }\n // A writable `cron_job_state` would let any signed-in user pause a\n // job for the whole fleet, or hold its run lease so that nothing\n // runs it.\n if (stateReady) {\n await ddl.step(\"Revoking end-user access to cron_job_state\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_job_state\")));\n }\n\n if (logsReady && claimsReady && stateReady) {\n logger.info(\"✅ Cron logs table ready\");\n return;\n }\n // Say which capability is gone, and what it costs. \"Continuing\n // without cron log persistence\" undersold this: the claims table is\n // the only thing stopping every instance from running every job.\n if (!claimsReady) {\n logger.error(\n `❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. ` +\n \"With more than one app instance, every instance will now run every job on every tick.\"\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);\n }\n if (!stateReady) {\n logger.warn(\n `⚠️ [cron-store] ${STATE_TABLE} is unavailable — pausing a job reaches only the process ` +\n \"that served the request, and jobs run as their code declares on every other one. \" +\n \"A manual trigger is not kept off a run another process is making.\"\n );\n }\n },\n\n async insertLog(entry: CronJobLogEntry): Promise<void> {\n try {\n const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;\n const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;\n\n await exec(\n `INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)\n VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,\n { params: [\n entry.jobId,\n entry.startedAt,\n entry.finishedAt,\n entry.durationMs,\n entry.success,\n entry.error || null,\n resultJson,\n logsJson,\n entry.manual\n ]}\n );\n } catch (err) {\n // Non-blocking — log persistence should never crash the scheduler\n logger.error(`[cron-store] Failed to persist log for \"${entry.jobId}\"`, { error: err });\n }\n },\n\n async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {\n try {\n const rows = await exec(\n `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\n FROM ${TABLE}\n WHERE job_id = $1\n ORDER BY started_at DESC\n LIMIT $2`,\n { params: [jobId, limit] }\n );\n\n return rows.map(rowToLogEntry);\n } catch (err) {\n logger.error(`[cron-store] Failed to fetch logs for \"${jobId}\"`, { error: err });\n return [];\n }\n },\n\n async fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>> {\n const stats = new Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>();\n try {\n const rows = await exec(`\n SELECT\n job_id,\n COUNT(*)::int AS total_runs,\n COUNT(*) FILTER (WHERE NOT success)::int AS total_failures,\n MAX(started_at) AS last_run_at\n FROM ${TABLE}\n GROUP BY job_id\n `);\n\n for (const row of rows) {\n stats.set(row.job_id as string, {\n totalRuns: row.total_runs as number,\n totalFailures: row.total_failures as number,\n lastRunAt: row.last_run_at ? new Date(row.last_run_at as string).toISOString() : undefined\n });\n }\n } catch (err) {\n logger.error(\"[cron-store] Failed to fetch job stats\", { error: err });\n }\n return stats;\n },\n\n async tryClaimRun(jobId: string, slot: string): Promise<boolean> {\n try {\n const rows = await exec(\n `INSERT INTO ${CLAIMS_TABLE} (job_id, slot)\n VALUES ($1, $2)\n ON CONFLICT (job_id, slot) DO NOTHING\n RETURNING job_id`,\n { params: [jobId, slot] }\n );\n return rows.length > 0;\n } catch (err) {\n if (isUniqueViolation(err)) {\n // Another instance won the race for this slot\n return false;\n }\n // Neither won nor lost: the store does not know, and says so.\n // Answering `true` here would decide \"fail open\" for every\n // caller, including the catch-up, whose whole safety rests on\n // failing closed.\n throw err;\n }\n },\n\n async saveEnabledOverride(jobId: string, enabled: boolean | null, updatedBy?: string): Promise<void> {\n await exec(\n `INSERT INTO ${STATE_TABLE} (job_id, enabled, updated_at, updated_by)\n VALUES ($1, $2::boolean, now(), $3)\n ON CONFLICT (job_id) DO UPDATE\n SET enabled = EXCLUDED.enabled, updated_at = now(), updated_by = EXCLUDED.updated_by`,\n { params: [jobId, enabled, updatedBy ?? null] }\n );\n },\n\n async fetchJobStates(jobIds: readonly string[]): Promise<Map<string, CronJobPersistedState>> {\n const states = new Map<string, CronJobPersistedState>();\n if (jobIds.length === 0) return states;\n const rows = await exec(\n `SELECT job_id, enabled,\n CASE WHEN running_until > now() THEN running_by END AS running_by\n FROM ${STATE_TABLE}\n WHERE job_id IN (${placeholders(jobIds.length)})`,\n { params: [...jobIds] }\n );\n for (const row of rows ?? []) {\n states.set(String(row.job_id), {\n enabled: typeof row.enabled === \"boolean\" ? row.enabled : null,\n runningBy: typeof row.running_by === \"string\" ? row.running_by : undefined\n });\n }\n return states;\n },\n\n async tryAcquireRunLease(jobId: string, holder: string, ttlSeconds: number): Promise<CronRunLease> {\n // One statement, always one row. The insert covers a job that has\n // no row yet; the conditional update covers one whose lease is\n // absent or expired, and does nothing over a live one — so of two\n // processes racing, the second waits on the first's row and then\n // finds it held. The outer SELECT sees the table as it was before\n // this statement, which is exactly the holder that refused us.\n const rows = await exec(\n `WITH attempt AS (\n INSERT INTO ${STATE_TABLE} AS s (job_id, running_until, running_by)\n VALUES ($1, now() + make_interval(secs => $2::double precision), $3)\n ON CONFLICT (job_id) DO UPDATE\n SET running_until = EXCLUDED.running_until, running_by = EXCLUDED.running_by\n WHERE s.running_until IS NULL OR s.running_until <= now()\n RETURNING s.job_id\n )\n SELECT EXISTS (SELECT 1 FROM attempt) AS acquired,\n (SELECT running_by FROM ${STATE_TABLE}\n WHERE job_id = $1 AND running_until > now()) AS holder`,\n { params: [jobId, ttlSeconds, holder] }\n );\n const row = rows?.[0];\n if (!row) {\n throw new Error(`The run lease for \"${jobId}\" got no answer from the database`);\n }\n if (row.acquired === true) return { acquired: true };\n return {\n acquired: false,\n holder: typeof row.holder === \"string\" ? row.holder : undefined\n };\n },\n\n async releaseRunLease(jobId: string, holder: string): Promise<void> {\n await exec(\n `UPDATE ${STATE_TABLE}\n SET running_until = NULL, running_by = NULL\n WHERE job_id = $1 AND running_by = $2`,\n { params: [jobId, holder] }\n );\n },\n\n async fetchRunSummaries(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>> {\n const summaries = new Map<string, CronJobRunSummary>();\n if (jobIds.length === 0) return summaries;\n // Per job, never a GROUP BY over the whole table: each subquery\n // reads one job's range of `idx_cron_logs_job` (the failures, the\n // partial `idx_cron_logs_job_failures`), and the last run is one\n // index probe. Jobs no longer registered are never read at all.\n const rows = await exec(\n `SELECT j.job_id,\n (SELECT count(*)::int FROM ${TABLE} l WHERE l.job_id = j.job_id) AS total_runs,\n (SELECT count(*)::int FROM ${TABLE} l WHERE l.job_id = j.job_id AND NOT l.success) AS total_failures,\n last.started_at, last.duration_ms, last.success, last.error\n FROM (VALUES ${jobIds.map((_, i) => `($${i + 1}::text)`).join(\", \")}) AS j(job_id)\n LEFT JOIN LATERAL (\n SELECT l.started_at, l.duration_ms, l.success, l.error\n FROM ${TABLE} l\n WHERE l.job_id = j.job_id\n ORDER BY l.started_at DESC\n LIMIT 1\n ) AS last ON true`,\n { params: [...jobIds] }\n );\n for (const row of rows ?? []) {\n const summary: CronJobRunSummary = {\n totalRuns: Number(row.total_runs ?? 0),\n totalFailures: Number(row.total_failures ?? 0)\n };\n const lastRunAt = toIsoString(row.started_at);\n if (lastRunAt) {\n summary.lastRunAt = lastRunAt;\n summary.lastDurationMs = Number(row.duration_ms);\n summary.lastSuccess = row.success === true;\n if (typeof row.error === \"string\") summary.lastError = row.error;\n }\n summaries.set(String(row.job_id), summary);\n }\n return summaries;\n }\n };\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────\n\n/** A TIMESTAMPTZ as the driver returned it — a `Date` or a string — in ISO form. */\nfunction toIsoString(value: unknown): string | undefined {\n if (value instanceof Date) return value.toISOString();\n if (typeof value === \"string\" || typeof value === \"number\") return new Date(value).toISOString();\n return undefined;\n}\n\nfunction rowToLogEntry(row: Record<string, unknown>): CronJobLogEntry {\n return {\n jobId: row.job_id as string,\n startedAt: new Date(row.started_at as string).toISOString(),\n finishedAt: new Date(row.finished_at as string).toISOString(),\n durationMs: row.duration_ms as number,\n success: row.success as boolean,\n error: (row.error as string) ?? undefined,\n result: row.result ?? undefined,\n logs: Array.isArray(row.logs) ? row.logs : (row.logs ? (() => { try { return JSON.parse(row.logs as string); } catch { return []; } })() : []),\n manual: row.manual as boolean\n };\n}\n"],"mappings":";;;;;;;;;;;;AA2IA,IAAM,QAAQ;AACd,IAAM,eAAe;AACrB,IAAM,cAAc;;AAGpB,SAAS,aAAa,OAAe,QAAQ,GAAW;CACpD,OAAO,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,MAAM,IAAI,QAAQ,GAAG,CAAC,CAAC,KAAK,IAAI;AAC7E;;;;;;AAOA,IAAM,uBAAuB;;;;;;AAO7B,IAAM,4BAA4B;;;;;;;;;;;;AAalC,SAAS,kBAAkB,KAAuB;CAC9C,OAAO,gBAAgB,MAAM,MACzB,EAAE,SAAS,WACX,EAAE,UAAU,QACX,OAAO,EAAE,YAAY,YAAY,EAAE,QAAQ,SAAS,0BAA0B,CACnF;AACJ;AAEA,SAAgB,gBAAgB,QAAqD;CACjF,MAAM,QAAQ,OAAO;CACrB,IAAI,CAAC,WAAW,KAAK,GAAG;EACpB,OAAO,KAAK,0FAA0F;EACtG;CACJ;CAEA,MAAM,QAAQ,SAAiB,YAC3B,MAAM,WAAW,SAAS,SAAS,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,KAAA,CAAS;CAEtF,MAAM,MAAM,sBAAsB,MAAM,YAAY;CAEpD,OAAO;EACH,MAAM,cAA6B;GAO/B,MAAM,IAAI,aAAa,0BAA0B,oCAAoC;GAErF,MAAM,IAAI,aAAa,YAAY,SAAS;6CACX,MAAM;;;;;;;;;;;;aAYtC;GAED,MAAM,IAAI,aAAa,8BAA8B;;qBAE5C,MAAM;aACd;GAMD,MAAM,IAAI,aAAa,uCAAuC;;qBAErD,MAAM;aACd;GAED,MAAM,IAAI,aAAa,YAAY,gBAAgB;6CAClB,aAAa;;;;;;aAM7C;GAOD,MAAM,IAAI,aAAa,YAAY,eAAe;6CACjB,YAAY;;;;;;;;aAQ5C;GASD,MAAM,CAAC,WAAW,aAAa,cAAc,MAAM,QAAQ,IAAI;IAC3D,IAAI,WAAW,KAAK;IACpB,IAAI,WAAW,YAAY;IAC3B,IAAI,WAAW,WAAW;GAC9B,CAAC;GAED,IAAI,aAAa;IAOb,MAAM,IAAI,KAAK,2BAA2B,YAAY;KAClD,MAAM,WAAW,MAAM,KACnB,eAAe,aAAa;;kDAG5B,EAAE,QAAQ,CAAC,yBAAyB,EAAE,CAC1C;KAIA,KAAK,MAAM,OAAQ,YAAY,CAAC,GAC5B,OAAO,KACH,oDAAoD,IAAI,KAAK,IAAI,IAAI,CAAC,CAAC,YAAY,EAAE,QAC7E,IAAI,OAAO,0FAEvB;IAER,CAAC;IAcD,MAAM,IAAI,KAAK,yBAAyB,YAAY;KAChD,MAAM,KACF,eAAe,aAAa;;;8DAGU,aAAa;;+BAGnD,EAAE,QAAQ,CAAC,oBAAoB,EAAE,CACrC;IACJ,CAAC;GACL;GAQA,IAAI,WACA,MAAM,IAAI,KAAK,+CACX,KAAK,uBAAuB,UAAU,WAAW,CAAC,CAAC;GAE3D,IAAI,aACA,MAAM,IAAI,KAAK,iDACX,KAAK,uBAAuB,UAAU,aAAa,CAAC,CAAC;GAK7D,IAAI,YACA,MAAM,IAAI,KAAK,oDACX,KAAK,uBAAuB,UAAU,gBAAgB,CAAC,CAAC;GAGhE,IAAI,aAAa,eAAe,YAAY;IACxC,OAAO,KAAK,yBAAyB;IACrC;GACJ;GAIA,IAAI,CAAC,aACD,OAAO,MACH,kBAAkB,aAAa,8IAEnC;GAEJ,IAAI,CAAC,WACD,OAAO,KAAK,mBAAmB,MAAM,0DAA0D;GAEnG,IAAI,CAAC,YACD,OAAO,KACH,mBAAmB,YAAY,4MAGnC;EAER;EAEA,MAAM,UAAU,OAAuC;GACnD,IAAI;IACA,MAAM,aAAa,MAAM,WAAW,KAAA,IAAY,KAAK,UAAU,MAAM,MAAM,IAAI;IAC/E,MAAM,WAAW,MAAM,KAAK,SAAS,IAAI,KAAK,UAAU,MAAM,IAAI,IAAI;IAEtE,MAAM,KACF,eAAe,MAAM;iFAErB,EAAE,QAAQ;KACN,MAAM;KACN,MAAM;KACN,MAAM;KACN,MAAM;KACN,MAAM;KACN,MAAM,SAAS;KACf;KACA;KACA,MAAM;IACV,EAAC,CACL;GACJ,SAAS,KAAK;IAEV,OAAO,MAAM,2CAA2C,MAAM,MAAM,IAAI,EAAE,OAAO,IAAI,CAAC;GAC1F;EACJ;EAEA,MAAM,UAAU,OAAe,QAAQ,IAAgC;GACnE,IAAI;IAUA,QAAO,MATY,KACf;4BACQ,MAAM;;;gCAId,EAAE,QAAQ,CAAC,OAAO,KAAK,EAAE,CAC7B,EAAA,CAEY,IAAI,aAAa;GACjC,SAAS,KAAK;IACV,OAAO,MAAM,0CAA0C,MAAM,IAAI,EAAE,OAAO,IAAI,CAAC;IAC/E,OAAO,CAAC;GACZ;EACJ;EAEA,MAAM,gBAAwG;GAC1G,MAAM,wBAAQ,IAAI,IAA8E;GAChG,IAAI;IACA,MAAM,OAAO,MAAM,KAAK;;;;;;2BAMb,MAAM;;iBAEhB;IAED,KAAK,MAAM,OAAO,MACd,MAAM,IAAI,IAAI,QAAkB;KAC5B,WAAW,IAAI;KACf,eAAe,IAAI;KACnB,WAAW,IAAI,cAAc,IAAI,KAAK,IAAI,WAAqB,CAAC,CAAC,YAAY,IAAI,KAAA;IACrF,CAAC;GAET,SAAS,KAAK;IACV,OAAO,MAAM,0CAA0C,EAAE,OAAO,IAAI,CAAC;GACzE;GACA,OAAO;EACX;EAEA,MAAM,YAAY,OAAe,MAAgC;GAC7D,IAAI;IAQA,QAAO,MAPY,KACf,eAAe,aAAa;;;wCAI5B,EAAE,QAAQ,CAAC,OAAO,IAAI,EAAE,CAC5B,EAAA,CACY,SAAS;GACzB,SAAS,KAAK;IACV,IAAI,kBAAkB,GAAG,GAErB,OAAO;IAMX,MAAM;GACV;EACJ;EAEA,MAAM,oBAAoB,OAAe,SAAyB,WAAmC;GACjG,MAAM,KACF,eAAe,YAAY;;;4GAI3B,EAAE,QAAQ;IAAC;IAAO;IAAS,aAAa;GAAI,EAAE,CAClD;EACJ;EAEA,MAAM,eAAe,QAAwE;GACzF,MAAM,yBAAS,IAAI,IAAmC;GACtD,IAAI,OAAO,WAAW,GAAG,OAAO;GAChC,MAAM,OAAO,MAAM,KACf;;wBAEQ,YAAY;oCACA,aAAa,OAAO,MAAM,EAAE,IAChD,EAAE,QAAQ,CAAC,GAAG,MAAM,EAAE,CAC1B;GACA,KAAK,MAAM,OAAO,QAAQ,CAAC,GACvB,OAAO,IAAI,OAAO,IAAI,MAAM,GAAG;IAC3B,SAAS,OAAO,IAAI,YAAY,YAAY,IAAI,UAAU;IAC1D,WAAW,OAAO,IAAI,eAAe,WAAW,IAAI,aAAa,KAAA;GACrE,CAAC;GAEL,OAAO;EACX;EAEA,MAAM,mBAAmB,OAAe,QAAgB,YAA2C;GAqB/F,MAAM,OAAM,MAdO,KACf;mCACmB,YAAY;;;;;;;;kDAQG,YAAY;kFAE9C,EAAE,QAAQ;IAAC;IAAO;IAAY;GAAM,EAAE,CAC1C,EAAA,GACmB;GACnB,IAAI,CAAC,KACD,MAAM,IAAI,MAAM,sBAAsB,MAAM,kCAAkC;GAElF,IAAI,IAAI,aAAa,MAAM,OAAO,EAAE,UAAU,KAAK;GACnD,OAAO;IACH,UAAU;IACV,QAAQ,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS,KAAA;GAC1D;EACJ;EAEA,MAAM,gBAAgB,OAAe,QAA+B;GAChE,MAAM,KACF,UAAU,YAAY;;yDAGtB,EAAE,QAAQ,CAAC,OAAO,MAAM,EAAE,CAC9B;EACJ;EAEA,MAAM,kBAAkB,QAAoE;GACxF,MAAM,4BAAY,IAAI,IAA+B;GACrD,IAAI,OAAO,WAAW,GAAG,OAAO;GAKhC,MAAM,OAAO,MAAM,KACf;qDACqC,MAAM;qDACN,MAAM;;gCAE3B,OAAO,KAAK,GAAG,MAAM,KAAK,IAAI,EAAE,QAAQ,CAAC,CAAC,KAAK,IAAI,EAAE;;;4BAGzD,MAAM;;;;qCAKlB,EAAE,QAAQ,CAAC,GAAG,MAAM,EAAE,CAC1B;GACA,KAAK,MAAM,OAAO,QAAQ,CAAC,GAAG;IAC1B,MAAM,UAA6B;KAC/B,WAAW,OAAO,IAAI,cAAc,CAAC;KACrC,eAAe,OAAO,IAAI,kBAAkB,CAAC;IACjD;IACA,MAAM,YAAY,YAAY,IAAI,UAAU;IAC5C,IAAI,WAAW;KACX,QAAQ,YAAY;KACpB,QAAQ,iBAAiB,OAAO,IAAI,WAAW;KAC/C,QAAQ,cAAc,IAAI,YAAY;KACtC,IAAI,OAAO,IAAI,UAAU,UAAU,QAAQ,YAAY,IAAI;IAC/D;IACA,UAAU,IAAI,OAAO,IAAI,MAAM,GAAG,OAAO;GAC7C;GACA,OAAO;EACX;CACJ;AACJ;;AAKA,SAAS,YAAY,OAAoC;CACrD,IAAI,iBAAiB,MAAM,OAAO,MAAM,YAAY;CACpD,IAAI,OAAO,UAAU,YAAY,OAAO,UAAU,UAAU,OAAO,IAAI,KAAK,KAAK,CAAC,CAAC,YAAY;AAEnG;AAEA,SAAS,cAAc,KAA+C;CAClE,OAAO;EACH,OAAO,IAAI;EACX,WAAW,IAAI,KAAK,IAAI,UAAoB,CAAC,CAAC,YAAY;EAC1D,YAAY,IAAI,KAAK,IAAI,WAAqB,CAAC,CAAC,YAAY;EAC5D,YAAY,IAAI;EAChB,SAAS,IAAI;EACb,OAAQ,IAAI,SAAoB,KAAA;EAChC,QAAQ,IAAI,UAAU,KAAA;EACtB,MAAM,MAAM,QAAQ,IAAI,IAAI,IAAI,IAAI,OAAQ,IAAI,cAAc;GAAE,IAAI;IAAE,OAAO,KAAK,MAAM,IAAI,IAAc;GAAG,QAAQ;IAAE,OAAO,CAAC;GAAG;EAAE,EAAA,CAAG,IAAI,CAAC;EAC5I,QAAQ,IAAI;CAChB;AACJ"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors-DWsX4yTd.js","names":[],"sources":["../src/api/errors.ts"],"sourcesContent":["import type { Context, ErrorHandler } from \"hono\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\nimport { hostEnv } from \"../utils/host\";\n\n/**\n * A stale caller's schema stamp, as the cause of the error it explains.\n *\n * `createSchemaDriftDetector` puts the two stamps on the context when a request\n * carries an `x-rebase-schema` older than this backend's. It lives next door;\n * this half is here because `errors.ts` is in the graph of\n * `@rebasepro/server/functions` and may not reach `@rebasepro/types` at runtime.\n */\nfunction schemaDriftCause(drift: { client: string; server: string } | undefined): {\n code: string;\n message: string;\n clientSchema: string;\n serverSchema: string;\n} | undefined {\n if (!drift) return undefined;\n return {\n code: \"SCHEMA_DRIFT\",\n message:\n `This client was generated against schema ${drift.client}; this backend serves `\n + `${drift.server}. If the field named above was renamed or removed, regenerate the `\n + \"SDK (`rebase generate-sdk`) and rebuild.\",\n clientSchema: drift.client,\n serverSchema: drift.server\n };\n}\n\n/** Tracks whether we've already shown the doctor hint (once per process). */\nlet _schemaDriftHinted = false;\n\n/**\n * The schema-drift remedy, in the words that work on *this* database.\n *\n * The three copies of this message hard-coded `Run \\`pnpm db:push\\``, and on a\n * stock scaffold — where the managed PGlite database is the default — that\n * command answers `✗ rebase db push does not work on the managed development\n * database.` and exits 1. So the one instruction the server gave when a\n * developer's schema had drifted was a command their project refuses.\n *\n * Atlas plans a push by diffing against a second, empty database, and PGlite\n * serves exactly one — which is why it cannot run there, and why the remedy\n * has to know which database is under this run. There, boot applies additive\n * changes, so restarting `rebase dev` *is* the fix.\n *\n * `REBASE_DEV_DATABASE_KIND` is set by the CLI from the database it resolved,\n * the same variable `rebase schema generate`'s closing line already branches\n * on. Absent — a deployed backend, a container, anything not started by\n * `rebase dev` — the answer is the general one.\n */\nexport function schemaDriftRemedy(): { short: string; lines: string[] } {\n if (hostEnv().REBASE_DEV_DATABASE_KIND === \"managed\") {\n return {\n short: \"Restart `rebase dev` — boot applies additive schema changes to the managed database.\",\n lines: [\n \" Quick fixes (managed development database):\",\n \" restart `rebase dev` boot applies additive changes\",\n \" rebase doctor full 3-way drift report\",\n \"\",\n \" `rebase db push` does not run here: Atlas plans against a\",\n \" second, empty database and PGlite serves one. For a change\",\n \" boot leaves alone, use your own Postgres (DATABASE_URL)\",\n \" or `rebase dev --docker`.\"\n ]\n };\n }\n\n return {\n short: \"Run `rebase db push` to sync your schema, or `rebase db migrate` to apply pending migrations.\",\n lines: [\n \" Quick fixes (local dev, against DATABASE_URL):\",\n \" rebase db push sync schema to database (dev)\",\n \" rebase db migrate apply pending migrations (prod)\",\n \" rebase doctor full 3-way drift report\",\n \"\",\n \" Managed cloud: the runtime applies schema + RLS at boot\",\n \" (REBASE_MIGRATE_ON_BOOT); redeploy rather than db push,\",\n \" which cannot reach the tenant database.\"\n ]\n };\n}\n\n/** Shape of Postgres / network errors with diagnostic codes */\ninterface PgLikeError {\n code?: string;\n address?: string;\n port?: number;\n message?: string;\n table?: string;\n column?: string;\n schema?: string;\n detail?: string;\n hint?: string;\n constraint?: string;\n}\n\n/** 5-character SQLSTATE, e.g. `42501`, `23505`. */\nconst SQLSTATE_RE = /^[0-9A-Z]{5}$/;\n\n/**\n * What SQLSTATE 25006 means here, in the only terms that help the author fix it.\n *\n * Every request-scoped read runs `withTransaction(..., { accessMode: \"read\n * only\" })`, so a write attempted anywhere under it — including from a\n * `context.data` call inside an `afterRead` callback — is refused by Postgres\n * rather than by us. The callback name is in the message because that is the\n * file the reader has to open, and nothing else on a read path can raise this.\n */\nconst READ_ONLY_TRANSACTION_MESSAGE =\n \"An `afterRead` callback tried to write. Request-scoped reads run in a READ ONLY \" +\n \"transaction, so neither the callback nor anything it calls (context.data included) \" +\n \"may write. Move the write outside the read: enqueue a background job, or use \" +\n \"`rebase.dataAsAdmin` from a job or a custom function.\";\n\n/**\n * Walk the cause chain for the underlying database error, identified by a\n * 5-char SQLSTATE `code`. Drizzle wraps the pg error in `.cause`, and route\n * code sometimes wraps drizzle again, so the real error may sit several\n * levels down.\n */\nfunction extractDbError(error: unknown, depth = 0): PgLikeError | null {\n if (!error || typeof error !== \"object\" || depth > 8) return null;\n const e = error as PgLikeError & { cause?: unknown };\n if (typeof e.code === \"string\" && SQLSTATE_RE.test(e.code)) return e;\n if (e.cause && typeof e.cause === \"object\") return extractDbError(e.cause, depth + 1);\n return null;\n}\n\n/**\n * Extract the missing table or column name from a PG error.\n * PG 42P01 messages look like: 'relation \"my_table\" does not exist'\n * PG 42703 messages look like: 'column \"my_col\" does not exist' or 'column my_table.my_col does not exist'\n */\nfunction extractMissingIdentifier(pgMessage?: string): string | null {\n if (!pgMessage) return null;\n // Match quoted identifier: relation \"xxx\" / column \"xxx\"\n const quoted = pgMessage.match(/(?:relation|column|table)\\s+\"([^\"]+)\"/i);\n if (quoted) return quoted[1];\n // Match unquoted: column table.col does not exist\n const unquoted = pgMessage.match(/(?:relation|column|table)\\s+([\\w.]+)\\s+does not exist/i);\n if (unquoted) return unquoted[1];\n return null;\n}\n\n/**\n * The sentence a `INVALID_FILTER_VALUE` answer carries.\n *\n * Built from Postgres's own wording rather than passed through, because the\n * driver hands the whole failed statement over as `error.message` and this one\n * goes to the caller in production too. What is quoted back is the type name\n * and the literal the caller themselves sent — never a table, a column list or\n * a statement.\n *\n * The three shapes are the ones a filter actually produces: an unparseable\n * literal (22P02, `?id=eq.abc`), a value outside an enum's labels (22P02 with\n * different wording), and a number past the column type's range (22003). A\n * fourth SQLSTATE in class 22 lands on the general sentence, which still says\n * the useful thing: it is the value that is wrong, not the server.\n */\nfunction describeDataException(dbError?: PgLikeError): string {\n const message = dbError?.message ?? \"\";\n const column = dbError?.column ? ` for column \"${dbError.column}\"` : \"\";\n\n const syntax = message.match(/invalid input syntax for type ([\\w ]+): \"(.*)\"/);\n if (syntax) return `\"${syntax[2]}\" is not a valid ${syntax[1]}${column}.`;\n\n const enumValue = message.match(/invalid input value for enum ([\\w.\"]+): \"(.*)\"/);\n if (enumValue) return `\"${enumValue[2]}\" is not one of the values of ${enumValue[1]}${column}.`;\n\n const range = message.match(/value \"(.*)\" is out of range for type ([\\w ]+)/);\n if (range) return `\"${range[1]}\" is out of range for ${range[2]}${column}.`;\n\n return `A value in this request could not be read as the type of the column it was compared against${column}.`;\n}\n\n/**\n * Standardized API error class.\n * Throw this from any route handler — the errorHandler middleware\n * will format it into `{ error: { message, code, details? } }`.\n */\nexport class ApiError extends Error {\n public readonly statusCode: number;\n public readonly code: string;\n public readonly details?: unknown;\n /**\n * Whether this outcome is a routine part of normal operation rather than\n * something an operator should look at. Expected errors log at debug; every\n * other operational error logs at warn.\n *\n * The motivating case is `POST /auth/refresh` with no session: clients\n * refresh on page load before they know whether one exists, so every\n * anonymous page view is a 401 — correct, and not worth a warning line.\n *\n * The other class is a caller-caused 4xx that never reached the database: a\n * mistyped filter operator, sort direction or limit, a request for a\n * collection that does not exist. Nothing on this server is wrong, and the\n * response body has already told the caller what to fix — while one client\n * holding a stale name would otherwise write a warning per request, forever,\n * until the level means nothing. See `api/rest/query-parser.ts`.\n *\n * What stays at warn is anything that says something about the *server*:\n * a schema that has drifted from the code, a permission the database\n * refused, a dependency that failed. Those are 4xx too, and they are still\n * incidents.\n */\n public readonly expected: boolean;\n\n constructor(statusCode: number, code: string, message: string, details?: unknown, expected = false) {\n super(message);\n this.name = \"ApiError\";\n this.statusCode = statusCode;\n this.code = code;\n this.details = details;\n this.expected = expected;\n }\n\n // ── Factory methods ──────────────────────────────────────────────\n\n static badRequest(message: string, code = \"BAD_REQUEST\", details?: unknown): ApiError {\n return new ApiError(400, code, message, details);\n }\n\n static unauthorized(message: string, code = \"UNAUTHORIZED\"): ApiError {\n return new ApiError(401, code, message);\n }\n\n /**\n * A 401 that is a normal outcome, not an incident — logged at debug.\n * See {@link ApiError.expected}.\n */\n static unauthenticated(message: string, code = \"UNAUTHORIZED\"): ApiError {\n return new ApiError(401, code, message, undefined, true);\n }\n\n static forbidden(message: string, code = \"FORBIDDEN\"): ApiError {\n return new ApiError(403, code, message);\n }\n\n static notFound(message: string, code = \"NOT_FOUND\"): ApiError {\n return new ApiError(404, code, message);\n }\n\n /**\n * `details` because a 409 is usually a `23505`, and the one thing the\n * caller needs is *which field* collided. The column name goes in; the\n * value never does — see `pgFieldViolations`.\n */\n static conflict(message: string, code = \"CONFLICT\", details?: unknown): ApiError {\n return new ApiError(409, code, message, details);\n }\n\n static internal(message: string, code = \"INTERNAL_ERROR\"): ApiError {\n return new ApiError(500, code, message);\n }\n\n static serviceUnavailable(message: string, code = \"SERVICE_UNAVAILABLE\"): ApiError {\n return new ApiError(503, code, message);\n }\n}\n\n/**\n * Canonical error response shape:\n * `{ error: { message: string, code: string, details?: unknown } }`\n */\nexport interface ErrorResponse {\n error: {\n message: string;\n code: string;\n details?: unknown;\n /** Request correlation ID for tracing (echoes X-Request-ID). */\n requestId?: string;\n /**\n * Why this request was going to fail whatever it asked for.\n *\n * Only `SCHEMA_DRIFT` today: the caller's `x-rebase-schema` stamp is\n * older than this backend's, so a 400 naming an unknown field is very\n * likely a rename the client has not regenerated for. The error itself\n * is unchanged — this explains it, it does not cause it.\n */\n cause?: {\n code: string;\n message: string;\n clientSchema: string;\n serverSchema: string;\n };\n };\n}\n\n/**\n * General shape of errors that flow through the API error handler.\n * Extends Error with optional HTTP status, error code, and details.\n */\nexport interface RebaseApiError extends Error {\n statusCode?: number;\n code?: string;\n details?: unknown;\n}\n\n/**\n * The answer an error chose for itself, read the same way at every door.\n *\n * @see declaredErrorAnswer\n */\nexport interface DeclaredErrorAnswer {\n /** The HTTP status the error carries. A socket frame has no slot for it. */\n status: number;\n code: string;\n message: string;\n details?: unknown;\n /** See {@link ApiError.expected}: log it at debug, not warn. */\n expected: boolean;\n}\n\n/**\n * The status, code and message an error carries as its own answer — or\n * `undefined` for an error that carries none, which is a server fault and gets\n * masked.\n *\n * Two classes carry one. The server's `ApiError`, and `RebaseApiError` (or its\n * `RebaseClientError` subclass) from `@rebasepro/types` once it has a status.\n * The second is the browser-safe class: a `config/collections/*.ts` file is\n * bundled into the admin SPA and cannot import this package, so it is what a\n * collection callback throws, and what a callback refusal becomes —\n * `callbackRefusal` returns one, and `toCallbackError` wraps anything thrown\n * that does not already carry a status.\n *\n * One function because several doors turn an error into an answer: the REST\n * error handler, the two WebSocket servers, and the Postgres realtime\n * subscriptions. Each used to list the classes it recognised by hand. The\n * sockets listed only `ApiError`, so a `beforeDelete` veto that REST\n * answered as 400 `CALLBACK_REJECTED` with the author's message reached the\n * admin panel — which writes through the socket — as `INTERNAL_ERROR`, and in\n * production as \"An unexpected error occurred\".\n *\n * Matched by name as well as `instanceof`: a monorepo can resolve two copies of\n * a package, and `instanceof` is false across them. Name matching is also why\n * this file needs no runtime import of `@rebasepro/types`, which it may not\n * have — it is in the graph of `@rebasepro/server/functions`.\n */\nexport function declaredErrorAnswer(error: unknown): DeclaredErrorAnswer | undefined {\n if (error === null || typeof error !== \"object\") return undefined;\n const e = error as { name?: unknown; message?: unknown; code?: unknown; details?: unknown; statusCode?: unknown; status?: unknown };\n\n let status: number | undefined;\n if (error instanceof ApiError || e.name === \"ApiError\") {\n status = typeof e.statusCode === \"number\" ? e.statusCode : undefined;\n } else if (typeof e.name === \"string\" && /^Rebase(Api|Client)Error$/.test(e.name)) {\n // It spells its status `status`; `statusCode` wins when both are set.\n status = typeof e.statusCode === \"number\" ? e.statusCode\n : typeof e.status === \"number\" ? e.status\n : undefined;\n // Without a status it has not chosen an answer — `RebaseClientError`\n // is also raised for plain logic errors — so it is not one here.\n if (status === undefined) return undefined;\n } else {\n return undefined;\n }\n\n return {\n status: status || 500,\n code: typeof e.code === \"string\" && e.code ? e.code : \"INTERNAL_ERROR\",\n message: typeof e.message === \"string\" ? e.message : String(e.message ?? \"\"),\n ...(e.details !== undefined && { details: e.details }),\n expected: error instanceof ApiError && error.expected\n };\n}\n\n// `isRebaseApiError` was here. It read `return error instanceof Error`, so it\n// answered yes to every error while being named and used as though it\n// discriminated — the create and update handlers guarded a \"classify this as\n// BAD_REQUEST\" branch on it, and an unreachable database was therefore reported\n// to callers as a bad request. Deleted rather than repaired: the shape it\n// claimed to test is not decidable from an `Error`, and the layer that does\n// know — the driver, which holds the SQLSTATE — raises a real `ApiError`.\n\n/**\n * Leave the code and the message where the request log will find them.\n *\n * A failed request used to produce two lines, each holding half of it: this\n * handler had the code and the diagnosis, `requestLogger` had the user, the\n * collection, the status and the latency. Correlating them meant matching on\n * the request id — which only one of them printed reliably — and the pair cost\n * twice the volume for less than one line's worth of meaning.\n */\nfunction handOffToRequestLog(c: Context<HonoEnv>, code: string, message: string): void {\n if (typeof c.set !== \"function\") return;\n c.set(\"errorSummary\", { code, message });\n}\n\n/**\n * Is a request line coming for this request?\n *\n * `requestLogger` claims it before the handler runs, so by the time an error\n * reaches here the answer is already known. When nothing claimed it — a router\n * a project mounted onto its own Hono app, a test driving `app.fetch`\n * directly — this handler stays the only thing that would report the failure,\n * so it still writes its own line. Silence is the one outcome neither half may\n * produce.\n */\nfunction requestWillBeLogged(c: Context<HonoEnv>): boolean {\n return typeof c.get === \"function\" && c.get(\"requestLogged\") === true;\n}\n\n/**\n * Hono error-handling middleware (`app.onError`).\n * Converts any error into the canonical `{ error: { message, code } }` shape.\n */\nexport const errorHandler: ErrorHandler<HonoEnv> = (err, c) => {\n // Typecast custom error properties\n const error: RebaseApiError = err;\n const reqId = typeof c.get === \"function\" ? c.get(\"requestId\") : undefined;\n\n /* A stale SDK, named on the errors it explains.\n\n 400 and 404 only: those are what a renamed or removed field produces —\n an unknown filter field is a 400, a collection gone from under its slug\n is a 404 — and they are the two a caller can act on by regenerating.\n Attaching it to a 500 would be noise, since a server fault has nothing to\n do with how old the caller's schema is. */\n const driftFor = (status: number) =>\n (status === 400 || status === 404) && typeof c.get === \"function\"\n ? schemaDriftCause(c.get(\"schemaDrift\"))\n : undefined;\n\n // An error that chose its own answer — `ApiError`, or the browser-safe\n // `RebaseApiError` a collection callback throws. The same predicate the\n // WebSocket servers use; see `declaredErrorAnswer`.\n const answer = declaredErrorAnswer(error);\n if (answer) {\n // Operational errors — log at warn, unless the error declares itself a\n // routine outcome (see ApiError.expected), which would otherwise put a\n // warning in the log for every anonymous page view.\n handOffToRequestLog(c, answer.code, answer.message);\n if (!requestWillBeLogged(c)) {\n const line = `[API] ${c.req.method} ${c.req.path} → ${answer.status} ${answer.code}: ${answer.message}` +\n (reqId ? ` [${reqId}]` : \"\");\n if (answer.expected) {\n logger.debug(line);\n } else {\n logger.warn(`⚠️ ${line}`);\n }\n }\n const apiErrorDrift = driftFor(answer.status);\n return c.json({\n error: {\n message: answer.message,\n code: answer.code,\n ...(answer.details !== undefined && { details: answer.details }),\n ...(reqId && { requestId: reqId }),\n ...(apiErrorDrift && { cause: apiErrorDrift })\n }\n } satisfies ErrorResponse, answer.status as ContentfulStatusCode);\n }\n\n let statusCode = error.statusCode || codeToStatus(error.code) || 500;\n let code = error.code || \"INTERNAL_ERROR\";\n\n // Handle DB connection and specific system errors for better logging\n let logMessage = error.message;\n\n // Resolve the actual cause — Node's net module wraps dual-stack failures\n // in an AggregateError whose inner errors carry the real address/port.\n let resolvedCause: PgLikeError | undefined;\n if (error.cause && typeof error.cause === \"object\" && error.cause !== null && \"code\" in error.cause) {\n const cause = error.cause as PgLikeError & { errors?: PgLikeError[] };\n if (cause.code === \"ECONNREFUSED\" && !cause.address && Array.isArray(cause.errors)) {\n // AggregateError — pick the first inner error that has address info\n resolvedCause = cause.errors.find(e => e.address) || cause;\n } else {\n resolvedCause = cause;\n }\n }\n\n // The real database error may sit several levels down the cause chain.\n // Losing it turns a precise failure (e.g. an RLS denial) into an opaque\n // \"Failed query: …\" 500 that is undiagnosable without direct DB access.\n const dbError = extractDbError(error);\n\n if (resolvedCause && (resolvedCause.code === \"ENETUNREACH\" || resolvedCause.code === \"ECONNREFUSED\")) {\n const cause = resolvedCause;\n if (cause.code === \"ENETUNREACH\") {\n logMessage = `Network unreachable. Cannot connect to database at ${cause.address}:${cause.port}.`;\n } else {\n logMessage = `Connection refused to database at ${cause.address}:${cause.port}. Is PostgreSQL running?`;\n }\n } else if (\"code\" in error && error.code === \"ENETUNREACH\") {\n const netErr = error as PgLikeError;\n logMessage = `Network unreachable. Cannot connect to service at ${netErr.address}:${netErr.port}.`;\n } else if (dbError && (dbError.code === \"42703\" || dbError.code === \"42P01\")) {\n code = \"SCHEMA_DRIFT\";\n const issue = dbError.code === \"42703\" ? \"column\" : \"table\";\n const identifier = dbError.table || dbError.column || extractMissingIdentifier(dbError.message) || \"unknown\";\n logMessage = `Schema drift: ${issue} \"${identifier}\" does not exist in the database. ${schemaDriftRemedy().short}`;\n } else if (dbError) {\n const parts = [`[PG ${dbError.code}] ${dbError.message}`];\n if (dbError.detail) parts.push(`Detail: ${dbError.detail}`);\n if (dbError.hint) parts.push(`Hint: ${dbError.hint}`);\n if (dbError.table) parts.push(`Table: ${dbError.table}`);\n if (dbError.column) parts.push(`Column: ${dbError.column}`);\n if (dbError.constraint) parts.push(`Constraint: ${dbError.constraint}`);\n if (dbError.code === \"42501\") {\n code = \"DB_PERMISSION_DENIED\";\n parts.push(\n \"The database rejected the statement for lack of privilege — usually a row-level \" +\n `security policy${dbError.table ? ` on \"${dbError.table}\"` : \"\"} denying this role, ` +\n \"or a stale FORCE ROW LEVEL SECURITY flag binding the owner connection.\"\n );\n }\n // 25006 read_only_sql_transaction. A request-scoped read opens its\n // transaction `READ ONLY`, so the only way to reach this is user code on\n // a read path attempting a write — which means an `afterRead` callback,\n // or something it called. Left in the generic branch it was a 500\n // \"Internal Server Error\", indistinguishable from the database being\n // down; it is the caller's own code, and it is not a server failure.\n if (dbError.code === \"25006\") {\n code = \"READ_ONLY_TRANSACTION\";\n statusCode = 409;\n parts.push(READ_ONLY_TRANSACTION_MESSAGE);\n }\n // SQLSTATE class 22 — data exception. The caller sent a value the\n // column's type cannot hold: `?id=eq.abc` on an integer key,\n // `?status=eq.nope` on an enum, a timestamp that is not one, a number\n // past the type's range. Every *other* bad query parameter already has\n // a precise 400 (`INVALID_LIMIT`, `UNKNOWN_FILTER_FIELD`,\n // `INVALID_LOGICAL_GROUP`); a bad *value* fell off the end of this\n // chain and answered 500 INTERNAL_ERROR — and in production `dbMessage`\n // is stripped, so the caller got a bare 500 naming nothing and had no\n // way to learn their own typo was the cause.\n if (dbError.code?.startsWith(\"22\")) {\n code = \"INVALID_FILTER_VALUE\";\n statusCode = 400;\n }\n logMessage = parts.join(\". \");\n }\n\n const isDbSchemaMismatch = code === \"SCHEMA_DRIFT\";\n\n // `logMessage`, not the sanitized client message: the request line is a\n // server log, and the whole point of this branch is the diagnosis it built.\n handOffToRequestLog(c, code, logMessage);\n\n if (isDbSchemaMismatch) {\n // Database schema mismatch is logged as a warning instead of a fatal error\n if (!requestWillBeLogged(c)) logger.warn(\n `⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n // In dev mode, show a one-time hint to run `rebase doctor`\n if (!_schemaDriftHinted && hostEnv().NODE_ENV !== \"production\") {\n _schemaDriftHinted = true;\n // Drawn rather than hand-aligned: the remedy inside it now varies\n // with the database, and a box whose rows were padded by hand\n // stayed straight only for the text it was written around.\n const WIDTH = 62;\n const row = (text: string) => `│${text.padEnd(WIDTH).slice(0, WIDTH)}│`;\n logger.warn([\n \"\",\n `┌${\"─\".repeat(WIDTH)}┐`,\n // One space short, deliberately: the emoji occupies two columns\n // in a terminal and one in `String.length`.\n `│${\" 💡 TIP: Run `rebase doctor` for full schema diagnostics\".padEnd(WIDTH - 1)}│`,\n row(\"\"),\n ...schemaDriftRemedy().lines.map(row),\n `└${\"─\".repeat(WIDTH)}┘`,\n \"\"\n ].join(\"\\n\"));\n }\n } else if (code === \"READ_ONLY_TRANSACTION\" || code === \"INVALID_FILTER_VALUE\") {\n // A 4xx: the application's own callback, refused — or a filter value\n // the caller's own request could not have worked with. Not a server\n // fault, so not an ❌ in the log either — and, like the drift arm above,\n // not a second line when the request log is already going to carry it.\n if (!requestWillBeLogged(c)) logger.warn(\n `⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n } else if (!requestWillBeLogged(c)) {\n // Unexpected errors — log at error level\n logger.error(\n `❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n }\n\n // Suppress the huge stack trace for known DB errors: it is noisy, and the\n // extracted [PG …] line above carries the signal. The SQL and the bound\n // params it used to leak are no longer this branch's problem — `logger`\n // strips Drizzle's `Failed query: … / params: …` wrapper out of every\n // message and stack it emits, so the fallbacks below (a connection dropped\n // mid-statement carries no SQLSTATE, so `dbError` is null and the stack is\n // logged) are covered too.\n const suppressStack = isDbSchemaMismatch || dbError !== null || (statusCode < 500 && code === \"BAD_REQUEST\");\n if (!suppressStack) {\n // The error goes in as a value, not as `String(error.stack)`. A string\n // is a leaf to the logger: `serialiseError` — the `.cause`/\n // `AggregateError` walker the boot path relies on — never runs on one,\n // so the request path used to print the outer wrapper's stack and drop\n // the sentence that says what actually failed (`connect ECONNRESET`,\n // sitting two `.cause` links down). Structured, it walks the chain and\n // redacts each link on the way.\n logger.error(\"unhandled request error\", { error });\n }\n\n // Sanitize the message for the client to prevent leaking sensitive details\n // like SQL queries or internal IP addresses.\n let clientMessage = \"An unexpected error occurred\";\n if (code === \"READ_ONLY_TRANSACTION\") {\n // Ahead of the generic 4xx arm below, which would echo the raw driver\n // message (\"Failed query: insert into …\") back to the caller.\n clientMessage = READ_ONLY_TRANSACTION_MESSAGE;\n } else if (code === \"INVALID_FILTER_VALUE\") {\n // Also ahead of the 4xx arm: `error.message` here is the driver's\n // \"Failed query: select … / params: …\", which is both unhelpful and the\n // one thing this envelope must never carry.\n clientMessage = describeDataException(dbError || (error as PgLikeError));\n } else if (statusCode < 500 && error.message) {\n // If it's a 4xx error (e.g. from validation), it's generally safe to send the message\n clientMessage = error.message;\n } else if (error instanceof ApiError || error.name === \"ApiError\") {\n // We already handled ApiError above, but just in case\n clientMessage = error.message;\n } else if (code === \"SCHEMA_DRIFT\") {\n const pgErr = dbError || (error as PgLikeError);\n const issue = pgErr.code === \"42703\" ? \"column\" : \"table\";\n const identifier = pgErr.table || pgErr.column || extractMissingIdentifier(pgErr.message || error.message) || \"unknown\";\n clientMessage = `Schema drift: ${issue} \"${identifier}\" does not exist. ${schemaDriftRemedy().short}`;\n } else if (code === \"DB_PERMISSION_DENIED\") {\n clientMessage = `Permission denied by the database${dbError?.table ? ` on \"${dbError.table}\"` : \"\"} (row-level security). Check the RLS policies for this table.`;\n } else if (code === \"INTERNAL_ERROR\") {\n clientMessage = \"Internal Server Error\";\n }\n\n // Database diagnostics for the envelope: the SQLSTATE is always safe to\n // return; message/detail/hint can reference schema internals, so only\n // outside production.\n const dbDetails = dbError ? {\n dbCode: dbError.code,\n ...(hostEnv().NODE_ENV !== \"production\" && {\n dbMessage: dbError.message,\n ...(dbError.detail && { detail: dbError.detail }),\n ...(dbError.hint && { hint: dbError.hint })\n })\n } : undefined;\n\n const drift = driftFor(statusCode);\n\n return c.json({\n error: {\n message: clientMessage,\n code,\n ...(error.details !== undefined\n ? { details: error.details }\n : dbDetails !== undefined ? { details: dbDetails } : {}),\n ...(reqId && { requestId: reqId }),\n ...(drift && { cause: drift })\n }\n } satisfies ErrorResponse, statusCode as ContentfulStatusCode);\n};\n\n/**\n * Map known error codes to HTTP status codes.\n */\nfunction codeToStatus(code?: string): number | undefined {\n if (!code) return undefined;\n const map: Record<string, number> = {\n BAD_REQUEST: 400,\n INVALID_INPUT: 400,\n WEAK_PASSWORD: 400,\n UNAUTHORIZED: 401,\n INVALID_CREDENTIALS: 401,\n INVALID_TOKEN: 401,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n CONFLICT: 409,\n EMAIL_EXISTS: 409,\n ROLE_EXISTS: 409,\n READ_ONLY_TRANSACTION: 409,\n INVALID_FILTER_VALUE: 400,\n SCHEMA_DRIFT: 500,\n DB_PERMISSION_DENIED: 500,\n INTERNAL_ERROR: 500,\n NOT_CONFIGURED: 503,\n SERVICE_UNAVAILABLE: 503\n };\n return map[code];\n}\n\n\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"function-routes-Chet4-lB.js","names":[],"sources":["../src/functions/context.ts","../src/functions/guards.ts","../src/functions/function-routes.ts"],"sourcesContent":["/**\n * Reading the request context from inside a custom function.\n *\n * The functions router resolves the caller's identity before any handler runs\n * and leaves the result on the Hono context. Getting it back out used to be the\n * user's problem, and the shape made that worse than it sounds: `HonoEnv`\n * types `user` as `AuthResult`, a union that includes `boolean`, `null` and an\n * index signature, because the same slot is filled by four different middlewares\n * — JWT, service key, API key, and a user-supplied validator that may return\n * `true`. Every example in the documentation therefore opened with\n *\n * const user = c.get(\"user\") as { uid: string; roles?: string[] } | undefined;\n *\n * and an assertion in a security-relevant position is exactly the kind of line\n * that gets copied once and then never re-examined. It is also wrong in one\n * case that occurs in practice: a custom validator returning `true` stores\n * `{ uid: \"default\", roles: [] }`, which the assertion above types as having a\n * `uid` — true here, but nothing checks it.\n *\n * These accessors do the narrowing once, in the framework, where it can be\n * tested. They are also **runtime-neutral by construction** — no crypto, no\n * token parsing, no I/O, nothing but property reads on an object another\n * middleware already populated. That is what lets them live in\n * `@rebasepro/server/functions` and run unchanged on a host that has no Node\n * built-ins.\n *\n * @module\n */\nimport type { Context } from \"hono\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport type { ApiKeyMasked } from \"../auth/api-keys/api-key-types\";\nimport { hasAdministrativeRole } from \"../auth/admin-roles\";\n\n/**\n * The caller, as a custom function sees them.\n *\n * A narrowed view of whatever the auth middleware resolved: `uid` and `roles`\n * are guaranteed, and the index signature keeps any extra claims the token or\n * the adapter carried (`email`, `org_id`, anything a custom validator added)\n * reachable without a cast.\n */\nexport interface FunctionUser {\n /** Stable id of the caller. `\"service\"` for service-key and API-key callers. */\n uid: string;\n /** Roles as resolved for this request. Never `undefined` — an empty array instead. */\n roles: string[];\n /** Present when the identity carried one. Not every auth method does. */\n email?: string;\n /** Any further claim the token, adapter or validator supplied. */\n [claim: string]: unknown;\n}\n\n/** Anything with a Hono-style `.get`, so these work on any `Context` shape. */\ntype CtxLike = Context<HonoEnv> | Context;\n\nfunction read<K extends keyof HonoEnv[\"Variables\"]>(\n c: CtxLike,\n key: K\n): HonoEnv[\"Variables\"][K] | undefined {\n // `c.get` is typed against the app's own Env, which a handler mounted\n // through `app.route()` may have declared more loosely. The cast is\n // confined to this one function rather than repeated at every call site.\n return (c as Context<HonoEnv>).get(key);\n}\n\n/**\n * The authenticated caller, or `undefined` for an anonymous request.\n *\n * **`undefined` is not a permission decision.** The functions router mounts its\n * auth middleware with `requireAuth: false` on purpose — a webhook receiver has\n * no token to send — so an anonymous caller reaches the handler and reads\n * `undefined` here while the handler runs on regardless. Use {@link requireAuth}\n * (or a `!user` branch that returns 401) to make it a decision.\n *\n * A caller who presented a *bad* token never gets this far: both auth\n * middlewares reject an unverifiable token with 401 before the router is\n * reached, precisely so an expired session cannot be silently downgraded to an\n * anonymous one.\n */\nexport function getUser(c: CtxLike): FunctionUser | undefined {\n const raw = read(c, \"user\");\n if (!raw || typeof raw !== \"object\") return undefined;\n\n const record = raw as Record<string, unknown>;\n const uid = typeof record.uid === \"string\" ? record.uid : undefined;\n if (uid === undefined) return undefined;\n\n const roles = Array.isArray(record.roles)\n ? record.roles.filter((role): role is string => typeof role === \"string\")\n : [];\n\n return { ...record,\n uid,\n roles } as FunctionUser;\n}\n\n/** The caller's id, or `undefined` when nobody is signed in. */\nexport function getUserId(c: CtxLike): string | undefined {\n return getUser(c)?.uid;\n}\n\n/** The caller's roles. Empty for an anonymous request — never `undefined`. */\nexport function getRoles(c: CtxLike): string[] {\n return getUser(c)?.roles ?? [];\n}\n\n/**\n * Whether the caller holds **any** of the named roles.\n *\n * Any rather than all, because that is what a route guard means by a list of\n * roles; require several by calling this more than once.\n */\nexport function hasRole(c: CtxLike, ...roles: string[]): boolean {\n if (roles.length === 0) return false;\n const held = new Set(getRoles(c));\n return roles.some(role => held.has(role));\n}\n\n/**\n * Whether the caller holds an administrative role.\n *\n * Delegates to the single definition in `auth/admin-roles.ts` — which is\n * `admin` **or** `schema-admin` — rather than comparing against `\"admin\"`.\n * Those two lists disagreed once, and the gap made every public registrant an\n * administrator; see that file.\n */\nexport function isAdmin(c: CtxLike): boolean {\n return hasAdministrativeRole(getRoles(c));\n}\n\n/** Whether the request carries an identity at all. */\nexport function isAuthenticated(c: CtxLike): boolean {\n return getUser(c) !== undefined;\n}\n\n/**\n * The request-scoped data driver: reads and writes run as **the caller**, with\n * your row-level security policies evaluated against their identity.\n *\n * This is the accessor to reach for when a function serves user-facing data.\n * `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs\n * as `{ uid: \"service\", roles: [\"admin\"] }` for every caller alike, which is\n * correct for trusted background work and wrong for a request.\n *\n * `undefined` only when no Rebase auth middleware ran (see\n * {@link identityResolved}); inside a function mounted by the framework it is\n * always present, anonymous requests included — they get an anon-scoped driver\n * so policies still have an identity to evaluate.\n */\nexport function getDriver(c: CtxLike): DataDriver | undefined {\n return read(c, \"driver\");\n}\n\n/**\n * {@link getDriver}, but throws instead of handing back `undefined`.\n *\n * For the common case where a handler cannot proceed without it and would\n * otherwise write `c.get(\"driver\")!` — an assertion that turns a wiring problem\n * into `Cannot read properties of undefined (reading 'fetchCollection')` twenty\n * lines away from the cause.\n */\nexport function requireDriver(c: CtxLike): DataDriver {\n const driver = getDriver(c);\n if (!driver) {\n throw new Error(\n \"No request-scoped driver on this context. A Rebase auth middleware \" +\n \"populates it before any custom function runs, so this means the handler \" +\n \"was mounted outside the functions router — e.g. added to your own Hono \" +\n \"app directly. Mount it from the functions directory, or use \" +\n \"`rebase.dataAsAdmin` if the work is genuinely service-scoped.\"\n );\n }\n return driver;\n}\n\n/**\n * The API key this request authenticated with, masked, or `undefined` when it\n * did not use one.\n *\n * Useful for attribution and for per-key behaviour. The permission check itself\n * has already happened — reaching a handler means the key was allowed to.\n */\nexport function getApiKey(c: CtxLike): ApiKeyMasked | undefined {\n return read(c, \"apiKey\");\n}\n\n/**\n * The correlation id for this request — generated, or taken from an inbound\n * `X-Request-ID`.\n *\n * Log it. It is the only thing that ties a line written inside a function to\n * the framework's own lines for the same request.\n */\nexport function getRequestId(c: CtxLike): string | undefined {\n return read(c, \"requestId\");\n}\n\n/**\n * Whether a Rebase auth middleware has run on this request.\n *\n * Both middlewares populate `driver` for *every* outcome, anonymous included,\n * and populate `user` whenever there is one. So \"neither is set\" does not mean\n * \"anonymous\" — it means nothing resolved the identity, and treating that as\n * anonymous is the dangerous reading. The guards use this to tell a genuinely\n * anonymous caller (401) from a misconfigured mount (500), because answering\n * 401 to the second sends whoever is debugging it to look at the token.\n */\nexport function identityResolved(c: CtxLike): boolean {\n return read(c, \"user\") !== undefined || read(c, \"driver\") !== undefined;\n}\n","/**\n * Route guards for custom functions.\n *\n * These decide access from the identity the platform already resolved. They do\n * **not** verify tokens, and that division is the point rather than a\n * limitation:\n *\n * - Verifying a token needs a signing key, constant-time comparison and a\n * revocation lookup. That is host work, it belongs to the process that holds\n * the secret, and it is the part of the stack that cannot be made\n * runtime-neutral without rewriting it against WebCrypto.\n * - Deciding whether *this* caller may call *this* route is application work.\n * It needs nothing but the resolved identity, so it costs nothing to make it\n * portable — and it is the half that lives in user code.\n *\n * Splitting there is what lets a function file compile and run unchanged on a\n * host with no Node built-ins, and it is why these live in\n * `@rebasepro/server/functions` while `verifyAccessToken` does not.\n *\n * **Inside the functions router these are equivalent to the guards exported\n * from the package root.** Both auth middlewares resolve the identity before\n * any handler runs: a valid credential populates `user`, an invalid one is\n * rejected with 401 by the middleware itself, and a missing one leaves `user`\n * unset. So the root `requireAuth`'s token-parsing branch is unreachable from a\n * function, and removing it changes no outcome. The one difference is a handler\n * mounted **outside** the framework's router, where no middleware ran: the root\n * guard would parse the `Authorization` header itself, and these refuse the\n * request with a 500 that names the wiring problem. Fail-closed, and legible.\n *\n * @module\n */\nimport type { MiddlewareHandler } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { getUser, isAdmin, getRoles, identityResolved } from \"./context\";\n\n/**\n * The answer to \"a guard ran, but no middleware had resolved anything\".\n *\n * Deliberately a 500 and not a 401. A 401 tells the caller their credential is\n * the problem, and here the caller's credential was never looked at — sending\n * them to check their token is sending them to the one place the answer is not.\n */\nfunction unresolvedIdentity(): { error: { message: string; code: string } } {\n return {\n error: {\n message:\n \"This route's identity was never resolved: no Rebase auth middleware ran \" +\n \"before the guard. A function loaded from the functions directory always \" +\n \"has one. This usually means the Hono app was mounted onto your own \" +\n \"server directly, bypassing the functions router.\",\n code: \"AUTH_MIDDLEWARE_MISSING\"\n }\n };\n}\n\n/**\n * Reject anonymous callers with 401.\n *\n * Put it in the route's own middleware slot rather than `app.use(\"/*\", …)`:\n * `use()` covers only the routes declared *below* it, so a route appended later\n * — by you, months from now, at the bottom of the file — is silently\n * unprotected. The per-route form cannot drift that way.\n *\n * @example\n * ```ts\n * app.post(\"/\", requireAuth, async (c) => {\n * const user = getUser(c)!; // guaranteed by the guard\n * return c.json({ uid: user.uid });\n * });\n * ```\n */\nexport const requireAuth: MiddlewareHandler<HonoEnv> = async (c, next) => {\n if (getUser(c)) return next();\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n};\n\n/**\n * Reject callers without an administrative role with 403.\n *\n * Must come **after** {@link requireAuth}: on its own it answers 401 for an\n * anonymous caller, which is right, but pairing them keeps the two failures\n * distinguishable — 401 \"who are you\", 403 \"not you\".\n *\n * Administrative means `admin` or `schema-admin`, from the single list in\n * `auth/admin-roles.ts`. Do not compare against `\"admin\"` by hand; that is the\n * divergence that list exists to prevent.\n */\nexport const requireAdmin: MiddlewareHandler<HonoEnv> = async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!isAdmin(c)) {\n return c.json({\n error: {\n message: \"Admin privileges required for this operation\",\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n};\n\n/**\n * Reject callers holding none of the named roles with 403.\n *\n * Any of them, not all — require several by chaining the guard twice. Naming no\n * role at all is a programming error and throws at module load rather than at\n * request time, because `requireRole()` with an empty list would otherwise read\n * as a guard while admitting everyone.\n *\n * @example\n * ```ts\n * app.post(\"/publish\", requireAuth, requireRole(\"editor\", \"admin\"), handler);\n * ```\n */\nexport function requireRole(...roles: string[]): MiddlewareHandler<HonoEnv> {\n if (roles.length === 0) {\n throw new Error(\n \"requireRole() needs at least one role. An empty list would admit every \" +\n \"signed-in caller while reading as a restriction.\"\n );\n }\n\n const allowed = new Set(roles);\n return async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!getRoles(c).some(role => allowed.has(role))) {\n return c.json({\n error: {\n message: `This operation requires one of these roles: ${roles.join(\", \")}`,\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n };\n}\n","import { Hono } from \"hono\";\nimport { HonoEnv } from \"../api/types\";\nimport { ApiError } from \"../api/errors\";\nimport { LoadedFunction } from \"./function-loader\";\nimport { requireAuth } from \"./guards\";\n\n/** The file a loader problem names, without its extension: `broken.ts (threw: …)` → `broken`. */\nfunction problemName(problem: string): string {\n return problem.split(\" \")[0].replace(/\\/$/, \"\").replace(/\\.[cm]?[jt]s$/, \"\");\n}\n\n/**\n * Mount all loaded function routes under a single Hono router.\n *\n * Each function is mounted at `/<function-name>`, preserving\n * whatever HTTP methods and middleware the Hono sub-app defines.\n *\n * @param functions What loaded. May be empty — the router still mounts, so\n * \"no functions are served\" answers 200 with an empty list instead of 404.\n * @param problems The files the loader saw and could not serve, as\n * `\"<file> (<reason>)\"`. The listing reports a count and a pointer to the log,\n * not the reasons: those carry import errors, and the listing is one guard\n * away from anyone. The unmatched-route handler uses the *names* to answer the\n * one question a 404 on a function nobody can find should answer — \"there is a\n * file for this and it did not load\" — which is the difference between a typo\n * and a broken deploy, and the loader was the only thing that knew.\n */\nexport function createFunctionRoutes(\n functions: LoadedFunction[],\n problems: string[] = [],\n /**\n * Where this router is mounted, so the listing can report a path a caller\n * can actually request. It used to hardcode `/functions/<name>`, which is\n * wrong under every `basePath` including the default `/api`.\n */\n mountPath = \"/functions\"\n): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n const skipped = problems.length;\n\n // Listing endpoint: GET / → list available functions.\n //\n // Functions themselves stay anonymous-callable by default — a webhook\n // receiver has to be — but the index of them does not: it is an inventory\n // of every custom endpoint, for whoever asks. `requireAuth` admits any\n // resolved identity (a signed-in user, an API key, the service key), so\n // `rebase doctor` and `rebase cloud debug` keep their answer; the latter\n // already reads a 401 here as \"mounted\".\n router.get(\"/\", requireAuth, (c) => {\n return c.json({\n functions: functions.map((fn) => ({\n name: fn.name,\n endpoint: `${mountPath}/${fn.name}`\n })),\n ...(skipped > 0 && {\n skipped,\n note: `${skipped} function file(s) failed to load and are NOT served — see the server log for the reason.`\n })\n });\n });\n\n for (const fn of functions) {\n router.route(`/${fn.name}`, fn.app);\n }\n\n // A name that matches nothing, answered in the envelope.\n //\n // Registered last so every real route wins it. Without it, a typo'd\n // function name — the single most likely 404 a developer meets on this\n // surface — fell through to Hono's default `404 Not Found` as `text/plain`,\n // and through the SDK arrived as `RebaseApiError { code: undefined }`, so\n // the documented `e.code === \"FUNCTION_NOT_FOUND\"` branch never ran.\n //\n // What the message may say depends on who is asking. The mounted names are\n // an inventory of every custom endpoint, which is exactly what the listing\n // above requires an identity to see — so an anonymous caller is told their\n // name is unknown and nothing more, and a resolved caller gets the list\n // that turns the 404 into a fix.\n const mounted = new Set(functions.map(fn => fn.name));\n const failedToLoad = new Map(problems.map(p => [problemName(p), p.split(\" \")[0]]));\n\n router.all(\"/:name{.*}\", (c): never => {\n const requested = (c.req.param(\"name\") ?? \"\").split(\"/\").filter(Boolean);\n const name = requested[0] ?? \"\";\n const rest = requested.slice(1).join(\"/\");\n const identified = Boolean(c.get(\"user\"));\n\n const refuse = (message: string): never => {\n throw new ApiError(404, \"FUNCTION_NOT_FOUND\", message, { function: name }, true);\n };\n\n if (!name) {\n refuse(`No function in the request path. Expected ${mountPath}/<function>.`);\n }\n if (mounted.has(name)) {\n refuse(\n `The function '${name}' is served, and has no ${c.req.method} route at '/${rest}'. ` +\n \"The path after the function name is routed by the function's own Hono app.\"\n );\n }\n const file = failedToLoad.get(name);\n if (file) {\n refuse(\n `The function '${name}' is not served: '${file}' failed to load. ` +\n \"The server log records why, at boot.\"\n );\n }\n return refuse(\n `No function named '${name}' on this backend.` +\n (identified\n ? (mounted.size > 0\n ? ` This backend serves: ${[...mounted].sort().join(\", \")}.`\n : \" This backend serves no functions.\")\n : \"\") +\n (skipped > 0 && identified\n ? ` ${skipped} function file(s) failed to load and are not served — see the server log.`\n : \"\")\n );\n });\n\n return router;\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"logger-DO2PZc4i.js","names":[],"sources":["../src/utils/host.ts","../src/utils/logger.ts"],"sourcesContent":["/**\n * The host globals this package reads, behind functions that do not assume Node.\n *\n * `process` is not defined on workerd, on Deno Deploy without the compat flag,\n * or in a browser. That matters here for one specific reason: the portable\n * authoring surface (`@rebasepro/server/functions`) reaches the logger and the\n * error handler, and a bare `process.env.NODE_ENV` inside either of them turns\n * the first log line of a request into a `ReferenceError` on a runtime that has\n * no `process` — a failure that reads as \"the framework crashed\" rather than\n * \"this runtime has no process object\".\n *\n * Nothing here throws and nothing here is async. A runtime that cannot answer\n * gets the empty answer, because every caller in this file's blast radius is\n * choosing a log level or a format, and the safe default for both is the\n * development one.\n *\n * @module\n */\n\n/**\n * Where an adapter with no `process` can publish the environment.\n *\n * Cloudflare Workers hand the environment to the *request*, not to the module,\n * so there is no global to read at import time. An edge adapter that has\n * already seen a request can stash the bag here and every contextless reader in\n * the framework — the logger, chiefly — starts answering correctly.\n *\n * `Symbol.for` rather than a module-local for the same reason the singleton\n * uses it: more than one copy of this module can be loaded into one process,\n * and a module-local would leave every copy but the writer's blind. See\n * `../singleton.ts`.\n *\n * Request-scoped code should NOT read this. Use `getEnv(c)` from\n * `@rebasepro/server/functions`, which reads the binding attached to the\n * request it is serving — the only correct source on a runtime where two\n * concurrent requests can carry different bindings.\n */\nconst ENV_SLOT = Symbol.for(\"@rebasepro/server:host-env\");\n\ntype GlobalWithEnv = typeof globalThis & {\n [ENV_SLOT]?: Record<string, string | undefined>;\n process?: { env?: Record<string, string | undefined> };\n};\n\n/**\n * The process environment, or the closest thing this runtime has to one.\n *\n * Order: a bag published by {@link setHostEnv} first, because an adapter that\n * set one knows more than the ambient globals do; then `process.env`; then\n * nothing.\n */\nexport function hostEnv(): Record<string, string | undefined> {\n const global = globalThis as GlobalWithEnv;\n return global[ENV_SLOT] ?? global.process?.env ?? {};\n}\n\n/**\n * Read one environment variable without touching `process` directly.\n *\n * Trimmed, and blank is treated as absent — a variable declared with no value\n * is the ordinary way to write a compose file or a `.env` line, and every\n * caller in this package means \"unset\" by it. See `resolveFunctionsTimeoutMs`,\n * which learned that the hard way.\n */\nexport function hostEnvVar(name: string): string | undefined {\n const raw = hostEnv()[name];\n if (typeof raw !== \"string\") return undefined;\n const trimmed = raw.trim();\n return trimmed === \"\" ? undefined : trimmed;\n}\n\n/**\n * Publish an environment bag for contextless readers.\n *\n * Called by an adapter for a runtime whose environment is not ambient. Merges\n * rather than replaces, so two adapters (or an adapter plus a test) do not\n * silently erase each other's variables.\n */\nexport function setHostEnv(env: Record<string, string | undefined>): void {\n const global = globalThis as GlobalWithEnv;\n global[ENV_SLOT] = {\n ...(global[ENV_SLOT] ?? {}),\n ...env\n };\n}\n\n/** @internal Test seam — drops anything {@link setHostEnv} published. */\nexport function _clearHostEnv(): void {\n delete (globalThis as GlobalWithEnv)[ENV_SLOT];\n}\n\ntype GlobalWithStdio = typeof globalThis & {\n process?: {\n stdout?: { write?: (chunk: string) => unknown };\n stderr?: { write?: (chunk: string) => unknown };\n };\n};\n\n/**\n * Write one already-formatted line to the process's output.\n *\n * `process.stdout.write` is preferred where it exists because it is the only\n * one of the two that does not append its own formatting to a line that is\n * already a complete JSON document — `console.log` on Node is\n * `process.stdout.write` plus `util.format`, and `util.format` will happily\n * reinterpret a `%s` that appeared inside a user's log message.\n *\n * Where it does not exist, `console` is the runtime's log sink and is what its\n * platform collects.\n */\nexport function writeLine(stream: \"out\" | \"err\", line: string): void {\n const proc = (globalThis as GlobalWithStdio).process;\n const sink = stream === \"err\" ? proc?.stderr : proc?.stdout;\n if (typeof sink?.write === \"function\") {\n sink.write(line + \"\\n\");\n return;\n }\n if (stream === \"err\") console.error(line);\n else console.log(line);\n}\n","/**\n * Structured Logger for Rebase Backend\n *\n * Outputs JSON lines when `NODE_ENV=production`, human-readable prefixed\n * lines otherwise. Designed to work with Google Cloud Logging severity levels.\n *\n * Every line — message and data, at any depth — passes through the redaction\n * below, which strips Drizzle's `Failed query: … / params: …` wrapper and the\n * values of secret-looking keys. See the block above `serialiseError`.\n *\n * Usage:\n * import { logger } from \"./utils/logger\";\n * logger.info(\"Server started\", { port: 3001 });\n * logger.error(\"Request failed\", { path: \"/api/test\", error: err });\n *\n * Every host global goes through `./host`, and that is load-bearing rather than\n * tidy: this module is reachable from `@rebasepro/server/functions`, the\n * authoring surface that has to import cleanly on a runtime with no `process`.\n * A bare `process.env.NODE_ENV` here would make the first log line of the first\n * request on workerd a `ReferenceError`.\n */\nimport { hostEnv, writeLine } from \"./host\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\n/** Google Cloud Logging severity strings. */\nconst GCP_SEVERITY: Record<LogLevel, string> = {\n debug: \"DEBUG\",\n info: \"INFO\",\n warn: \"WARNING\",\n error: \"ERROR\"\n};\n\nconst LOG_PRIORITY: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3\n};\n\nexport interface LogEntry {\n severity: string;\n message: string;\n timestamp: string;\n [key: string]: unknown;\n}\n\nexport interface Logger {\n debug(message: string, data?: Record<string, unknown>): void;\n info(message: string, data?: Record<string, unknown>): void;\n warn(message: string, data?: Record<string, unknown>): void;\n error(message: string, data?: Record<string, unknown>): void;\n child(defaultFields: Record<string, unknown>): Logger;\n}\n\nfunction isProduction(): boolean {\n return hostEnv().NODE_ENV === \"production\";\n}\n\n/**\n * An explicit level from `config.logging.level`, when a project set one.\n *\n * Outranks `LOG_LEVEL` because it is the more specific statement: an\n * environment variable is the deployment's default, and this is the\n * application saying what it wants regardless of where it runs.\n *\n * There used to be a second, separate mechanism for this — `utils/logging.ts`\n * reassigned `console.debug`/`console.log`/`console.warn` to no-ops — and the\n * two disagreed in a way nobody could have guessed from either: `LOG_LEVEL=warn`\n * silenced this logger's info lines *and* every `console.log` in the process,\n * including a dependency's, including a project's own debugging. It also could\n * not be undone, because the originals were gone.\n */\nlet configuredLevel: LogLevel | undefined;\n\n/**\n * Set the level from configuration. `undefined` returns to `LOG_LEVEL`.\n *\n * Read per line rather than captured at construction, so a logger created\n * before configuration is read still honours it — which the singleton below\n * always is.\n */\nexport function setLogLevel(level?: LogLevel): void {\n configuredLevel = level;\n}\n\nfunction getMinLevel(): LogLevel {\n if (configuredLevel) return configuredLevel;\n const env = (hostEnv().LOG_LEVEL || \"info\").toLowerCase();\n if (env in LOG_PRIORITY) return env as LogLevel;\n return \"info\";\n}\n\n// ── Redaction ───────────────────────────────────────────────────────\n//\n// Drizzle builds every query failure as\n// `Failed query: ${query}\\nparams: ${params}` (drizzle-orm/errors.js), so the\n// statement *and* every bound value ride along in `.message` and `.stack` of\n// whatever a driver rethrows — an email and a bcrypt hash reach stdout the\n// moment a registration hits a unique violation. The redaction lives here, in\n// the one function every log line passes through, rather than at the ~124\n// `{ error: … }` call sites: a per-site rule is what produced the leak (one\n// file suppressed the stack, four others did not), and the next caller would\n// reintroduce it. Nothing above this line needs to know about it.\n\nconst FAILED_QUERY_MARKER = \"Failed query:\";\n/**\n * The marker says how to lift it.\n *\n * Every DDL, RLS and CDC failure ends at this string, and the statement is the\n * whole diagnosis — three of them landed in one boot of a two-database project,\n * each a dead end. The switch existed; nothing named it, in the log or in the\n * docs, so `grep -rn REBASE_LOG_RAW_QUERIES` over the documentation, the\n * templates and the agent skills came back empty.\n */\nconst REDACTED_QUERY =\n \"Failed query: [redacted — set REBASE_LOG_RAW_QUERIES=true in development to see it]\";\nconst REDACTED_VALUE = \"[redacted]\";\n\n/**\n * Key fragments whose values are never safe to publish. Compared against the\n * key with separators and case removed, so `api_key`, `apiKey` and `API-KEY`\n * all match `apikey`.\n */\nconst SENSITIVE_KEY_FRAGMENTS = [\n \"password\",\n \"passwd\",\n \"passphrase\",\n \"secret\",\n \"token\",\n \"apikey\",\n \"authorization\",\n \"credential\",\n \"cookie\",\n \"privatekey\",\n \"sessionid\"\n];\n\n/** Longest structure the redactor will walk before giving up. */\nconst MAX_REDACT_DEPTH = 8;\n\nfunction isSensitiveKey(key: string): boolean {\n const normalised = key.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n return SENSITIVE_KEY_FRAGMENTS.some(fragment => normalised.includes(fragment));\n}\n\n/**\n * Whether a SQL statement may be written out at all.\n *\n * The escape hatch for the `Failed query:` strip — the statement is the fastest\n * way to diagnose a failing query on a developer machine. Ignored in\n * production, so a runtime that inherits the variable cannot leak because of\n * it, and it never re-enables the key deny-list.\n *\n * Exported because it is the *only* answer to \"may this process print SQL\", and\n * a driver that wants to trace what it executes has to ask the same question.\n * The Postgres driver used to decide for itself, with a `console.debug` gated\n * on `NODE_ENV` alone: every statement went to stdout whatever `LOG_LEVEL`\n * said, and it went there without passing through the redaction that lives in\n * this file.\n */\nexport function rawQueryLoggingEnabled(): boolean {\n return hostEnv().NODE_ENV !== \"production\"\n && hostEnv().REBASE_LOG_RAW_QUERIES === \"true\";\n}\n\n/**\n * Strip every `Failed query: … / params: …` span out of a message or stack,\n * keeping the surrounding text (including stack frames, which carry no user\n * data). When no `params:` line follows the marker the rest of the string is\n * dropped: a statement of unknown extent is treated as sensitive rather than\n * guessed at.\n *\n * Idempotent, and it has to be: an already-redacted span still starts with the\n * marker but has no `params:` line, so a second pass over it would fall into\n * the drop-the-rest branch and eat the stack frames behind it. Redaction runs\n * more than once on the same string in practice — the cron scheduler redacts\n * before persisting and then logs the result.\n */\nexport function redactSensitiveText(text: string): string {\n if (!text.includes(FAILED_QUERY_MARKER) || rawQueryLoggingEnabled()) return text;\n\n let out = text;\n let idx = out.indexOf(FAILED_QUERY_MARKER);\n while (idx !== -1) {\n if (out.startsWith(REDACTED_QUERY, idx)) {\n idx = out.indexOf(FAILED_QUERY_MARKER, idx + REDACTED_QUERY.length);\n continue;\n }\n const paramsIdx = out.indexOf(\"\\nparams:\", idx);\n let end: number;\n if (paramsIdx === -1) {\n end = out.length;\n } else {\n const eol = out.indexOf(\"\\n\", paramsIdx + 1);\n end = eol === -1 ? out.length : eol;\n }\n out = out.slice(0, idx) + REDACTED_QUERY + out.slice(end);\n idx = out.indexOf(FAILED_QUERY_MARKER, idx + REDACTED_QUERY.length);\n }\n return out;\n}\n\n/**\n * Diagnostic own-properties worth carrying up out of an error.\n *\n * These are what a socket failure actually says: `ECONNREFUSED` with the\n * `address` and `port` it was refused on, `ENOTFOUND` with the hostname that\n * did not resolve. They live as own-properties on the Node error rather than in\n * its message, so a serialiser that copies only `message` and `stack` prints a\n * boot failure that names no host, no port and no reason.\n *\n * Deliberately a fixed list rather than \"every own-property\": `DrizzleQueryError`\n * carries `query` and `params` beside its message, and copying those would put\n * the statement and its bound values — an email, a bcrypt hash — straight back\n * on stdout, which is what the redaction above exists to prevent. Postgres's own\n * `detail` and `hint` are left out for the same reason: `23505` reports\n * `Key (email)=(a@b.c) already exists.`, which is a row's contents.\n */\nconst ERROR_DETAIL_KEYS = [\"code\", \"errno\", \"syscall\", \"address\", \"port\", \"hostname\"] as const;\n\n/** How far the cause chain is followed before the serialiser gives up. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/** How many of an `AggregateError`'s children are serialised. */\nconst MAX_AGGREGATE_ERRORS = 4;\n\n/**\n * Serialise an Error into a plain object, with the query text redacted out of\n * its message and stack. `query`/`params` own-properties — which\n * `DrizzleQueryError` carries beside the message — are deliberately not copied.\n *\n * The chain matters more than the top. Everything a driver rethrows is a\n * wrapper: Drizzle's is `Failed query: SELECT 1` with a stack through drizzle\n * internals, and the sentence that says what is wrong — `connect ECONNREFUSED\n * 127.0.0.1:5432`, `password authentication failed for user \"app\"` — sits in\n * `.cause`, or inside the `AggregateError.errors` that `net` raises when every\n * resolved address is refused. Serialising only the wrapper is why a boot\n * against a stopped database used to log a redacted query and nothing else.\n *\n * Handles non-Error values gracefully.\n */\nfunction serialiseError(value: unknown, depth = 0): Record<string, unknown> {\n const isError = value instanceof Error;\n // A cause is not always an Error: drivers throw plain `{ code, address,\n // port }` bags, and stringifying one yields `[object Object]`, which is\n // worse than nothing. Only the named fields are copied out of it — the same\n // fixed list, for the same reason.\n const isDetailBag = !isError && depth > 0 && Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n if (!isError && !isDetailBag) {\n return { value: redactSensitiveText(String(value)) };\n }\n\n const own = value as Record<string, unknown>;\n const out: Record<string, unknown> = isError\n ? {\n name: (value as Error).name,\n message: redactSensitiveText((value as Error).message),\n stack: (value as Error).stack ? redactSensitiveText((value as Error).stack as string) : undefined\n }\n : {\n ...(typeof own.name === \"string\" ? { name: own.name } : {}),\n ...(typeof own.message === \"string\" ? { message: redactSensitiveText(own.message) } : {})\n };\n\n for (const key of ERROR_DETAIL_KEYS) {\n const detail = own[key];\n if (detail === undefined || detail === null) continue;\n if (typeof detail === \"object\") continue;\n out[key] = typeof detail === \"string\" ? redactSensitiveText(detail) : detail;\n }\n\n if (depth >= MAX_CAUSE_DEPTH) return out;\n\n if (own.cause !== undefined && own.cause !== null) {\n out.cause = serialiseError(own.cause, depth + 1);\n }\n const aggregated = own.errors;\n if (Array.isArray(aggregated) && aggregated.length > 0) {\n out.errors = aggregated\n .slice(0, MAX_AGGREGATE_ERRORS)\n .map(item => serialiseError(item, depth + 1));\n }\n return out;\n}\n\n/**\n * Redact one logged value: errors are serialised, strings are stripped of\n * query text, objects and arrays are walked. Cycles and over-deep structures\n * collapse to a marker rather than throwing — a logger that can fail is worse\n * than one that logs less. (An object referenced twice in one payload is\n * reported as `[circular]` the second time; bounding the walk matters more\n * than rendering a shared reference twice.)\n */\nfunction redactValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {\n // `serialiseError` returns only already-redacted strings, so it is the\n // terminal step — walking its output again would redact twice.\n if (value instanceof Error) return serialiseError(value);\n if (typeof value === \"string\") return redactSensitiveText(value);\n if (value === null || typeof value !== \"object\") return value;\n if (depth >= MAX_REDACT_DEPTH) return \"[truncated]\";\n if (seen.has(value)) return \"[circular]\";\n seen.add(value);\n\n if (Array.isArray(value)) {\n return value.map(item => redactValue(item, depth + 1, seen));\n }\n if (value instanceof Date) return value;\n\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(value as Record<string, unknown>)) {\n out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, depth + 1, seen);\n }\n return out;\n}\n\nfunction formatData(data?: Record<string, unknown>): Record<string, unknown> | undefined {\n if (!data) return undefined;\n const seen = new WeakSet<object>();\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(data)) {\n out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, 0, seen);\n }\n return out;\n}\n\n/**\n * Something that wants a copy of every line this logger writes.\n *\n * Receives the message and fields *after* redaction, never before: a sink is\n * another destination for the same line, and the one thing that must not vary\n * by destination is whether the query and its bound values are in it.\n */\nexport type LogSink = (\n level: LogLevel,\n message: string,\n data: Record<string, unknown>\n) => void;\n\nconst sinks = new Set<LogSink>();\n\n/**\n * Tee this logger somewhere else. Returns the unsubscribe.\n *\n * The Studio's Logs Explorer is the caller: its ring buffer used to be fed only\n * by a request middleware, so the panel showed a wall of `GET … 200` and not one\n * of the errors, warnings or diagnoses the server was writing to stdout at the\n * same moment. A log viewer that cannot show you an error is a log viewer\n * nobody opens twice.\n *\n * A sink MUST NOT log. It is called from inside `emit`, so anything that comes\n * back through `logger` recurses; the guard below stops the stack blowing, but\n * the line is dropped rather than delivered, which is its own bug.\n */\nexport function addLogSink(sink: LogSink): () => void {\n sinks.add(sink);\n return () => { sinks.delete(sink); };\n}\n\n/** Re-entrancy guard: see `addLogSink`. */\nlet inSink = false;\n\nfunction fanOut(level: LogLevel, message: string, data: Record<string, unknown>): void {\n if (sinks.size === 0 || inSink) return;\n inSink = true;\n try {\n for (const sink of sinks) {\n // One broken sink must not take down the line, nor the request that\n // was writing it.\n try { sink(level, message, data); } catch { /* a broken tee is not the caller's problem */ }\n }\n } finally {\n inSink = false;\n }\n}\n\nfunction createLogger(rawDefaultFields: Record<string, unknown> = {}): Logger {\n // Child fields go through the same pass as per-call data — they are merged\n // into every line this logger emits, so leaving them raw would be a hole\n // the moment `child()` gets its first caller.\n const defaultFields = formatData(rawDefaultFields) ?? {};\n\n function emit(level: LogLevel, message: string, data?: Record<string, unknown>): void {\n // Per line, not captured at construction: the singleton is created when\n // this module is first imported, which is long before a project's\n // `config.logging.level` has been read.\n if (LOG_PRIORITY[level] < LOG_PRIORITY[getMinLevel()]) return;\n\n // The message is redacted too, not just the data: several call sites\n // interpolate `error.message` straight into the line they log.\n const safeMessage = redactSensitiveText(message);\n const merged = { ...defaultFields,\n...formatData(data) };\n\n // Before the write, so a sink still sees the line if stdout is the\n // thing that is broken.\n fanOut(level, safeMessage, merged);\n\n if (isProduction()) {\n // Structured JSON for Cloud Logging\n const entry: LogEntry = {\n severity: GCP_SEVERITY[level],\n message: safeMessage,\n timestamp: new Date().toISOString(),\n ...merged\n };\n const line = JSON.stringify(entry);\n\n if (level === \"error\") {\n writeLine(\"err\", line);\n } else {\n writeLine(\"out\", line);\n }\n } else {\n // Human-readable for development\n const prefix = level === \"error\" ? \"❌\"\n : level === \"warn\" ? \"⚠️\"\n : level === \"info\" ? \"ℹ️\"\n : \"🐛\";\n const extra = Object.keys(merged).length > 0 ? ` ${JSON.stringify(merged)}` : \"\";\n const out = `${prefix} [${level.toUpperCase()}] ${safeMessage}${extra}`;\n\n if (level === \"error\") {\n console.error(out);\n } else if (level === \"warn\") {\n console.warn(out);\n } else {\n console.log(out);\n }\n }\n }\n\n return {\n debug: (msg, data) => emit(\"debug\", msg, data),\n info: (msg, data) => emit(\"info\", msg, data),\n warn: (msg, data) => emit(\"warn\", msg, data),\n error: (msg, data) => emit(\"error\", msg, data),\n child(fields: Record<string, unknown>): Logger {\n return createLogger({ ...defaultFields,\n...fields });\n }\n };\n}\n\n/**\n * Singleton logger instance.\n * In production: emits JSON lines with `severity`, `message`, `timestamp`.\n * In development: emits human-readable prefixed lines.\n */\nexport const logger: Logger = createLogger();\n\n/**\n * The cause chain, one readable line per link.\n *\n * `serialiseError` puts the chain in the structured payload, which is the right\n * place for a log aggregator and the wrong place for a person staring at a\n * container that will not start: the sentence they need is inside a JSON blob\n * behind an escaped stack trace. This renders the same chain as lines to print\n * beside the headline, so the first thing on screen after \"Failed to start\" is\n * `caused by: connect ECONNREFUSED 127.0.0.1:5432 (ECONNREFUSED)`.\n *\n * Redacted like everything else, and bounded by the same depth: a chain is\n * usually two links and never usefully more than four.\n */\nexport function describeCauseChain(error: unknown): string[] {\n const lines: string[] = [];\n const seen = new Set<unknown>();\n\n const walk = (value: unknown, depth: number): void => {\n if (depth > MAX_CAUSE_DEPTH || value === undefined || value === null) return;\n if (typeof value === \"object\") {\n if (seen.has(value)) return;\n seen.add(value);\n }\n if (depth > 0) {\n const described = describeOneCause(value);\n if (described) lines.push(`caused by: ${described}`);\n }\n if (typeof value !== \"object\") return;\n const own = value as Record<string, unknown>;\n walk(own.cause, depth + 1);\n const aggregated = own.errors;\n if (Array.isArray(aggregated)) {\n for (const item of aggregated.slice(0, MAX_AGGREGATE_ERRORS)) walk(item, depth + 1);\n }\n };\n\n walk(error, 0);\n return lines;\n}\n\n/** One cause rendered as `message (CODE) address:port`, or nothing to say. */\nfunction describeOneCause(value: unknown): string | undefined {\n if (value === null || typeof value !== \"object\") {\n const text = redactSensitiveText(String(value));\n return text || undefined;\n }\n const own = value as Record<string, unknown>;\n const message = typeof own.message === \"string\" && own.message\n ? redactSensitiveText(own.message)\n : undefined;\n const code = typeof own.code === \"string\" ? own.code : undefined;\n // Only when the message does not already carry it. Node writes\n // `connect ECONNREFUSED 127.0.0.1:5432` and also sets `address`/`port`, and\n // repeating the endpoint reads like two different facts.\n const endpoint = own.address !== undefined && own.port !== undefined\n ? `${String(own.address)}:${String(own.port)}`\n : undefined;\n const where = endpoint && !(message ?? \"\").includes(endpoint) ? endpoint : undefined;\n const parts = [message ?? code, code && message ? `(${code})` : undefined, where]\n .filter(Boolean);\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n"],"mappings":"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"}