@rebasepro/server 0.25.0 → 0.26.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 (174) hide show
  1. package/dist/{GCSStorageController-C7ARrDAp.js → GCSStorageController-40FyFeSx.js} +8 -4
  2. package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
  3. package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-Bl5shp94.js} +8 -4
  4. package/dist/S3StorageController-Bl5shp94.js.map +1 -0
  5. package/dist/access-BFAUvsSs.js +1897 -0
  6. package/dist/access-BFAUvsSs.js.map +1 -0
  7. package/dist/api/branch-routes.d.ts +25 -0
  8. package/dist/api/closed-database.d.ts +25 -0
  9. package/dist/api/live-schema-routes.d.ts +10 -0
  10. package/dist/api/rest/api-generator.d.ts +9 -1
  11. package/dist/api/rest/idempotency.d.ts +15 -1
  12. package/dist/api/root-error-handler.d.ts +6 -5
  13. package/dist/api/types.d.ts +12 -0
  14. package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
  15. package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
  16. package/dist/auth/access.d.ts +10 -0
  17. package/dist/auth/adapter-middleware.d.ts +2 -1
  18. package/dist/auth/adapter-service-key.d.ts +24 -0
  19. package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
  20. package/dist/auth/api-keys/api-key-store.d.ts +8 -1
  21. package/dist/auth/api-keys/key-grant.d.ts +18 -1
  22. package/dist/auth/expired-token-sweep.d.ts +5 -0
  23. package/dist/auth/impersonation.d.ts +3 -1
  24. package/dist/auth/jwt.d.ts +8 -2
  25. package/dist/auth/oauth-signin-policy.d.ts +21 -35
  26. package/dist/auth/rls-scope.d.ts +17 -7
  27. package/dist/auth/rls-simulator-routes.d.ts +51 -0
  28. package/dist/auth/simulation-read-only.d.ts +28 -0
  29. package/dist/auth/simulation.d.ts +141 -0
  30. package/dist/auth/sql-rate-limit-store.d.ts +15 -1
  31. package/dist/{auth-CteGWxsL.js → auth-izI7AEzC.js} +391 -1893
  32. package/dist/auth-izI7AEzC.js.map +1 -0
  33. package/dist/auth_adapter-B7RUMk6e.js +89 -0
  34. package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
  35. package/dist/backend/boot-announcements.d.ts +13 -0
  36. package/dist/backend/create-backend.d.ts +10 -0
  37. package/dist/backend/options.d.ts +9 -4
  38. package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
  39. package/dist/backend-CaG_Jyso.js.map +1 -0
  40. package/dist/{backup-Dq0lyJ_D.js → backup-BVbHoKo8.js} +8 -8
  41. package/dist/{backup-Dq0lyJ_D.js.map → backup-BVbHoKo8.js.map} +1 -1
  42. package/dist/boot/boot.d.ts +2 -15
  43. package/dist/boot/ddl-bootstrap.d.ts +34 -0
  44. package/dist/boot/env.d.ts +6 -0
  45. package/dist/boot/fetch-bundle.d.ts +5 -1
  46. package/dist/boot/provision-only.d.ts +23 -0
  47. package/dist/boot/provision.d.ts +53 -2
  48. package/dist/boot/role.d.ts +2 -0
  49. package/dist/boot/schema-change-recorder.d.ts +45 -0
  50. package/dist/boot/security-headers.d.ts +22 -0
  51. package/dist/branch-routes-B2vvEx53.js +84 -0
  52. package/dist/branch-routes-B2vvEx53.js.map +1 -0
  53. package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CU2A3JnF.js} +3 -3
  54. package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
  55. package/dist/cron/cron-store.d.ts +8 -1
  56. package/dist/{cron-loader-NHqRfKF0.js → cron-loader-mMhqbVsl.js} +3 -3
  57. package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
  58. package/dist/{cron-routes-Dhf82HzT.js → cron-routes-Cs33j_Kr.js} +5 -5
  59. package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
  60. package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-Be7CNghL.js} +3 -3
  61. package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
  62. package/dist/{cron-store-DEpVstUU.js → cron-store-DLn11M8H.js} +11 -10
  63. package/dist/cron-store-DLn11M8H.js.map +1 -0
  64. package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
  65. package/dist/data_driver-D0mg1Y_x.js.map +1 -0
  66. package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-CjRrn12l.js} +92 -3
  67. package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
  68. package/dist/deploy/pod-contract.d.ts +1 -1
  69. package/dist/email/dev-sink.d.ts +6 -0
  70. package/dist/email/index.d.ts +1 -1
  71. package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
  72. package/dist/errors-OMWkKSH-.js.map +1 -0
  73. package/dist/{soft-delete-params-BAgpmBvp.js → field-access-DYsvjI_P.js} +27 -57
  74. package/dist/field-access-DYsvjI_P.js.map +1 -0
  75. package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
  76. package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
  77. package/dist/{function-loader-DbCSoNJj.js → function-loader-B8YzXEsQ.js} +22 -15
  78. package/dist/function-loader-B8YzXEsQ.js.map +1 -0
  79. package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
  80. package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
  81. package/dist/functions/hono-like.d.ts +12 -0
  82. package/dist/functions/index.js +37 -2
  83. package/dist/functions/index.js.map +1 -1
  84. package/dist/functions/request-timeout.d.ts +3 -3
  85. package/dist/history/history-routes.d.ts +6 -0
  86. package/dist/history/retention-sweep.d.ts +5 -0
  87. package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-Bsau7h4X.js} +7 -7
  88. package/dist/history-recorder-Bsau7h4X.js.map +1 -0
  89. package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
  90. package/dist/history-store-BQz5YCkW.js.map +1 -0
  91. package/dist/index.d.ts +3 -1
  92. package/dist/index.es.js +3003 -681
  93. package/dist/index.es.js.map +1 -1
  94. package/dist/init/process-safety.d.ts +4 -3
  95. package/dist/init/shutdown.d.ts +8 -0
  96. package/dist/init.d.ts +6 -1
  97. package/dist/jobs/job-store.d.ts +8 -1
  98. package/dist/{jobs-8G-_agld.js → jobs-Co6Ni5HP.js} +7 -7
  99. package/dist/jobs-Co6Ni5HP.js.map +1 -0
  100. package/dist/{jwt-YhciR93H.js → jwt-rDS0884_.js} +5 -4
  101. package/dist/{jwt-YhciR93H.js.map → jwt-rDS0884_.js.map} +1 -1
  102. package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
  103. package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
  104. package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
  105. package/dist/logger-CW8pmTS6.js.map +1 -0
  106. package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
  107. package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
  108. package/dist/mcp/consent-page.d.ts +38 -1
  109. package/dist/mcp/mcp-routes.d.ts +9 -0
  110. package/dist/mcp/mcp-tools.d.ts +7 -1
  111. package/dist/mcp/oauth-store.d.ts +12 -5
  112. package/dist/metrics/history-recorder.d.ts +4 -1
  113. package/dist/metrics/history-store.d.ts +8 -0
  114. package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
  115. package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
  116. package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
  117. package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
  118. package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-CyxEtYEX.js} +61 -22
  119. package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
  120. package/dist/{proxy-BEKbsil6.js → proxy-BYir8tWK.js} +5 -7
  121. package/dist/proxy-BYir8tWK.js.map +1 -0
  122. package/dist/{query-parser-CGohjyWI.js → query-parser-CDXWf2hH.js} +7 -6
  123. package/dist/{query-parser-CGohjyWI.js.map → query-parser-CDXWf2hH.js.map} +1 -1
  124. package/dist/{request-timeout-CRny5KD0.js → request-timeout-DVjo-c_8.js} +6 -6
  125. package/dist/request-timeout-DVjo-c_8.js.map +1 -0
  126. package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
  127. package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
  128. package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
  129. package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Dc3DU_jm.js} +4 -4
  130. package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
  131. package/dist/soft-delete-params-BRONCOA2.js +59 -0
  132. package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
  133. package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-QycX8W8S.js} +40 -6
  134. package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
  135. package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
  136. package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
  137. package/dist/storage/GCSStorageController.d.ts +2 -0
  138. package/dist/storage/LocalStorageController.d.ts +8 -3
  139. package/dist/storage/S3StorageController.d.ts +2 -0
  140. package/dist/storage/maintenance-keys.d.ts +17 -0
  141. package/dist/storage/maintenance-routes.d.ts +28 -5
  142. package/dist/storage/maintenance.d.ts +136 -4
  143. package/dist/storage/orphan-scans.d.ts +120 -0
  144. package/dist/storage/types.d.ts +11 -0
  145. package/dist/strings.es-B7QLnHh2.js +96 -0
  146. package/dist/strings.es-B7QLnHh2.js.map +1 -0
  147. package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
  148. package/dist/upload-validation-Bgo626uY.js.map +1 -0
  149. package/dist/utils/dev-dependency-command.d.ts +15 -0
  150. package/dist/utils/dev-port.d.ts +8 -0
  151. package/dist/utils/logger.d.ts +24 -0
  152. package/package.json +5 -5
  153. package/dist/GCSStorageController-C7ARrDAp.js.map +0 -1
  154. package/dist/S3StorageController-qevJ0pVE.js.map +0 -1
  155. package/dist/auth-CteGWxsL.js.map +0 -1
  156. package/dist/auth_adapter-CIItCTc8.js +0 -21
  157. package/dist/auth_adapter-CIItCTc8.js.map +0 -1
  158. package/dist/backend-DTAOsLQc.js.map +0 -1
  159. package/dist/cron-store-DEpVstUU.js.map +0 -1
  160. package/dist/ddl-bootstrap-BNqi2NZe.js.map +0 -1
  161. package/dist/errors-_ytoGi8A.js.map +0 -1
  162. package/dist/function-loader-DbCSoNJj.js.map +0 -1
  163. package/dist/history-recorder-RZ1Ow4YY.js.map +0 -1
  164. package/dist/history-store-DPofGVDp.js.map +0 -1
  165. package/dist/jobs-8G-_agld.js.map +0 -1
  166. package/dist/logger-Bt32sHFw.js.map +0 -1
  167. package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
  168. package/dist/openapi-generator-BamY6-ga.js.map +0 -1
  169. package/dist/proxy-BEKbsil6.js.map +0 -1
  170. package/dist/request-timeout-CRny5KD0.js.map +0 -1
  171. package/dist/soft-delete-params-BAgpmBvp.js.map +0 -1
  172. package/dist/sql-export-routes-BgTJ7K3S.js.map +0 -1
  173. package/dist/strings.es-BeZWAodn.js.map +0 -1
  174. package/dist/upload-validation-I9cCUBIW.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-store-DLn11M8H.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 { REBASE_USER_ROLE, revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, provisioningExec } 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 /**\n * Ensure the backing tables exist and are locked away from end users. Runs\n * once per process per database, however many stores ask: concurrent\n * callers share the attempt in flight, and an attempt that fell short is\n * made again on the next call.\n */\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 *\n * Throws when the history cannot be read. An empty array is a claim — the\n * job has no recorded runs — and a caller shows it as \"never ran\".\n *\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. The SQL store reads them from\n * the all-time counters on `cron_job_state`, not from the history.\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 — every process's\n * runs, where the scheduler's own counters hold only the ones it made. The\n * SQL store keeps them as all-time counters on `cron_job_state`, so they\n * outlive the history's retention.\n */\n fetchRunSummaries?(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>>;\n\n /**\n * Delete slot claims past retention — all but each job's most recent one —\n * in batches. Resolves with how many went. The scheduler runs it on a\n * timer while it is started; a store without it keeps every claim.\n */\n sweepClaims?(): Promise<number>;\n\n /**\n * Delete run history past retention, and count into the job counters any\n * run that is not counted yet — a history written before the counters\n * existed, or by an older server during a rolling deploy. Both in batches.\n * The scheduler runs it with {@link sweepClaims}; a store without it keeps\n * every run.\n */\n sweepLogs?(): Promise<CronLogSweep>;\n}\n\n/** What one {@link CronStore.sweepLogs} did. */\nexport interface CronLogSweep {\n /** History rows deleted past retention. */\n deleted: number;\n /** Runs counted into the job counters that were not counted before. */\n counted: number;\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 — all but each job's most\n * recent one, which is kept however old it is; see `sweepClaims`.\n */\nconst CLAIM_RETENTION_DAYS = 7;\n\n/** Claims one retention statement deletes; a sweep repeats it until a batch comes back short. */\nconst CLAIM_SWEEP_BATCH = 5_000;\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 * How long a run stays in `cron_logs`, which is what the history panel and\n * `GET /api/admin/cron/:id/logs` show. The run and failure counts do not\n * shrink with it: they are kept on `cron_job_state` for all time.\n *\n * Nothing used to delete from `cron_logs`: twelve ordinary jobs wrote 1.5M rows\n * and 617 MB a year, and the counts were `count(*)` over all of it on every\n * poll of the Studio panel.\n */\nconst LOG_RETENTION_DAYS = 30;\n\n/** History rows one retention or counting statement handles; a sweep repeats it until a batch comes back short. */\nconst LOG_SWEEP_BATCH = 5_000;\n\n/**\n * Whether the run being folded in is at least as recent as the job's last\n * one. The last-run fields only move forward: a long run is logged when it\n * ends, after the skip that started while it ran, and a counting batch can\n * hold any run of the job's history.\n */\nconst IS_NEWER_RUN = \"(s.last_run_at IS NULL OR EXCLUDED.last_run_at >= s.last_run_at)\";\n\n/**\n * Add a set of runs to their jobs' counters on `cron_job_state`, as one\n * `INSERT … ON CONFLICT`. `runs` names a relation of the runs —\n * `job_id, started_at, duration_ms, success, error` — and becomes one row per\n * job: how many, how many failed, and the most recent of them.\n *\n * Ordered by job, so two statements folding the same jobs lock their rows in\n * the same order and cannot deadlock.\n */\nfunction foldIntoCounters(runs: string): string {\n return `\n INSERT INTO ${STATE_TABLE} AS s\n (job_id, total_runs, total_failures, last_run_at, last_duration_ms, last_success, last_error)\n SELECT tally.job_id, tally.runs, tally.failures,\n latest.started_at, latest.duration_ms, latest.success, latest.error\n FROM (\n SELECT job_id, count(*) AS runs, count(*) FILTER (WHERE NOT success) AS failures\n FROM ${runs} GROUP BY job_id\n ) AS tally\n JOIN (\n SELECT DISTINCT ON (job_id) job_id, started_at, duration_ms, success, error\n FROM ${runs} ORDER BY job_id, started_at DESC\n ) AS latest ON latest.job_id = tally.job_id\n ORDER BY tally.job_id\n ON CONFLICT (job_id) DO UPDATE SET\n total_runs = s.total_runs + EXCLUDED.total_runs,\n total_failures = s.total_failures + EXCLUDED.total_failures,\n last_run_at = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_run_at ELSE s.last_run_at END,\n last_duration_ms = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_duration_ms ELSE s.last_duration_ms END,\n last_success = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_success ELSE s.last_success END,\n last_error = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_error ELSE s.last_error END`;\n}\n\n/**\n * SQLSTATEs that mean the counters are not there to write to — a column or\n * table a failed boot did not create, or a privilege it did not get. A log\n * insert that meets one is written without its count; the next sweep counts\n * it. Anything else is not retried, because a statement that failed after it\n * committed would then be logged — and counted — twice.\n */\nconst COUNTERS_MISSING_SQLSTATES = new Set([\"42703\", \"42P01\", \"42501\"]);\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\n/**\n * The setup each database is going through right now, in this process. The\n * scheduler, the expired-token sweep and the history retention sweep each make\n * a store on the same driver, and three setups at once ran the same REVOKEs\n * concurrently — which Postgres can answer with \"tuple concurrently updated\",\n * failing a security control at boot. Keyed on the driver, so a second\n * database gets a setup of its own.\n *\n * Only the attempt in flight is shared. A later call runs the setup again —\n * every statement in it is idempotent — because the tables can be gone by\n * then: a database reset under a running server, or a suite that drops the\n * schema between tests. A process that remembered \"set up\" would never bring\n * them back.\n */\nconst setUpsInFlight = new WeakMap<DataDriver, Promise<void>>();\n\n/** Databases this process has already said \"ready\" for, so it says so once. */\nconst announcedReady = new WeakSet<DataDriver>();\n\nfunction ensureSharedInFlight(driver: DataDriver, setUp: () => Promise<boolean>): Promise<void> {\n const current = setUpsInFlight.get(driver);\n if (current) return current;\n const attempt = setUp().then(() => undefined).finally(() => {\n if (setUpsInFlight.get(driver) === attempt) setUpsInFlight.delete(driver);\n });\n setUpsInFlight.set(driver, attempt);\n return attempt;\n}\n\nexport function createCronStore(\n driver: DataDriver,\n options: {\n /**\n * Default `true`. `false` is a backend that changes nothing in its\n * database: `ensureTable` reads what is there, creates and revokes\n * nothing, and says which capability a missing table costs.\n */\n provision?: boolean;\n } = {}\n): 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 = provisioningExec(\n (sqlText, execOptions) =>\n admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : undefined),\n options.provision\n );\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n /** Whether `rebase.<table>` has `column`. False when it cannot tell, so the idempotent ALTER still runs. */\n const hasColumn = async (table: string, column: string): Promise<boolean> => {\n try {\n const rows = await exec(\n `SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'rebase' AND table_name = $1 AND column_name = $2`,\n { params: [table, column] }\n );\n return (rows ?? []).length > 0;\n } catch {\n return false;\n }\n };\n\n /**\n * Whether a boot has nothing to do: the three tables, the three indexes and\n * the run-counter columns are there, the dropped index is gone, and the\n * end-user role holds no privilege on any of the tables. One catalogue\n * read, where every boot used to run eleven statements — `CREATE … IF NOT\n * EXISTS` takes its table's lock before it finds the object there, and a\n * `REVOKE` rewrites the table's catalogue row even when nothing is granted.\n * Anything missing, or a read that fails, and the boot runs them all.\n */\n const bootIsCurrent = async (): Promise<boolean> => {\n const objects = [\"cron_logs\", \"cron_claims\", \"cron_job_state\", \"idx_cron_logs_job\", \"idx_cron_logs_uncounted\", \"idx_cron_logs_started\"];\n const columns = [\"cron_logs.counted\", ...[\"total_runs\", \"total_failures\", \"last_run_at\", \"last_duration_ms\", \"last_success\", \"last_error\"].map(c => `cron_job_state.${c}`)];\n const quoted = (names: string[]) => names.map(name => `'${name}'`).join(\", \");\n try {\n const rows = await exec(`\n WITH objects AS (\n SELECT c.oid, c.relname, c.relkind, c.relrowsecurity, c.relacl FROM pg_class c\n WHERE c.relnamespace = to_regnamespace('rebase')\n ), target AS (\n SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'\n )\n SELECT\n (SELECT count(*) FROM objects WHERE relname IN (${quoted(objects)}))::int AS present,\n (SELECT count(*) FROM objects WHERE relname = 'idx_cron_logs_job_failures')::int AS stale,\n (SELECT count(*) FROM information_schema.columns\n WHERE table_schema = 'rebase' AND table_name || '.' || column_name IN (${quoted(columns)}))::int AS columns,\n (SELECT count(*) FROM objects o\n WHERE o.relkind = 'r' AND o.relname IN ('cron_logs', 'cron_claims', 'cron_job_state')\n AND NOT o.relrowsecurity AND EXISTS (SELECT 1 FROM target)\n AND (o.relacl IS NULL OR EXISTS (\n SELECT 1 FROM aclexplode(o.relacl) a WHERE a.grantee = (SELECT oid FROM target)\n )))::int AS granted\n `);\n const row = rows?.[0];\n return Number(row?.present) === objects.length && Number(row?.stale) === 0\n && Number(row?.columns) === columns.length && Number(row?.granted) === 0;\n } catch {\n return false;\n }\n };\n\n /**\n * Drop claims for slots that have not happened yet. A slot is claimed at\n * the moment it fires, so a future one can only come from a timer that\n * woke early — and because claims are permanent, that claim would silently\n * skip the real run when it finally came due. The margin keeps a legitimate\n * claim made moments early by a clock-skewed peer.\n */\n const releaseFutureClaims = () => 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 nothing; the\n // rows are only used to report, so treat that 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 /** Run one sweep statement until a batch comes back short. Resolves with the total of `column`. */\n const inBatches = async (sqlText: string, params: unknown[], column: string): Promise<number> => {\n let total = 0;\n for (;;) {\n const rows = await exec(sqlText, { params });\n const done = Number((rows?.[0] as Record<string, unknown> | undefined)?.[column] ?? 0);\n total += done;\n if (done < LOG_SWEEP_BATCH) return total;\n }\n };\n\n /**\n * The boot setup. Resolves with whether every table came up and was locked\n * away; anything short of that has been reported, and is worth another try.\n */\n const announceReady = (): void => {\n if (announcedReady.has(driver)) return;\n announcedReady.add(driver);\n logger.info(\"✅ Cron logs table ready\");\n };\n\n const setUp = async (): Promise<boolean> => {\n // A warm boot: everything is there and locked away. The future\n // claims are about the slots this boot is about to fire, so they\n // are released either way.\n if (await bootIsCurrent()) {\n await releaseFutureClaims();\n announceReady();\n return true;\n }\n\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 // Whether a run is in its job's counters. A run logged by this\n // server is counted in the statement that logs it; NULL is a run\n // written before the counters existed, or by an older server\n // during a rolling deploy, which `sweepLogs` counts. Asked of the\n // catalog first: `ADD COLUMN IF NOT EXISTS` takes an ACCESS\n // EXCLUSIVE lock on the table even when the column is there.\n if (!(await hasColumn(\"cron_logs\", \"counted\"))) {\n await ddl.ensureObject(`Adding ${TABLE}.counted`, `\n ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS counted BOOLEAN\n `);\n }\n\n // The runs not counted yet — every row of a history that predates\n // the counters, then nothing. What the counting sweep walks, and\n // what a listing adds to the counters until it has.\n await ddl.ensureObject(\"Creating idx_cron_logs_uncounted\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_uncounted\n ON ${TABLE}(job_id, started_at DESC) WHERE counted IS NULL\n `);\n\n // The retention sweep's index: it deletes the oldest rows first,\n // whatever job wrote them.\n await ddl.ensureObject(\"Creating idx_cron_logs_started\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_started\n ON ${TABLE}(started_at)\n `);\n\n // It held the failed runs, for a failure count over the whole\n // history on every listing. The count is a counter now.\n await ddl.ensureObject(\"Dropping idx_cron_logs_job_failures\",\n \"DROP INDEX IF EXISTS rebase.idx_cron_logs_job_failures\");\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 // Each job's all-time run counts and its last run, kept in the\n // statement that logs the run, so a listing is one row per job\n // however long the history — and outlives its retention.\n if (!(await hasColumn(\"cron_job_state\", \"total_runs\"))) {\n await ddl.ensureObject(`Adding the run counters to ${STATE_TABLE}`, `\n ALTER TABLE ${STATE_TABLE}\n ADD COLUMN IF NOT EXISTS total_runs BIGINT NOT NULL DEFAULT 0,\n ADD COLUMN IF NOT EXISTS total_failures BIGINT NOT NULL DEFAULT 0,\n ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMPTZ,\n ADD COLUMN IF NOT EXISTS last_duration_ms INTEGER,\n ADD COLUMN IF NOT EXISTS last_success BOOLEAN,\n ADD COLUMN IF NOT EXISTS last_error 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 await releaseFutureClaims();\n\n // The retention sweep is not here any more: it ran at boot\n // and only at boot, so a scheduler that lived for months kept\n // every claim it made — 525,000 a year for one per-minute job\n // — until the next deploy deleted them in the boot path. The\n // scheduler runs `sweepClaims` on a timer instead.\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 announceReady();\n return true;\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 // On a backend that may not create it, also say where it comes from.\n const provisioningOff = options.provision === false\n ? \" This backend changes nothing in its database; a provisioning run creates it.\"\n : \"\";\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 provisioningOff\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.${provisioningOff}`);\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 provisioningOff\n );\n }\n return false;\n };\n\n return {\n ensureTable: () => ensureSharedInFlight(driver, setUp),\n\n async sweepClaims(): Promise<number> {\n // Garbage-collect old claims — except each job's most recent one.\n // A claim is also the record that a slot already ran, and the\n // catch-up at boot asks exactly that about the job's latest slot,\n // as far back as its `catchUpWindowSeconds` reaches — which can be\n // a month. Sweeping that claim after seven days would make the\n // next boot re-run the slot. A claim behind the latest answers for\n // a slot no catch-up looks at, since a catch-up considers only the\n // most recent one.\n //\n // \"Most recent\" counts only slots that have happened (give or take\n // the skew margin). A stranded future claim — the boot's\n // future-slot sweep releases those — would otherwise count as the\n // job's latest, and the real one behind it would be swept here and\n // the future one released there, leaving the job with neither.\n // That used to be guarded by running this after that sweep; it\n // now runs on a timer, so the query guards itself.\n let total = 0;\n for (;;) {\n const rows = await exec(\n `WITH swept AS (\n DELETE FROM ${CLAIMS_TABLE}\n WHERE (job_id, slot) IN (\n SELECT c.job_id, c.slot 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 AND latest.slot <= now() + make_interval(mins => $2)\n )\n LIMIT $3\n FOR UPDATE SKIP LOCKED\n )\n RETURNING 1\n )\n SELECT count(*)::int AS deleted FROM swept`,\n { params: [CLAIM_RETENTION_DAYS, FUTURE_CLAIM_SKEW_MINUTES, CLAIM_SWEEP_BATCH] }\n );\n const deleted = Number((rows?.[0] as { deleted?: number } | undefined)?.deleted ?? 0);\n total += deleted;\n if (deleted < CLAIM_SWEEP_BATCH) return total;\n }\n },\n\n async sweepLogs(): Promise<CronLogSweep> {\n // Retention first, so a history that predates the counters is\n // not marked counted row by row only to be deleted after. A row\n // deleted uncounted is counted in the statement that deletes it:\n // retention never takes a run out of the totals.\n //\n // Both statements take their rows `FOR UPDATE SKIP LOCKED`, so\n // replicas sweeping at once divide the rows between them, and a\n // row another sweep has just counted fails the `counted IS NULL`\n // recheck instead of being counted twice. Each batch is its own\n // short transaction: on a table that has grown for years this is\n // many of them, off the boot path, and never one long lock.\n const deleted = await inBatches(\n `WITH gone AS (\n DELETE FROM ${TABLE}\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE started_at < now() - make_interval(days => $1)\n ORDER BY started_at\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING job_id, counted, started_at, duration_ms, success, error\n ), uncounted AS (\n SELECT job_id, started_at, duration_ms, success, error FROM gone WHERE counted IS NULL\n ), folded AS (\n ${foldIntoCounters(\"uncounted\")}\n RETURNING 1\n )\n SELECT count(*)::int AS deleted FROM gone`,\n [LOG_RETENTION_DAYS, LOG_SWEEP_BATCH],\n \"deleted\"\n );\n\n // Then the rest of the uncounted runs, newest first per job.\n const counted = await inBatches(\n `WITH batch AS (\n SELECT id FROM ${TABLE}\n WHERE counted IS NULL\n ORDER BY job_id, started_at DESC\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n ), marked AS (\n UPDATE ${TABLE} AS l SET counted = true\n FROM batch WHERE l.id = batch.id\n RETURNING l.job_id, l.started_at, l.duration_ms, l.success, l.error\n ), folded AS (\n ${foldIntoCounters(\"marked\")}\n RETURNING 1\n )\n SELECT count(*)::int AS counted FROM marked`,\n [LOG_SWEEP_BATCH],\n \"counted\"\n );\n\n return { deleted, counted };\n },\n\n async insertLog(entry: CronJobLogEntry): Promise<void> {\n const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;\n const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;\n const 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 const columns = \"job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\";\n const values = \"$1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9\";\n try {\n try {\n // The run and its count in one statement, so neither is\n // ever written without the other. Every run counts — a\n // skip too, as it always has: it is a row of the history.\n await exec(\n `WITH logged AS (\n INSERT INTO ${TABLE} (${columns}, counted)\n VALUES (${values}, true)\n RETURNING job_id, started_at, duration_ms, success, error\n )\n ${foldIntoCounters(\"logged\")}`,\n { params }\n );\n } catch (err) {\n if (!hasInCauseChain(err, (e) => typeof e.code === \"string\" && COUNTERS_MISSING_SQLSTATES.has(e.code))) {\n throw err;\n }\n // The counters are not there to write to. Keep the run\n // anyway, uncounted: a listing adds it to the counts, and\n // the next sweep counts it.\n await exec(`INSERT INTO ${TABLE} (${columns}) VALUES (${values})`, { params });\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 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 },\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 // The counters, one row per job — not a GROUP BY over the\n // whole history, which every start used to run. Runs not\n // counted yet are left to the sweep the scheduler starts with;\n // a listing adds them meanwhile.\n const rows = await exec(`\n SELECT job_id, total_runs, total_failures, last_run_at\n FROM ${STATE_TABLE}\n WHERE total_runs > 0\n `);\n\n for (const row of rows ?? []) {\n stats.set(String(row.job_id), {\n totalRuns: Number(row.total_runs),\n totalFailures: Number(row.total_failures),\n lastRunAt: toIsoString(row.last_run_at)\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 // One primary-key read of the counters per job. It used to be two\n // `count(*)` over the job's whole history, on every 15-second poll\n // of the Studio panel: 64–132 ms at a year of twelve jobs, and\n // doubling every year.\n //\n // Plus the runs not counted yet, so the answer is exact while the\n // counting sweep has not caught up: a history from before the\n // counters, or a run an older server logged during a rolling\n // deploy. Both read only `idx_cron_logs_uncounted`, which is empty\n // once the sweep has run.\n const rows = await exec(\n `SELECT j.job_id,\n COALESCE(s.total_runs, 0) + COALESCE(pending.runs, 0) AS total_runs,\n COALESCE(s.total_failures, 0) + COALESCE(pending.failures, 0) AS total_failures,\n CASE WHEN newer.later THEN pending_last.started_at ELSE s.last_run_at END AS started_at,\n CASE WHEN newer.later THEN pending_last.duration_ms ELSE s.last_duration_ms END AS duration_ms,\n CASE WHEN newer.later THEN pending_last.success ELSE s.last_success END AS success,\n CASE WHEN newer.later THEN pending_last.error ELSE s.last_error END AS error\n FROM (VALUES ${jobIds.map((_, i) => `($${i + 1}::text)`).join(\", \")}) AS j(job_id)\n LEFT JOIN ${STATE_TABLE} AS s ON s.job_id = j.job_id\n LEFT JOIN LATERAL (\n SELECT count(*) AS runs, count(*) FILTER (WHERE NOT l.success) AS failures\n FROM ${TABLE} l\n WHERE l.job_id = j.job_id AND l.counted IS NULL\n ) AS pending ON true\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 AND l.counted IS NULL\n ORDER BY l.started_at DESC\n LIMIT 1\n ) AS pending_last ON true\n CROSS JOIN LATERAL (\n SELECT pending_last.started_at IS NOT NULL\n AND (s.last_run_at IS NULL OR pending_last.started_at >= s.last_run_at) AS later\n ) AS newer`,\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":"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"}
@@ -162,95 +162,7 @@ function resolveClientListLimit(rawLimit, opts = {}) {
162
162
  }
163
163
  return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
164
164
  }
165
- var snakeCaseRegex = /[A-Z]{2,}(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|[0-9]+(?:[a-z](?![a-z]))?|[A-Z]/g;
166
- var toSnakeCase = (str) => {
167
- if (!str || typeof str !== "string") return "";
168
- const regExpMatchArray = str.match(snakeCaseRegex);
169
- if (!regExpMatchArray) return "";
170
- return regExpMatchArray.map((x) => x.toLowerCase()).join("_");
171
- };
172
- /**
173
- * Are these two identifiers one edit apart, ignoring case?
174
- *
175
- * Tight on purpose. A suggester that fires on two edits offers `columnWidth`
176
- * for `colWith` and `readOnly` for `required`, and a "did you mean" that is
177
- * usually wrong teaches people to stop reading them. One edit covers what
178
- * actually happens — a dropped letter (`multilne`), a doubled one, a transposed
179
- * pair (`validaton`), a wrong case — and nothing else.
180
- *
181
- * Lives here rather than beside either caller because both the CLI's manifest
182
- * reader and the server's collection validator answer the same question about
183
- * an unknown key, and two copies of a similarity rule is two rules the moment
184
- * one of them is tuned.
185
- */
186
- function isNearMiss(a, b) {
187
- const x = a.toLowerCase();
188
- const y = b.toLowerCase();
189
- if (x === y) return true;
190
- if (Math.abs(x.length - y.length) > 1) return false;
191
- const [shorter, longer] = x.length <= y.length ? [x, y] : [y, x];
192
- let i = 0;
193
- let j = 0;
194
- let edits = 0;
195
- while (i < shorter.length && j < longer.length) {
196
- if (shorter[i] === longer[j]) {
197
- i++;
198
- j++;
199
- continue;
200
- }
201
- if (++edits > 1) return false;
202
- if (shorter.length === longer.length) i++;
203
- j++;
204
- }
205
- return edits + (longer.length - j) + (shorter.length - i) <= 1;
206
- }
207
- /**
208
- * The key in `known` that `key` was probably meant to be, if any.
209
- *
210
- * `undefined` when nothing is close — which is the common case for a key that
211
- * is simply from a newer version, and the reason the caller must be able to say
212
- * "unknown" without saying "wrong".
213
- */
214
- function suggestNearMiss(known, key) {
215
- return known.find((candidate) => isNearMiss(candidate, key));
216
- }
217
- /**
218
- * The terms a user meant when they typed into a search box.
219
- *
220
- * A search box is not a substring box. Typing `sebastian melendez` on a
221
- * collection whose name is split across `first_name` and `last_name` has to
222
- * find the row, and matching the whole string against each field separately
223
- * cannot: no single column holds both words. Splitting on whitespace and
224
- * requiring *every* term to match *some* field is what does, and it is what
225
- * `websearch_to_tsquery` already does for collections that declared a `search`
226
- * block — so this is also what keeps the two search paths answering the same
227
- * question.
228
- *
229
- * It fixes the trailing space too: `sebastian ` matched nothing at all, because
230
- * the space was part of the pattern.
231
- *
232
- * A double-quoted run is one term, again matching the full-text path, so a
233
- * caller who really does want the phrase can ask for it. An unbalanced quote is
234
- * stripped rather than honoured — the alternative is that everything after a
235
- * stray `"` silently becomes one long term that matches nothing.
236
- *
237
- * Returns an empty array for a string with no terms in it at all (whitespace,
238
- * or nothing but quotes); the caller decides what an empty search means, since
239
- * only it knows whether "no constraint" or "no rows" is the safe reading.
240
- */
241
- function splitSearchTerms(searchString, maxTerms = 8) {
242
- const terms = [];
243
- const pattern = /"([^"]*)"|(\S+)/g;
244
- let match;
245
- while ((match = pattern.exec(searchString)) !== null) {
246
- const term = (match[1] ?? match[2].replace(/"/g, "")).trim();
247
- if (!term) continue;
248
- terms.push(term);
249
- if (terms.length >= maxTerms) break;
250
- }
251
- return terms;
252
- }
253
165
  //#endregion
254
- export { MAX_LIST_LIMIT as a, RebaseClientError as c, ListLimitError as i, isUnsupported as l, suggestNearMiss as n, resolveClientListLimit as o, toSnakeCase as r, RebaseApiError as s, splitSearchTerms as t, unsupportedMethod as u };
166
+ export { RebaseClientError as a, RebaseApiError as i, MAX_LIST_LIMIT as n, isUnsupported as o, resolveClientListLimit as r, unsupportedMethod as s, ListLimitError as t };
255
167
 
256
- //# sourceMappingURL=strings.es-BeZWAodn.js.map
168
+ //# sourceMappingURL=data_driver-D0mg1Y_x.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data_driver-D0mg1Y_x.js","names":[],"sources":["../../types/src/errors.ts","../../types/src/controllers/data_driver.ts"],"sourcesContent":["/**\n * The error codes every route can produce, as `RebaseApiError.code`.\n *\n * These are the defaults on `ApiError`'s static constructors server-side, so\n * any endpoint can answer with one. They are **not** the complete set: routes\n * pass their own more specific codes too (`EMAIL_EXISTS`, `TOKEN_EXPIRED`,\n * `INVALID_BULK_BODY`, …), and auth alone defines a couple of dozen.\n *\n * Hence the union is deliberately open rather than closed. It exists to give\n * autocomplete and to catch a typo in the common cases — `code` was a bare\n * `string`, so `e.code === \"NOT_FOUND\"` and `e.code === \"NOTFOUND\"` were\n * equally valid and only one of them worked. Closing it would be a lie that\n * broke the moment a route added a code.\n *\n * @example\n * if (e instanceof RebaseApiError) {\n * switch (e.code) {\n * case \"NOT_FOUND\": return null; // completed\n * case \"FORBIDDEN\": return redirect();\n * default: throw e; // routes' own codes land here\n * }\n * }\n *\n * @group Errors\n */\nexport type RebaseErrorCode =\n | \"BAD_REQUEST\"\n | \"UNAUTHORIZED\"\n | \"FORBIDDEN\"\n | \"NOT_FOUND\"\n | \"CONFLICT\"\n | \"INTERNAL_ERROR\"\n | \"SERVICE_UNAVAILABLE\"\n | \"NETWORK_ERROR\"\n | \"OFFLINE\"\n | \"DB_PERMISSION_DENIED\"\n | \"SCHEMA_DRIFT\"\n /**\n * Realtime: the socket closed after a request was written and before the\n * server answered — the request may or may not have run, and it is never\n * re-sent. On a live subscription's `onError`: the connection has been\n * down longer than a blip; the subscription is kept and resumes, with a\n * fresh `onUpdate`, when the socket is back.\n */\n | \"CONNECTION_LOST\"\n /**\n * Realtime: no answer within the request timeout of the call — either it\n * waited for a socket the whole time and was never sent, or it was written\n * and never answered.\n */\n | \"REQUEST_TIMEOUT\"\n // `string & {}` keeps the union open while preserving completion on the\n // literals above — a bare `| string` would collapse them and offer nothing.\n | (string & {});\n\n/**\n * Structured initializer for {@link RebaseApiError}.\n *\n * @group Errors\n */\nexport interface RebaseErrorInit {\n /**\n * HTTP status code, when the error originated from an HTTP response.\n *\n * Three states, and they mean different things:\n *\n * - a real status — the server answered, and this is what it said;\n * - **`0`** — the request never reached a server: DNS, a refused\n * connection, CORS, an abort. `XMLHttpRequest` has always spelled that\n * `0`, and a fabricated 5xx would be indistinguishable from one the\n * server actually sent. The original failure is on `cause`;\n * - `undefined` — nothing was sent at all: a realtime/WebSocket failure,\n * or a client-side logic error raised before any request.\n */\n status?: number;\n /** Stable, machine-readable error code. See {@link RebaseErrorCode}. */\n code?: RebaseErrorCode;\n /** Structured error payload returned by the server, when present. */\n details?: unknown;\n /**\n * The server's correlation id for the request that failed, when it sent\n * one — the `requestId` in the error envelope, which also comes back on the\n * `X-Request-ID` header.\n *\n * The envelope has carried it for a while; the client dropped it on the\n * floor, so a bug report from an app could never quote the one string that\n * finds the server-side line.\n */\n requestId?: string;\n /**\n * Seconds to wait before retrying, from the response's `Retry-After`\n * header. Present on a 429 and on some 503s.\n *\n * Also dropped. The offline queue's own backoff therefore ignored a server\n * that had said exactly how long to wait — the one number that turns a\n * retry storm into a queue that drains.\n */\n retryAfterSeconds?: number;\n /** The underlying error this one wraps, if any. */\n cause?: unknown;\n}\n\n/**\n * The single error type thrown across the entire Rebase client surface —\n * HTTP data/control-plane calls, realtime/WebSocket operations, and\n * client-side logic errors (e.g. an unknown collection accessor). A `catch`\n * block only ever needs to check for this one class:\n *\n * ```ts\n * import { RebaseApiError } from \"@rebasepro/client\"; // re-exported\n *\n * try {\n * await client.data.products.update(id, { price: 9 });\n * } catch (e) {\n * if (e instanceof RebaseApiError) {\n * if (e.status === 404) { ... } // HTTP failures carry a status\n * console.error(e.code, e.details);\n * }\n * }\n * ```\n *\n * `status` is present for HTTP failures and `undefined` otherwise, so its\n * presence distinguishes transport-level errors from realtime/logic errors.\n *\n * @group Errors\n */\nexport class RebaseApiError extends Error {\n /** HTTP status code, or `undefined` for non-HTTP errors. */\n readonly status?: number;\n /** Stable machine-readable error code, when the server supplied one. See {@link RebaseErrorCode}. */\n readonly code?: RebaseErrorCode;\n /** Structured error payload from the server, when present. */\n readonly details?: unknown;\n /** See {@link RebaseErrorInit.requestId}. Quote it in a bug report. */\n readonly requestId?: string;\n /** See {@link RebaseErrorInit.retryAfterSeconds}. */\n readonly retryAfterSeconds?: number;\n\n constructor(message: string, init: RebaseErrorInit = {}) {\n super(message);\n this.name = \"RebaseApiError\";\n this.status = init.status;\n this.code = init.code;\n this.details = init.details;\n this.requestId = init.requestId;\n this.retryAfterSeconds = init.retryAfterSeconds;\n if (init.cause !== undefined) {\n // `cause` is standard on Error but not always in the lib target's type.\n (this as { cause?: unknown }).cause = init.cause;\n }\n }\n}\n\n/**\n * Client-side logic error — raised before any request is made (e.g. accessing\n * an unknown collection accessor when a typed dictionary is configured).\n *\n * A subclass of {@link RebaseApiError} (with no `status`), so a single\n * `catch (e) { if (e instanceof RebaseApiError) ... }` handles it too.\n *\n * @group Errors\n */\nexport class RebaseClientError extends RebaseApiError {\n /**\n * `init` is the same one {@link RebaseApiError} takes, and it is what makes\n * `code` reachable at all.\n *\n * The constructor used to accept a message and nothing else, so every\n * client-side failure — an undefined filter value, an unknown accessor,\n * `listen()` on a client built with `realtime: false`, a function name with\n * a `/` in it, `refreshSession()` while signed out — arrived with `code ===\n * undefined`. The documented `switch (e.code)` in this file's own example\n * fell to `default: throw e` for all of them, and the only client-side error\n * that *did* carry a code was `OFFLINE`, because that one path minted a\n * `RebaseApiError` instead.\n */\n constructor(message: string, init: RebaseErrorInit = {}) {\n super(message, init);\n this.name = \"RebaseClientError\";\n }\n}\n\n/**\n * Brand for a contract method a particular client cannot serve.\n *\n * `Symbol.for` rather than a fresh symbol: two copies of `@rebasepro/types` in\n * one tree — which happens, see `docs/dependency-duplication-traps.md` — must\n * agree about it, and a module-local symbol would not.\n */\nconst UNSUPPORTED_METHOD = Symbol.for(\"rebase.unsupportedMethod\");\n\n/**\n * Build the stub a client installs for a contract method it cannot serve.\n *\n * `listen`, `listenById` and `count` are part of `SDKCollectionClient`, not\n * optional extras — a caller should be able to write\n * `client.data.posts.count()` without asking first, and a transport that cannot\n * serve it should answer with a sentence naming the configuration that would,\n * rather than with `undefined is not a function` at the call site. Where the\n * transport genuinely cannot (a client built with `realtime: false`, a driver\n * with no `listenCollection`), it installs one of these instead of omitting the\n * method.\n *\n * @param message What to tell the caller, naming the fix.\n * @group Errors\n */\n/**\n * The tag {@link unsupportedMethod} puts on the stub it returns.\n *\n * Written as an optional member on a callable, which is what a tagged stub\n * actually is — so both the write and the read below are single casts and tsc\n * checks the symbol and the value type on each. Reading it as\n * `Record<symbol, boolean>` made every symbol key on the object a `boolean`\n * (which is not true of a function, and is why that needed `as unknown as` to\n * be written at all), and the write and the read were then free to disagree\n * about the tag.\n */\ntype UnsupportedTagged = ((...args: never[]) => unknown) & { [UNSUPPORTED_METHOD]?: boolean };\n\nexport function unsupportedMethod<F>(message: string): F {\n const stub = (): never => {\n // The two reasons a method is a stub — `realtime: false`, and a driver\n // with no `listenCollection` — are one thing to a caller: this client\n // cannot do realtime. One code covers both, and the message says which.\n throw new RebaseClientError(message, { code: \"REALTIME_DISABLED\" });\n };\n (stub as UnsupportedTagged)[UNSUPPORTED_METHOD] = true;\n return stub as F;\n}\n\n/**\n * Can this method actually do anything?\n *\n * `true` for a stub from {@link unsupportedMethod} **and** for a method that is\n * simply not there — a partial client, a hand-built test double, an\n * implementation written against an older shape of the interface. Both mean the\n * same thing to a caller, so both answer the same way, and an adapter that\n * checks this cannot be caught out by either.\n *\n * Ordinary code does not need it: calling the method and letting it throw is\n * the normal path. Adapters do — the admin panel chooses between subscribing\n * and a one-shot `find()` by asking whether the client can listen, and a UI\n * that subscribes into a throw is worse than one that polls. This is the\n * question `if (accessor.listen)` used to be asking, made explicit now that the\n * method is always there to call.\n *\n * @group Errors\n */\nexport function isUnsupported(method: unknown): boolean {\n if (typeof method !== \"function\") return true;\n return (method as UnsupportedTagged)[UNSUPPORTED_METHOD] === true;\n}\n","import { RebaseApiError } from \"../errors\";\nimport type { CollectionRegistryController } from \"./collection_registry\";\nimport type { EntityStatus, EntityValues } from \"../types/entities\";\nimport type { CollectionConfig, FilterValues } from \"../types/collections\";\nimport type { OrderByTuple } from \"../types/filter-operators\";\nimport type { RebaseCallContext } from \"../call_context\";\nimport type { IncludeSpec, LogicalCondition } from \"./data\";\nimport type { CollectionUpdateMeta } from \"../types/websockets\";\n\n\n/**\n * @internal\n */\nexport interface FetchOneProps<M extends Record<string, unknown> = Record<string, unknown>> {\n path: string;\n id: string | number;\n databaseId?: string;\n collection?: CollectionConfig<M>\n /**\n * See {@link FetchCollectionProps.withDeleted}. A soft-deleted row is a 404\n * here by default, so `findById` and `find` agree about which rows exist —\n * a row you cannot find in a listing and can still open by id is the kind\n * of inconsistency that makes a feature untrustworthy.\n */\n withDeleted?: boolean | \"only\";\n}\n\n/**\n * What a row listener subscribes to: the row, and the relations each frame\n * loads into it.\n *\n * @internal\n */\nexport type ListenOneTarget<M extends Record<string, unknown> = Record<string, unknown>> =\n FetchOneProps<M>\n & {\n /**\n * Relations to load into every frame, as {@link FetchCollectionProps.include}\n * loads them into each row of a list. Without it a to-many relation\n * is absent from each frame, and the admin's record form, opened by\n * URL, showed it empty — so a save from it unlinked every related row.\n */\n include?: IncludeSpec;\n }\n\n/**\n * @internal\n */\nexport type ListenOneProps<M extends Record<string, unknown> = Record<string, unknown>> =\n ListenOneTarget<M>\n & {\n onUpdate: (row: Record<string, unknown> | null) => void,\n onError?: (error: Error) => void,\n }\n\n/**\n * Configuration for vector similarity search queries.\n * Vector search applies an ORDER BY distance expression and optionally\n * filters results by a distance threshold.\n */\nexport interface VectorSearchParams {\n /** Property name containing the vector column */\n property: string;\n /** Query vector to compare against */\n vector: number[];\n /** Distance function (default: \"cosine\") */\n distance?: \"cosine\" | \"l2\" | \"inner_product\";\n /** Only return results within this distance threshold */\n threshold?: number;\n}\n\n// ── List pagination bounds ────────────────────────────────────────────────\n//\n// Client-driven list reads (REST `GET /<collection>` and the WebSocket\n// `subscribe_collection` message) accept a client-supplied `limit`. Without\n// bounds, an ABSENT limit streams the entire table into memory — a trivial\n// OOM/DoS — and `limit=100000000` (or `limit=0`, historically an unlimited\n// bypass) is honoured verbatim. `resolveClientListLimit` is the single shared\n// enforcement point so every untrusted ingress behaves identically. Trusted\n// server-side callers build fetch options directly and are intentionally NOT\n// bounded here (migrations, admin exports, and CDC refetches may need the full\n// set).\n//\n// A limit the platform will not serve is REFUSED, not quietly shrunk. Clamping\n// answers a request for 100 000 rows with 1 000 of them, and a short page is\n// indistinguishable from \"that is all the data there is\" — which is how a CSV\n// export shipped 50 rows of a 100 000-row collection under a filename that read\n// like the whole thing. `meta.total`/`meta.hasMore` make truncation *detectable*\n// on the REST list response, but only for a caller who thinks to compare what it\n// asked for against what it got, and the WebSocket `collection_update` frame\n// carries neither — so signalling cannot be the answer on every surface and\n// rejecting is. An ABSENT limit still defaults: naming no window is not the same\n// as asking for one that cannot be served.\n\n/** Rows returned for a plain / text-search list read when the client sends no `limit`. */\nexport const DEFAULT_LIST_LIMIT = 50;\n/** Rows returned for a vector-search list read when the client sends no `limit`. */\nexport const DEFAULT_VECTOR_LIST_LIMIT = 10;\n/** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */\nexport const MAX_LIST_LIMIT = 1000;\n\n/** Overridable bounds for {@link resolveClientListLimit}. */\nexport interface ListLimitBounds {\n /** Default page size for plain and text-search reads. */\n defaultLimit?: number;\n /** Default page size for vector-search reads. */\n vectorDefaultLimit?: number;\n /** Largest limit a client may ask for. A larger one is rejected, not clamped. */\n maxLimit?: number;\n}\n\n/**\n * Thrown by {@link resolveClientListLimit} for a `limit` the platform will not\n * serve. Carries an HTTP status so an ingress that speaks HTTP can forward it\n * verbatim, and `maxLimit` so one can be built without re-deriving the ceiling.\n *\n * @group Errors\n */\nexport class ListLimitError extends RebaseApiError {\n /** The ceiling that was exceeded — what the caller should page by instead. */\n readonly maxLimit: number;\n\n constructor(message: string, maxLimit: number) {\n super(message, { status: 400, code: \"INVALID_LIMIT\" });\n this.name = \"ListLimitError\";\n this.maxLimit = maxLimit;\n // Keeps `instanceof` working when this is compiled down for an older\n // target, where extending a builtin otherwise loses the prototype.\n Object.setPrototypeOf(this, ListLimitError.prototype);\n }\n}\n\n/**\n * Resolve a client-supplied list `limit` into a safe, always-defined value.\n *\n * - An absent / blank limit falls back to the mode default:\n * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.\n * - A limit that is present must be an integer in `[1, maxLimit]`. Anything\n * else — `0`, a negative, `1.5`, `abc`, `100000000` — throws\n * {@link ListLimitError} rather than being coerced into range, because every\n * coercion answers a question the caller did not ask with a page it cannot\n * tell apart from the whole collection.\n *\n * The return is never `undefined` — no ingress that routes its client limit\n * through this can produce an unbounded read.\n *\n * @throws {ListLimitError} when a present `limit` is not an integer in range.\n */\nexport function resolveClientListLimit(\n rawLimit: number | string | null | undefined,\n opts: ListLimitBounds & { vectorSearch?: boolean } = {}\n): number {\n const maxLimit = opts.maxLimit ?? MAX_LIST_LIMIT;\n if (rawLimit != null && String(rawLimit).trim() !== \"\") {\n // `Number`, not `parseInt`: `parseInt(\"50rows\")` is 50, which silently\n // reads a typo as a window the caller never wrote.\n const parsed = typeof rawLimit === \"number\" ? rawLimit : Number(String(rawLimit).trim());\n if (!Number.isInteger(parsed) || parsed < 1) {\n throw new ListLimitError(\n `Invalid \\`limit\\`: ${String(rawLimit)}. Expected a whole number between 1 and ${maxLimit}.`,\n maxLimit\n );\n }\n if (parsed > maxLimit) {\n throw new ListLimitError(\n `\\`limit\\` ${parsed} is above the maximum of ${maxLimit}. Ask for at most ${maxLimit} rows ` +\n \"per read and page through the rest with `offset` — answering with a smaller page would be \" +\n \"indistinguishable from there being no more rows.\",\n maxLimit\n );\n }\n return parsed;\n }\n return opts.vectorSearch\n ? (opts.vectorDefaultLimit ?? DEFAULT_VECTOR_LIST_LIMIT)\n : (opts.defaultLimit ?? DEFAULT_LIST_LIMIT);\n}\n\n/**\n * @internal\n */\nexport interface FetchCollectionProps<M extends Record<string, unknown> = Record<string, unknown>> {\n path: string;\n collection?: CollectionConfig<M>;\n filter?: FilterValues<Extract<keyof M, string>>,\n /**\n * An `or(...)`/`and(...)` group, applied alongside `filter`.\n *\n * The REST layer parsed `?or=` into this and then had nowhere to put it, so\n * the group was dropped and the read ran unfiltered — returning every row\n * the caller's policies allowed rather than the ones they asked for.\n */\n logical?: LogicalCondition;\n limit?: number;\n offset?: number;\n startAfter?: unknown;\n /**\n * The sort, in either of two spellings:\n *\n * - a field name, whose direction is the separate `order` below — the\n * original single-column contract, which every existing driver reads;\n * - a list of `[field, direction]` tuples applied in order of significance,\n * which carries a multi-column sort and ignores `order` entirely.\n *\n * `normalizeDriverOrderBy` in `@rebasepro/common` collapses the pair to the\n * list form. A driver that has not been taught the list form should read it\n * through that helper rather than assume a string: handed an array, `String()`\n * would produce a field name like `roles,asc` and the sort would 400 (or,\n * with unknown-field warnings on, silently vanish).\n */\n orderBy?: string | OrderByTuple[];\n searchString?: string;\n /** Ask each row which declared search field matched — populates `_matches`. */\n searchExplain?: boolean;\n /** Direction for the string form of `orderBy`. Ignored when `orderBy` is a list. */\n order?: \"desc\" | \"asc\";\n /** Vector similarity search configuration */\n vectorSearch?: VectorSearchParams;\n /**\n * What to do about rows a soft delete has stamped.\n *\n * Unset (the default) hides them, which is the whole point of the feature:\n * a deleted row is deleted as far as the application is concerned. `true`\n * includes them alongside the live ones — a trash view, an admin audit.\n * `\"only\"` returns nothing but them, which is the trash view proper and is\n * not expressible as a filter, because the field is not part of the\n * caller's vocabulary.\n *\n * Ignored by collections that do not declare {@link\n * PostgresCollectionConfig.softDelete}: there is no stamp to look at, and\n * silently returning nothing for `\"only\"` on such a collection would be a\n * worse answer than ignoring it.\n */\n withDeleted?: boolean | \"only\";\n /**\n * Relations to load — see {@link IncludeSpec}.\n *\n * Absent means *no* relations, the same as it does over REST. It used to be\n * absent from this contract entirely, and the driver's own fetch then loaded\n * every relation of every row unconditionally: `find()` returned a row with\n * a foreign key and `listen()` returned the same row with a nested object\n * where that key was, for the same query.\n */\n include?: IncludeSpec;\n /** Columns to read, as a projection. See `FindParams.fields`. */\n fields?: string[];\n /** `SELECT DISTINCT` over the projection. See `FindParams.distinct`. */\n distinct?: boolean;\n}\n\n/**\n * @internal\n */\nexport type ListenCollectionProps<M extends Record<string, unknown> = Record<string, unknown>> =\n FetchCollectionProps<M> &\n {\n /**\n * Page number (1-indexed), as `FindParams.page`.\n *\n * A subscription could name a `limit` and an `offset` but not a `page`,\n * so a live list on page three had to compute the offset itself — and\n * the two spellings then disagreed about what a page was.\n */\n page?: number;\n /**\n * Subscribe to the count alone: the server reads no rows, and each\n * frame carries `rows: []` and `meta.total`. A server that predates it\n * ignores the key and answers with the rows asked for (so ask for\n * `limit: 1`) and the same `meta`. See `listenCount`.\n */\n countOnly?: boolean;\n onUpdate: (rows: Record<string, unknown>[], meta?: CollectionUpdateMeta) => void;\n onError?: (error: Error) => void;\n };\n\n/**\n * @internal\n */\nexport interface SaveProps<M extends Record<string, unknown> = Record<string, unknown>> {\n path: string;\n values: Partial<EntityValues<M>>;\n id?: string | number; // can be empty for new entities\n previousValues?: Partial<EntityValues<M>>;\n collection?: CollectionConfig<M>;\n status: EntityStatus;\n /**\n * Write the row with INSERT ... ON CONFLICT DO UPDATE on the primary key\n * instead of choosing between insert and update up front.\n *\n * One statement, so it does not lose the race a read-then-write can, and it\n * succeeds whether or not the row is already there — what a re-runnable\n * import needs. Requires every primary key column to be present; without\n * them there is no conflict target and the row is inserted normally.\n */\n upsert?: boolean;\n\n /**\n * The columns the upsert matches a conflict on, instead of the primary key.\n *\n * The key is the only target that always exists, and it is the wrong one\n * for the write an upsert is usually reached for: \"this user, identified by\n * their email, exists with these values\". Keyed on the primary key that is\n * an insert, because the caller does not know the serial id — so the row is\n * duplicated on every run.\n *\n * Only column sets carrying a uniqueness guarantee are legal here; Postgres\n * refuses anything else with 42P10, from inside a transaction. The REST\n * layer checks the target against the collection's declarations first (see\n * `resolveConflictTarget`), so the answer is a 400 naming the available\n * targets rather than a 500 naming a constraint the caller never wrote.\n */\n onConflict?: readonly string[];\n\n /**\n * A check on the stored row an update addresses, run by the driver inside\n * the write's own transaction, before any hook runs and before anything is\n * written. See {@link RowPrecondition}. Honoured only by a driver that\n * declares {@link DataDriver.checksRowPreconditions}; ignored on a create.\n */\n precondition?: RowPrecondition;\n}\n\n/**\n * A check a caller makes on the row a write addresses, inside that write.\n *\n * The REST routes used to read the row in a transaction of their own before\n * writing it, to answer a missing row with their 404 and to compare\n * `If-Match`. Two transactions: the read loaded every declared relation of the\n * row, and two writes carrying the same tag could both pass the comparison,\n * because neither saw the other's write before making its own.\n *\n * @internal\n */\nexport interface RowPrecondition {\n /**\n * Lock the row before it is read, so no other write to it can land between\n * this check and this write. A conditional write (`If-Match`) sets it; a\n * plain existence check does not need it and does not pay for it.\n */\n lock?: boolean;\n /**\n * Judge the row as this caller reads it at the address written (their\n * policies, their `beforeQuery`, the trash hidden unless the write is a\n * restore or a purge) — `undefined` when there is none. Throw to refuse the\n * write; nothing has been written when this runs.\n */\n check(stored: Record<string, unknown> | undefined): void | Promise<void>;\n}\n\n/**\n * @internal\n */\nexport interface SaveManyProps<M extends Record<string, unknown> = Record<string, unknown>> {\n path: string;\n /**\n * The rows to write. A row carrying its primary key updates (or, with\n * `upsert`, inserts-or-updates) that row; one without inserts.\n */\n rows: Partial<EntityValues<M>>[];\n collection?: CollectionConfig<M>;\n /** Apply every row as INSERT ... ON CONFLICT DO UPDATE. See {@link SaveProps.upsert}. */\n upsert?: boolean;\n /** The conflict target for those upserts. See {@link SaveProps.onConflict}. */\n onConflict?: readonly string[];\n}\n\n/**\n * @internal\n */\nexport interface UpdateManyProps<M extends Record<string, unknown> = Record<string, unknown>> {\n path: string;\n /**\n * The rows to update, each named by its address.\n *\n * Distinct from {@link SaveManyProps.rows}, which carries keys *inside* the\n * values and is insert-shaped — `saveMany` passes `status: \"new\"` and no\n * `id`, so it cannot express \"update exactly this row\". This can, and it is\n * why bulk update is a separate driver method rather than a flag on that one.\n */\n updates: { id: string | number; values: Partial<EntityValues<M>> }[];\n collection?: CollectionConfig<M>;\n}\n\n/**\n * @internal\n */\nexport interface DeleteProps<M extends Record<string, unknown> = Record<string, unknown>> {\n /**\n * The row's address, and nothing about its contents.\n *\n * The driver reads the row itself, under the caller's own scope, and that\n * read is what `beforeDelete` and `afterDelete` receive and what history\n * records. This used to carry `values`, and the driver took them as the\n * row — so over the WebSocket, which forwarded the client's frame, the\n * caller wrote the audit record of their own deletion and could get past a\n * `beforeDelete` that refused on a column by sending `values: {}`.\n */\n row: { id: string | number; path: string };\n collection?: CollectionConfig<M>;\n /**\n * Issue a real `DELETE` on a collection that declares\n * {@link PostgresCollectionConfig.softDelete}.\n *\n * The row and every cascade behind it go. It needs the same permission an\n * ordinary delete does and nothing more: it is the same verb, and a second\n * access-control surface for one operation is a second thing to get wrong.\n * No effect on a collection without soft delete, where every delete is\n * already this one.\n */\n hard?: boolean;\n\n /**\n * A check on the stored row, inside the delete's own transaction, before\n * any hook runs. See {@link RowPrecondition}. Honoured only by a driver\n * that declares {@link DataDriver.checksRowPreconditions}.\n */\n precondition?: RowPrecondition;\n}\n\n/**\n * @internal\n */\nexport interface DeleteManyProps<M extends Record<string, unknown> = Record<string, unknown>> {\n path: string;\n ids: (string | number)[];\n collection?: CollectionConfig<M>;\n /** See {@link DeleteProps.hard}. */\n hard?: boolean;\n}\n\n/**\n * Addressing ONE link of a many-to-many, to set the columns it carries.\n *\n * `path` is the relation on a row — `posts/1/tags` — and `targetId` the row on\n * the far side, so the pair names exactly one junction row. Not a `SaveProps`,\n * because a save at that address means \"write the target row\", and a link's own\n * columns are not the target's: two posts sharing a tag see one tag and two\n * different links.\n *\n * @internal\n */\nexport interface UpdateRelationPivotProps {\n /** The nested relation path, e.g. `posts/1/tags`. */\n path: string;\n /** The far side's key. */\n targetId: string | number;\n /** The junction columns to set, keyed by the property key `through.properties` declares. */\n pivot: Record<string, unknown>;\n}\n\n/**\n * One operation of a {@link DataDriver.batchWrite}.\n *\n * `path` rather than a slug, because a batch entry addresses rows exactly as\n * the single-row props do and a nested path is a legal address there.\n *\n * @internal\n */\nexport interface BatchWriteOperation<M extends Record<string, unknown> = Record<string, unknown>> {\n op: \"create\" | \"update\" | \"upsert\" | \"delete\";\n path: string;\n /** Required for `update` and `delete`. May be a `$ref` marker; see `batchWrite`. */\n id?: unknown;\n values?: Partial<EntityValues<M>>;\n collection?: CollectionConfig<M>;\n /** See {@link SaveProps.onConflict}. `upsert` only. */\n onConflict?: readonly string[];\n /** Names this operation's result, for a later `$ref`. */\n ref?: string;\n}\n\n/**\n * @internal\n */\nexport interface BatchWriteProps<M extends Record<string, unknown> = Record<string, unknown>> {\n operations: BatchWriteOperation<M>[];\n}\n\nexport type FilterCombinationValidProps = {\n path: string;\n databaseId?: string;\n collection: CollectionConfig;\n filterValues: FilterValues<string>;\n sortBy?: [string, \"asc\" | \"desc\"];\n};\n\n/**\n * The integration SPI for plugging a data backend into Rebase.\n *\n * Implement this interface to connect a custom backend (or use a built-in\n * driver such as the Firestore one) and register it on\n * `<Rebase dataSources>`. Rebase wraps drivers via `buildRebaseData` and\n * routes collections to them by their `dataSource` key.\n *\n * For *consuming* data in application code, use `RebaseData` /\n * `context.data` instead — this interface is only for providing it.\n *\n * @group Datasource\n */\nexport interface DataDriver {\n\n /**\n * Key that identifies this driver\n */\n key?: string;\n\n /**\n * If the driver has been initialised\n */\n initialised?: boolean;\n\n /**\n * Fetch data from a collection\n * @param props\n * @return Promise of flat rows\n */\n fetchCollection<M extends Record<string, unknown> = Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]>;\n\n /**\n * Listen to a collection in a given path. If you don't implement this method\n * `fetchCollection` will be used instead, with no real time updates.\n * @param props\n * @return Function to cancel subscription\n */\n listenCollection?<M extends Record<string, unknown> = Record<string, unknown>>(props: ListenCollectionProps<M>): () => void;\n\n /**\n * Retrieve a single row given a path and a collection\n * @param props\n */\n fetchOne<M extends Record<string, unknown> = Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;\n\n /**\n * Get realtime updates on one row.\n * @param props\n * @return Function to cancel subscription\n */\n listenOne?<M extends Record<string, unknown> = Record<string, unknown>>(props: ListenOneProps<M>): () => void;\n\n /**\n * Save a row to the specified path\n * @param props\n */\n save<M extends Record<string, unknown> = Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>>;\n\n /**\n * Save many rows as one unit of work.\n *\n * Every row runs the same pipeline as {@link save} — callbacks, relations\n * and row-level security all still apply — but they share a single\n * transaction, so the batch either lands whole or not at all. That, and the\n * single round trip, is what makes importing tens of thousands of rows\n * viable without dropping to raw SQL.\n *\n * Optional: drivers that cannot do this leave it undefined and callers fall\n * back to `save` per row.\n */\n saveMany?<M extends Record<string, unknown> = Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]>;\n\n /**\n * Update many rows in one transaction, each addressed by id.\n *\n * Optional for the same reason `saveMany` is: a driver that cannot make the\n * batch atomic should not pretend to. The REST layer reports\n * `BULK_UNSUPPORTED` rather than silently falling back to a loop of single\n * writes, which would be neither atomic nor one round trip — the two things\n * a caller reaches for a batch to get.\n */\n updateMany?<M extends Record<string, unknown> = Record<string, unknown>>(props: UpdateManyProps<M>): Promise<Record<string, unknown>[]>;\n\n /**\n * Delete the row `props.row` addresses.\n *\n * **Resolving means the row is gone because this call removed it.** A\n * delete that matched nothing must reject with a not-found error\n * (`ApiError.notFound`, `statusCode: 404`) rather than resolving quietly.\n *\n * The rule is here rather than in each driver because the two\n * implementations answered differently and each had a test pinning its own\n * habit: Postgres threw, Mongo logged a warning and resolved. Three things\n * decide it in favour of rejecting.\n *\n * The REST layer already says 404 — `DELETE /api/data/<c>/<id>` reads the\n * row before removing it — so a quiet resolve made the driver API disagree\n * with the HTTP API about the same operation, and only in-process\n * `rebase.data` callers could see the difference.\n *\n * A caller cannot tell \"deleted\" from \"there was nothing there\" without it,\n * and those are different facts: one means the caller's model of the data\n * was right, the other that it was stale. Silence hands back the wrong one\n * and the caller carries on.\n *\n * And on a driver with row-level security, \"matched nothing\" is *also* how\n * a policy refusal arrives — Postgres filters `DELETE` through `USING`\n * rather than raising. A driver that resolves on zero rows therefore\n * reports a refused delete as a completed one, which is the defect\n * `explainZeroRowWrite` exists to prevent (see `write-denial.ts`).\n *\n * Conformance for both server drivers lives in\n * `packages/server/test/contract/delete-contract.ts`, run by each driver's\n * own suite against its own database. `packages/firebase`'s Firestore\n * driver does not honour it: `deleteDoc` resolves for a missing document\n * and reporting otherwise would cost a read on every delete. It runs in the\n * browser against Firestore's own semantics rather than behind\n * `rebase.data`, and that exception is stated here rather than left to be\n * discovered.\n */\n delete<M extends Record<string, unknown> = Record<string, unknown>>(props: DeleteProps<M>): Promise<void>;\n\n /**\n * Delete all entities from a collection.\n * @param path Collection path\n */\n deleteAll?(path: string): Promise<void>;\n\n /**\n * Set the columns ONE many-to-many link carries, leaving the membership\n * alone — `manyToMany`'s `through.properties`.\n *\n * `PATCH /api/data/<c>/<id>/<relation>/<targetId>` with a `_pivot` body\n * reaches this. The membership array cannot express it: sending one element\n * would unlink everything else, and re-sending the whole set to change one\n * value reintroduces the lost update the membership diff exists to avoid.\n *\n * Optional. A driver whose junctions carry nothing but the two keys leaves\n * it undefined, and the REST layer answers `RELATION_PIVOT_UNSUPPORTED`\n * rather than pretending the write landed.\n */\n updateRelationPivot?(props: UpdateRelationPivotProps): Promise<void>;\n\n /**\n * Delete many rows in one transaction, addressed by id.\n *\n * Ids rather than a filter, deliberately — see\n * {@link SDKCollectionClient.deleteMany}. Optional, as `saveMany` is.\n */\n deleteMany?<M extends Record<string, unknown> = Record<string, unknown>>(props: DeleteManyProps<M>): Promise<void>;\n\n /**\n * Apply a mixed list of writes across collections as one unit of work.\n *\n * The capability `saveMany` and `deleteMany` cannot express between them: a\n * batch that touches two tables. Sent as two requests those can\n * half-succeed, and the recovery — read back, work out which half landed,\n * undo it — is code nobody writes.\n *\n * Every operation runs the pipeline its single-row equivalent runs, in\n * order, in one transaction, under the caller's own role. Operations may\n * carry `{ \"$ref\": \"<name>.<field>\" }` markers in `values` or `id`, which\n * the driver resolves against the rows earlier operations wrote — the\n * driver, because inside the transaction is the only place those rows\n * exist. `@rebasepro/server` exports `resolveBatchRefs` so the resolution\n * is one implementation rather than one per driver.\n *\n * Resolves to one entry per operation, aligned to the input: the written\n * row for a create, update or upsert, and `null` for a delete.\n *\n * Optional for the same reason `saveMany` is: a driver that cannot make it\n * atomic must not pretend to. The REST layer answers `BATCH_UNSUPPORTED`\n * rather than falling back to a loop, which would be the non-atomic\n * sequence the caller reached for this to avoid.\n */\n batchWrite?<M extends Record<string, unknown> = Record<string, unknown>>(\n props: BatchWriteProps<M>\n ): Promise<(Record<string, unknown> | null)[]>;\n\n /**\n * Check if the given property is unique in the given collection\n * @param path Collection path\n * @param name of the property\n * @param value\n * @param id\n * @param collection\n * @return `true` if there are no other fields besides the given entity\n */\n checkUniqueField(\n path: string,\n name: string,\n value: unknown,\n id?: string | number,\n collection?: CollectionConfig\n ): Promise<boolean>;\n\n /**\n * Count the number of entities in a collection\n */\n count?<M extends Record<string, unknown> = Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;\n\n /**\n * Run several reads as one: `work` is handed a driver whose reads all go\n * through a single read-only transaction that reads one snapshot, under\n * this driver's identity and row-level security, and its result is\n * returned.\n *\n * A list page is a read and a count, and each was a transaction of its\n * own — the second checkout, `BEGIN`, identity setup and `COMMIT` cost as\n * many round trips as the count itself. On one snapshot the two also\n * agree: `meta.total` describes the rows the page was taken from, and an\n * `ETag` the row the body was rendered from.\n *\n * Optional: a driver without it is called directly, read by read.\n */\n readTogether?<T>(work: (driver: DataDriver) => Promise<T>): Promise<T>;\n\n /**\n * Every row a collection stores, as stored, a page at a time: soft-deleted\n * rows included, and past every read hook — no `beforeQuery`, no\n * `afterRead`, no per-field read rules.\n *\n * For whole-store sweeps run by the server itself, never for a read served\n * to anyone. `rebase storage orphans` reads it to find the files no row\n * names: read through `fetchCollection` instead, a `beforeQuery` scoping\n * rows to `context.user` (there is none in a sweep) or an `afterRead` that\n * rewrites a file field hides references, and the files they name are\n * reported as unused and deleted.\n *\n * Optional. A sweep reads a collection without read hooks through\n * `fetchCollection`, and refuses one with read hooks whose driver lacks\n * this, rather than trust what the hooks let through.\n */\n scanStoredRows?(props: {\n path: string;\n collection: CollectionConfig;\n /** Rows per page. */\n pageSize?: number;\n }): AsyncIterable<Record<string, unknown>[]>;\n\n /**\n * Check if the given filter combination is valid\n * @param props\n */\n isFilterCombinationValid?(props: Omit<FilterCombinationValidProps, \"collection\"> & {\n databaseId?: string\n }): boolean;\n\n /**\n * Get the object to generate the current time in the driver\n */\n currentTime?: () => unknown;\n\n delegateToCMSModel?: (data: unknown) => unknown;\n\n cmsToDelegateModel?: (data: unknown) => unknown;\n\n initTextSearch?: (props: {\n context: RebaseCallContext,\n path: string,\n databaseId?: string,\n collection: CollectionConfig,\n parentCollectionSlugs?: string[];\n parentEntityIds?: string[];\n }) => Promise<boolean>;\n\n /**\n * Flag to indicate if the driver has requested the initialization of the text search index\n */\n needsInitTextSearch?: boolean;\n\n // ── REST fetch capabilities ─────────────────────────────────────────\n\n /**\n * Optional REST-optimised fetch service. When present, the REST API\n * generator uses these methods instead of the generic `fetchOne` /\n * `fetchCollection` pipeline, enabling include-aware eager-loading.\n */\n restFetchService?: RestFetchService;\n\n /**\n * This driver reads the row an update or a delete addresses inside that\n * write's own transaction, and runs {@link SaveProps.precondition} /\n * {@link DeleteProps.precondition} on it there. A caller may then leave its\n * own read of the row out. A driver without it ignores the precondition,\n * so a caller must read the row itself.\n */\n readonly checksRowPreconditions?: boolean;\n\n // ── Admin capabilities ─────────────────────────────────────────────\n //\n // Admin operations are now modelled as capability-specific interfaces\n // (SQLAdmin, DocumentAdmin, SchemaAdmin) in `@rebasepro/types/backend`.\n //\n // Drivers that support admin features should expose them here.\n // Consumers should use the `isSQLAdmin()`, `isSchemaAdmin()` etc.\n // type guards to safely narrow the type before calling methods.\n\n /**\n * Return the admin capabilities of this driver.\n * @see SQLAdmin\n * @see DocumentAdmin\n * @see SchemaAdmin\n */\n admin?: import(\"../types/backend\").DatabaseAdmin;\n\n}\n\n/**\n * REST-optimised fetch service exposed by drivers that support\n * eager-loading of relations via `include`.\n *\n * The methods return flattened rows — exactly the table's columns, under their\n * own names and with the types the database returned — and included relations\n * inlined as plain nested rows. This is the shape served to app developers\n * through the REST API / SDK client.\n *\n * No synthesized `id`: identity is a primary key, which may be named anything\n * and span several columns, so an address is derived by whoever needs one (see\n * `buildCompositeId`) rather than written into the row on top of the data.\n *\n * @group DataDriver\n */\nexport interface RestFetchService {\n /**\n * Fetch a collection of flattened entities with optional relation includes.\n */\n fetchCollectionForRest(\n collectionPath: string,\n options?: {\n filter?: FilterValues<string>;\n /** An `or(...)`/`and(...)` group, applied alongside `filter`. */\n logical?: LogicalCondition;\n /** See `FetchCollectionProps.orderBy`: a field name plus `order`, or a list of tuples. */\n orderBy?: string | OrderByTuple[];\n order?: \"desc\" | \"asc\";\n limit?: number;\n offset?: number;\n startAfter?: Record<string, unknown>;\n searchString?: string;\n /** Ask each row which declared search fields matched — populates `_matches`. */\n searchExplain?: boolean;\n databaseId?: string;\n vectorSearch?: VectorSearchParams;\n /** See {@link FetchCollectionProps.withDeleted}. */\n withDeleted?: boolean | \"only\";\n /**\n * Columns to read. A projection pushed into the SELECT, not a trim\n * of the response — `excludeFromApi` still applies on top, and the\n * primary key is always read whether or not it is named.\n */\n fields?: string[];\n /** `SELECT DISTINCT` over the projection. See `FindParams.distinct`. */\n distinct?: boolean;\n },\n include?: IncludeSpec\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * The opaque cursor that continues a listing after `row`.\n *\n * On the driver rather than the route because deriving it needs the\n * collection's primary key — which may be named anything and span several\n * columns — and that is the driver's knowledge. The route holds the last\n * row and the sort keys and asks for the string.\n *\n * `undefined` where no cursor can describe the page: an ordering with no\n * stored value to compare against (relevance), or a row missing a value for\n * one of the sort keys. The listing then reports no `nextCursor` and the\n * caller pages by offset, which is what it did before cursors existed.\n *\n * Optional: a driver that cannot seek simply never issues one, and\n * `meta.nextCursor` is absent for every read it serves.\n */\n cursorFor?(\n collectionPath: string,\n row: Record<string, unknown>,\n orderBy?: OrderByTuple[]\n ): string | undefined;\n\n /**\n * `count`/`sum`/`avg`/`min`/`max` over the rows a filter selects,\n * optionally grouped.\n *\n * Optional, and the REST route answers 501 where a driver does not\n * implement it — an aggregate is not a thing to approximate, and an empty\n * result set would read as \"nothing matched\".\n *\n * Any implementation **must apply the same row-level authorization as a\n * read**. An aggregate is an efficient way to learn about rows you cannot\n * select, and `count(*)` over a table whose policies would return nothing\n * has to be zero.\n */\n aggregate?(\n collectionPath: string,\n options: {\n aggregates: { fn: \"count\" | \"sum\" | \"avg\" | \"min\" | \"max\"; field?: string; alias: string }[];\n groupBy?: string[];\n filter?: FilterValues<string>;\n logical?: LogicalCondition;\n searchString?: string;\n /** Groups to return. Applies to a grouped aggregate; one without `groupBy` is one row. */\n limit?: number;\n /** Groups to skip, for paging past `limit`. Grouped aggregates only. */\n offset?: number;\n /**\n * Sort for the groups, by a `groupBy` field or an aggregate's alias\n * (`count`, `sum_total`). Whatever it leaves tied is ordered by the\n * group keys, so the order is total and a page boundary falls in\n * the same place every time. Grouped aggregates only.\n */\n orderBy?: OrderByTuple[];\n /** See {@link FetchCollectionProps.withDeleted}. */\n withDeleted?: boolean | \"only\";\n }\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Fetch a single flattened entity with optional relation includes.\n */\n fetchOneForRest(\n collectionPath: string,\n id: string | number,\n include?: IncludeSpec,\n databaseId?: string,\n options?: {\n /** See `FetchCollectionProps.fields`. */\n fields?: string[];\n /** See {@link FetchOneProps.withDeleted}. */\n withDeleted?: boolean | \"only\";\n }\n ): Promise<Record<string, unknown> | null>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8HA,IAAa,iBAAb,cAAoC,MAAM;;CAEtC;;CAEA;;CAEA;;CAEA;;CAEA;CAEA,YAAY,SAAiB,OAAwB,CAAC,GAAG;EACrD,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,SAAS,KAAK;EACnB,KAAK,OAAO,KAAK;EACjB,KAAK,UAAU,KAAK;EACpB,KAAK,YAAY,KAAK;EACtB,KAAK,oBAAoB,KAAK;EAC9B,IAAI,KAAK,UAAU,KAAA,GAEf,KAA8B,QAAQ,KAAK;CAEnD;AACJ;;;;;;;;;;AAWA,IAAa,oBAAb,cAAuC,eAAe;;;;;;;;;;;;;;CAclD,YAAY,SAAiB,OAAwB,CAAC,GAAG;EACrD,MAAM,SAAS,IAAI;EACnB,KAAK,OAAO;CAChB;AACJ;;;;;;;;AASA,IAAM,qBAAqB,OAAO,IAAI,0BAA0B;AA8BhE,SAAgB,kBAAqB,SAAoB;CACrD,MAAM,aAAoB;EAItB,MAAM,IAAI,kBAAkB,SAAS,EAAE,MAAM,oBAAoB,CAAC;CACtE;CACA,KAA4B,sBAAsB;CAClD,OAAO;AACX;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,cAAc,QAA0B;CACpD,IAAI,OAAO,WAAW,YAAY,OAAO;CACzC,OAAQ,OAA6B,wBAAwB;AACjE;;ACxJA,IAAa,iBAAiB;;;;;;;;AAmB9B,IAAa,iBAAb,MAAa,uBAAuB,eAAe;;CAE/C;CAEA,YAAY,SAAiB,UAAkB;EAC3C,MAAM,SAAS;GAAE,QAAQ;GAAK,MAAM;EAAgB,CAAC;EACrD,KAAK,OAAO;EACZ,KAAK,WAAW;EAGhB,OAAO,eAAe,MAAM,eAAe,SAAS;CACxD;AACJ;;;;;;;;;;;;;;;;;AAkBA,SAAgB,uBACZ,UACA,OAAqD,CAAC,GAChD;CACN,MAAM,WAAW,KAAK,YAAA;CACtB,IAAI,YAAY,QAAQ,OAAO,QAAQ,CAAC,CAAC,KAAK,MAAM,IAAI;EAGpD,MAAM,SAAS,OAAO,aAAa,WAAW,WAAW,OAAO,OAAO,QAAQ,CAAC,CAAC,KAAK,CAAC;EACvF,IAAI,CAAC,OAAO,UAAU,MAAM,KAAK,SAAS,GACtC,MAAM,IAAI,eACN,sBAAsB,OAAO,QAAQ,EAAE,0CAA0C,SAAS,IAC1F,QACJ;EAEJ,IAAI,SAAS,UACT,MAAM,IAAI,eACN,aAAa,OAAO,2BAA2B,SAAS,oBAAoB,SAAS,qJAGrF,QACJ;EAEJ,OAAO;CACX;CACA,OAAO,KAAK,eACL,KAAK,sBAAA,KACL,KAAK,gBAAA;AAChB"}
@@ -2,9 +2,94 @@ import { createRequire as __rebaseCreateRequire } from "module";
2
2
  import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
- import { r as logger } from "./logger-Bt32sHFw.js";
5
+ import { a as logger } from "./logger-CW8pmTS6.js";
6
6
  //#region src/boot/ddl-bootstrap.ts
7
7
  /**
8
+ * The first keyword of every statement that changes what the database *is* —
9
+ * its tables, columns, indexes, types, functions, triggers, policies, roles and
10
+ * privileges — as opposed to the rows in it.
11
+ *
12
+ * `DO` is here because every anonymous block Rebase runs exists to make a
13
+ * guarded `GRANT`, `REVOKE` or `CREATE`; there is no row write anywhere in the
14
+ * framework that needs one. `TRUNCATE`, `VACUUM` and `ANALYZE` are not: they
15
+ * act on data and statistics, and none of them is something a change process
16
+ * reviews.
17
+ */
18
+ var SCHEMA_CHANGE_KEYWORDS = /* @__PURE__ */ new Set([
19
+ "CREATE",
20
+ "ALTER",
21
+ "DROP",
22
+ "GRANT",
23
+ "REVOKE",
24
+ "COMMENT",
25
+ "DO",
26
+ "REINDEX",
27
+ "CLUSTER",
28
+ "SECURITY",
29
+ "IMPORT",
30
+ "REASSIGN"
31
+ ]);
32
+ /** A statement with any leading whitespace and comments removed. */
33
+ function withoutLeadingComments(sqlText) {
34
+ let rest = sqlText;
35
+ for (;;) {
36
+ rest = rest.trimStart();
37
+ if (rest.startsWith("--")) {
38
+ const end = rest.indexOf("\n");
39
+ rest = end === -1 ? "" : rest.slice(end + 1);
40
+ } else if (rest.startsWith("/*")) {
41
+ const end = rest.indexOf("*/");
42
+ rest = end === -1 ? "" : rest.slice(end + 2);
43
+ } else return rest;
44
+ }
45
+ }
46
+ /** The statement's first keyword, past any leading comments, upper-cased. */
47
+ function leadingKeyword(sqlText) {
48
+ return (withoutLeadingComments(sqlText).match(/^[A-Za-z]+/)?.[0] ?? "").toUpperCase();
49
+ }
50
+ /**
51
+ * Whether a statement changes the database's structure or its privileges.
52
+ *
53
+ * Decided on the leading keyword, which is exact for every statement the
54
+ * framework writes: none of them hides DDL behind a `WITH` or a function call.
55
+ */
56
+ function isSchemaChange(sqlText) {
57
+ return SCHEMA_CHANGE_KEYWORDS.has(leadingKeyword(sqlText));
58
+ }
59
+ /**
60
+ * Raised in place of a schema change on a backend that provisions nothing.
61
+ *
62
+ * Its own class so a bootstrap step can tell "this was not run on purpose" from
63
+ * "this failed", and say the first at `debug` rather than as an error per
64
+ * statement on every boot.
65
+ */
66
+ var SchemaChangeRefusedError = class extends Error {
67
+ statement;
68
+ code = "REBASE_PROVISIONING_OFF";
69
+ constructor(statement) {
70
+ super(`Not run: this backend changes nothing in its database (provisioning is off). Statement: ${statement.trim().split("\n")[0]}`);
71
+ this.statement = statement;
72
+ this.name = "SchemaChangeRefusedError";
73
+ }
74
+ };
75
+ /**
76
+ * `exec`, with every schema change refused and every other statement passed
77
+ * through.
78
+ *
79
+ * What a store is built on when its backend provisions nothing. The store's own
80
+ * bootstrap still runs, so it reads what is there exactly as it always has and
81
+ * reports what is missing in its own words; only the statements that would
82
+ * have changed the database stop here. A guard rather than a branch per store,
83
+ * because a branch is a thing the next store can forget.
84
+ */
85
+ function refuseSchemaChanges(exec) {
86
+ return (sqlText, options) => isSchemaChange(sqlText) ? Promise.reject(new SchemaChangeRefusedError(sqlText)) : exec(sqlText, options);
87
+ }
88
+ /** {@link refuseSchemaChanges} when `provision` is `false`; `exec` itself otherwise. */
89
+ function provisioningExec(exec, provision) {
90
+ return provision === false ? refuseSchemaChanges(exec) : exec;
91
+ }
92
+ /**
8
93
  * SQLSTATEs a *simultaneous boot* can raise from statements that are otherwise
9
94
  * idempotent. Retrying is always the right answer for these: the second attempt
10
95
  * finds the object present and does nothing.
@@ -114,6 +199,10 @@ function createDdlBootstrapper(exec, scope) {
114
199
  try {
115
200
  await run();
116
201
  } catch (err) {
202
+ if (hasInCauseChain(err, (e) => e.code === "REBASE_PROVISIONING_OFF")) {
203
+ logger.debug(`[${scope}] ${label}: not run — this backend changes nothing in its database`);
204
+ return;
205
+ }
117
206
  logger.error(`[${scope}] ${label} failed`, { error: err });
118
207
  }
119
208
  };
@@ -142,6 +231,6 @@ function createDdlBootstrapper(exec, scope) {
142
231
  };
143
232
  }
144
233
  //#endregion
145
- export { isDuplicateObjectRace as a, isConcurrentDdlRace as i, createDdlBootstrapper as n, hasInCauseChain as r, CONCURRENT_DDL_SQLSTATES as t };
234
+ export { isConcurrentDdlRace as a, provisioningExec as c, hasInCauseChain as i, withoutLeadingComments as l, SchemaChangeRefusedError as n, isDuplicateObjectRace as o, createDdlBootstrapper as r, isSchemaChange as s, CONCURRENT_DDL_SQLSTATES as t };
146
235
 
147
- //# sourceMappingURL=ddl-bootstrap-BNqi2NZe.js.map
236
+ //# sourceMappingURL=ddl-bootstrap-CjRrn12l.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ddl-bootstrap-CjRrn12l.js","names":[],"sources":["../src/boot/ddl-bootstrap.ts"],"sourcesContent":["import { logger } from \"../utils/logger.js\";\n\n/**\n * Helpers for the \"create my internal table if it isn't there yet\" bootstrap\n * that several stores run at boot.\n *\n * These all look trivially safe — every statement is `IF NOT EXISTS` — and they\n * are not, for two reasons that only show up with more than one app instance:\n *\n * 1. `CREATE … IF NOT EXISTS` reads the catalog and then writes to it, and\n * those two steps are not one atomic operation. Instances starting together\n * — a rolling deploy, a replica count going from 1 to N, a crash loop\n * restarting the fleet — can both see \"absent\" and both try to create. The\n * loser gets a duplicate key on a *catalog* index instead of the silent\n * no-op the syntax appears to promise. Measured against Postgres 18: with\n * five instances booting at once, 8 of 10 `ensureTable` calls hit it.\n *\n * 2. A bootstrap written as one long `try` block therefore abandons everything\n * after the losing statement — including, in every store that has one, the\n * `REVOKE` that takes the table back off the end-user role. That revoke is\n * a security control and must not be collateral damage from a race.\n *\n * So: retry the race, contain each statement separately, and decide what to do\n * next from what actually exists rather than from who won.\n */\n\n/** A driver's `executeSql`, narrowed to what a bootstrap needs. */\nexport type SqlExec = (sqlText: string, options?: { params?: unknown[] }) => Promise<Record<string, unknown>[]>;\n\n/**\n * The first keyword of every statement that changes what the database *is* —\n * its tables, columns, indexes, types, functions, triggers, policies, roles and\n * privileges — as opposed to the rows in it.\n *\n * `DO` is here because every anonymous block Rebase runs exists to make a\n * guarded `GRANT`, `REVOKE` or `CREATE`; there is no row write anywhere in the\n * framework that needs one. `TRUNCATE`, `VACUUM` and `ANALYZE` are not: they\n * act on data and statistics, and none of them is something a change process\n * reviews.\n */\nconst SCHEMA_CHANGE_KEYWORDS = new Set([\n \"CREATE\", \"ALTER\", \"DROP\", \"GRANT\", \"REVOKE\", \"COMMENT\", \"DO\",\n \"REINDEX\", \"CLUSTER\", \"SECURITY\", \"IMPORT\", \"REASSIGN\"\n]);\n\n/** A statement with any leading whitespace and comments removed. */\nexport function withoutLeadingComments(sqlText: string): string {\n let rest = sqlText;\n for (;;) {\n rest = rest.trimStart();\n if (rest.startsWith(\"--\")) {\n const end = rest.indexOf(\"\\n\");\n rest = end === -1 ? \"\" : rest.slice(end + 1);\n } else if (rest.startsWith(\"/*\")) {\n const end = rest.indexOf(\"*/\");\n rest = end === -1 ? \"\" : rest.slice(end + 2);\n } else {\n return rest;\n }\n }\n}\n\n/** The statement's first keyword, past any leading comments, upper-cased. */\nfunction leadingKeyword(sqlText: string): string {\n return (withoutLeadingComments(sqlText).match(/^[A-Za-z]+/)?.[0] ?? \"\").toUpperCase();\n}\n\n/**\n * Whether a statement changes the database's structure or its privileges.\n *\n * Decided on the leading keyword, which is exact for every statement the\n * framework writes: none of them hides DDL behind a `WITH` or a function call.\n */\nexport function isSchemaChange(sqlText: string): boolean {\n return SCHEMA_CHANGE_KEYWORDS.has(leadingKeyword(sqlText));\n}\n\n/**\n * Raised in place of a schema change on a backend that provisions nothing.\n *\n * Its own class so a bootstrap step can tell \"this was not run on purpose\" from\n * \"this failed\", and say the first at `debug` rather than as an error per\n * statement on every boot.\n */\nexport class SchemaChangeRefusedError extends Error {\n readonly code = \"REBASE_PROVISIONING_OFF\";\n\n constructor(readonly statement: string) {\n super(\n \"Not run: this backend changes nothing in its database (provisioning is off). \" +\n `Statement: ${statement.trim().split(\"\\n\")[0]}`\n );\n this.name = \"SchemaChangeRefusedError\";\n }\n}\n\n/**\n * `exec`, with every schema change refused and every other statement passed\n * through.\n *\n * What a store is built on when its backend provisions nothing. The store's own\n * bootstrap still runs, so it reads what is there exactly as it always has and\n * reports what is missing in its own words; only the statements that would\n * have changed the database stop here. A guard rather than a branch per store,\n * because a branch is a thing the next store can forget.\n */\nexport function refuseSchemaChanges(exec: SqlExec): SqlExec {\n return (sqlText, options) => isSchemaChange(sqlText)\n ? Promise.reject(new SchemaChangeRefusedError(sqlText))\n : exec(sqlText, options);\n}\n\n/** {@link refuseSchemaChanges} when `provision` is `false`; `exec` itself otherwise. */\nexport function provisioningExec(exec: SqlExec, provision: boolean | undefined): SqlExec {\n return provision === false ? refuseSchemaChanges(exec) : exec;\n}\n\n\n/**\n * SQLSTATEs a *simultaneous boot* can raise from statements that are otherwise\n * idempotent. Retrying is always the right answer for these: the second attempt\n * finds the object present and does nothing.\n */\nexport const CONCURRENT_DDL_SQLSTATES = new Set([\n \"23505\", // unique_violation on pg_type / pg_class / pg_namespace\n \"42P06\", // duplicate_schema\n \"42P07\", // duplicate_table\n \"42710\", // duplicate_object — an index or a constraint\n \"40P01\" // deadlock_detected — two boots taking catalog locks in step\n]);\n\n/** Attempts per idempotent DDL statement, including the first. */\nexport const DDL_ATTEMPTS = 4;\nconst DDL_RETRY_BASE_MS = 40;\n\n/**\n * Walk an error's `cause` chain, stopping at the first link `visit` accepts.\n * Drizzle wraps the driver error, so nothing useful is ever on the top level.\n */\nexport function hasInCauseChain(err: unknown, visit: (e: Record<string, unknown>) => boolean): boolean {\n let current: unknown = err;\n for (let depth = 0; depth < 10 && current; depth++) {\n if (typeof current !== \"object\") break;\n const e = current as Record<string, unknown>;\n if (visit(e)) return true;\n current = e.cause;\n }\n return false;\n}\n\n/**\n * Is this the loser of a race to create something that already exists?\n *\n * Deliberately narrow. A permission failure, an unreachable database or a typo\n * in the DDL must surface on the first attempt rather than being retried four\n * times and then reported as a race that never was.\n */\nexport function isConcurrentDdlRace(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n (typeof e.code === \"string\" && CONCURRENT_DDL_SQLSTATES.has(e.code)) ||\n // SQLite and MySQL say it in words rather than in a shared SQLSTATE.\n (typeof e.message === \"string\" && /already exists/i.test(e.message))\n );\n}\n\n/**\n * SQLSTATEs that mean, unambiguously, *the object is already there*.\n *\n * A subset of {@link CONCURRENT_DDL_SQLSTATES} and a stricter question. The\n * broad set answers \"should this be retried\"; this one answers \"is it safe to\n * carry on as though the statement had succeeded\", which is a claim about the\n * end state rather than about the attempt. Deadlock is not in it — a deadlocked\n * statement did nothing and must be retried, not skipped.\n */\nconst DUPLICATE_OBJECT_SQLSTATES = new Set([\n \"42P06\", // duplicate_schema\n \"42P07\", // duplicate_table\n \"42710\" // duplicate_object — a type, an index, a constraint\n]);\n\n/**\n * Did this statement fail *because a peer already created the same object*?\n *\n * The narrow companion to {@link isConcurrentDdlRace}, for the one caller that\n * needs to tell \"someone beat me to it\" from \"this genuinely failed\": a loop\n * applying a schema plan, where treating every `23505` as a harmless race would\n * silently swallow the one that matters — a unique constraint that cannot be\n * added because the customer's existing rows violate it.\n *\n * `23505` is therefore only accepted when it names a `pg_catalog` index. That is\n * what a lost `CREATE TYPE`/`CREATE TABLE` race raises (`pg_type_typname_nsp_index`\n * is the one seen in practice); a unique violation on user data names the user's\n * own constraint and is left to the caller.\n *\n * With one exception, in {@link NOT_A_RACE_PG_INDEXES}.\n */\nexport function isDuplicateObjectRace(err: unknown): boolean {\n return hasInCauseChain(err, (e) => {\n if (typeof e.code !== \"string\") return false;\n if (DUPLICATE_OBJECT_SQLSTATES.has(e.code)) return true;\n if (e.code !== \"23505\") return false;\n // node-postgres puts the violated index in `constraint`; some paths only\n // carry it in the detail text, so check both rather than miss the race.\n const constraint = typeof e.constraint === \"string\" ? e.constraint : \"\";\n const detail = typeof e.detail === \"string\" ? e.detail : \"\";\n for (const index of NOT_A_RACE_PG_INDEXES) {\n if (constraint === index || detail.includes(index)) return false;\n }\n return constraint.startsWith(\"pg_\") || /\\bpg_[a-z_]+_index\\b/.test(detail);\n });\n}\n\n/**\n * `pg_catalog` indexes whose `23505` is never a race.\n *\n * `pg_enum_typid_label_index` is unique on (type, label), and one statement\n * violates it on its own: `CREATE TYPE t AS ENUM ('draft', 'draft')` — which is\n * what a collection with two enum entries carrying the same `id` generates.\n * Under the `pg_` prefix rule that read as \"a peer created it first\", so the\n * type was never created, boot carried on, and the column fell back to `TEXT`\n * with no enum behind it. Verified against PGlite: the duplicate `CREATE TYPE`\n * raises exactly this.\n *\n * The concurrent-boot case this rule exists for does not come through here at\n * all. Two pods adding the same label race on `ALTER TYPE … ADD VALUE`, which\n * raises `42710` (already in {@link DUPLICATE_OBJECT_SQLSTATES}) — and the\n * generator writes `ADD VALUE IF NOT EXISTS`, so it usually raises nothing.\n *\n * The duplicate `id` is rejected earlier now, by `validate-config`, which names\n * the property. This is the second line: a config that reaches the database with\n * one fails loudly instead of degrading.\n */\nconst NOT_A_RACE_PG_INDEXES = [\"pg_enum_typid_label_index\"];\n\nexport interface DdlBootstrapper {\n /**\n * Run one idempotent statement — `CREATE … IF NOT EXISTS`, `ALTER TABLE …\n * ADD COLUMN IF NOT EXISTS` — retrying the catalog race a simultaneous boot\n * produces. Never throws: a statement that cannot be made to work is logged\n * and the caller carries on to the next one.\n */\n ensureObject(label: string, sqlText: string): Promise<void>;\n\n /** Contain one step's failure so that the steps after it still run. */\n step(label: string, run: () => Promise<unknown>): Promise<void>;\n\n /**\n * Is this table there and readable? Asked with a query any SQL dialect\n * answers, rather than `to_regclass`, so a future non-Postgres SQL driver\n * gets a real answer instead of a syntax error read as \"missing\".\n */\n isReadable(table: string): Promise<boolean>;\n}\n\n/**\n * @param exec the driver's SQL escape hatch\n * @param scope log prefix identifying the caller, e.g. `\"cron-store\"`\n */\nexport function createDdlBootstrapper(exec: SqlExec, scope: string): DdlBootstrapper {\n /** Jittered, so peers that collided once do not collide again in lockstep. */\n const backoff = (attempt: number) =>\n new Promise(resolve => setTimeout(resolve, DDL_RETRY_BASE_MS * attempt * (1 + Math.random())));\n\n const step: DdlBootstrapper[\"step\"] = async (label, run) => {\n try {\n await run();\n } catch (err) {\n if (hasInCauseChain(err, e => e.code === \"REBASE_PROVISIONING_OFF\")) {\n logger.debug(`[${scope}] ${label}: not run — this backend changes nothing in its database`);\n return;\n }\n logger.error(`[${scope}] ${label} failed`, { error: err });\n }\n };\n\n return {\n step,\n\n ensureObject(label, sqlText) {\n return step(label, async () => {\n for (let attempt = 1; ; attempt++) {\n try {\n await exec(sqlText);\n return;\n } catch (err) {\n if (!isConcurrentDdlRace(err) || attempt >= DDL_ATTEMPTS) throw err;\n logger.debug(\n `[${scope}] Lost a create race for ${label} with another instance ` +\n `(attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`\n );\n await backoff(attempt);\n }\n }\n });\n },\n\n async isReadable(table) {\n try {\n await exec(`SELECT 1 FROM ${table} WHERE false`);\n return true;\n } catch {\n return false;\n }\n }\n };\n}\n"],"mappings":"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"}
@@ -83,7 +83,7 @@ export declare const RUNTIME_PROBE_PATHS: {
83
83
  * rather than to a value: the entry exists to take the decision away from the
84
84
  * project, not to make it here.
85
85
  */
86
- export declare const TOPOLOGY_ENV_VARS: readonly ["REBASE_ROLE", "REBASE_FUNCTIONS_ONLY", "REBASE_FUNCTIONS_EXCLUDE", "REBASE_FUNCTIONS_UPSTREAM", "REBASE_CRON_SCHEDULER", "REBASE_JOB_WORKERS", "REBASE_RLS_AUDIT", "REBASE_MCP_ENABLED", "REBASE_MIGRATE_ON_BOOT", "TRUSTED_PROXY_HOPS", "REBASE_RATE_LIMIT_STORE", "REBASE_REQUIRE_SCHEMA_MATCH"];
86
+ export declare const TOPOLOGY_ENV_VARS: readonly ["REBASE_ROLE", "REBASE_FUNCTIONS_ONLY", "REBASE_FUNCTIONS_EXCLUDE", "REBASE_FUNCTIONS_UPSTREAM", "REBASE_CRON_SCHEDULER", "REBASE_JOB_WORKERS", "REBASE_RLS_AUDIT", "REBASE_MCP_ENABLED", "REBASE_MIGRATE_ON_BOOT", "REBASE_PROVISION", "REBASE_PROVISION_ONLY", "TRUSTED_PROXY_HOPS", "REBASE_RATE_LIMIT_STORE", "REBASE_REQUIRE_SCHEMA_MATCH"];
87
87
  export type TopologyEnvVar = (typeof TOPOLOGY_ENV_VARS)[number];
88
88
  /** Whether a variable name is one the deployer owns rather than the project. */
89
89
  export declare function isTopologyEnvVar(name: string): name is TopologyEnvVar;
@@ -82,6 +82,12 @@ export declare function createDevEmailSink(options?: {
82
82
  * back and mail is still captured, it simply is not readable over HTTP.
83
83
  */
84
84
  export declare function registerDevEmailSink(sink: DevEmailSink): DevEmailSink;
85
+ /**
86
+ * The registered mailbox whose `sendEmail` this is — the sink a backend's
87
+ * `auth.email.sendEmail` came from — or `undefined` when it is some other
88
+ * sender, or the process is production.
89
+ */
90
+ export declare function devEmailSinkFor(sendEmail: object | undefined): DevEmailSink | undefined;
85
91
  /** The registered mailbox, if there is one. */
86
92
  export declare function activeDevEmailSink(): DevEmailSink | undefined;
87
93
  /** Forget the registered mailbox. For tests, and for a server shutting down. */
@@ -5,7 +5,7 @@ export type { EmailService, EmailSendOptions, EmailSendResult, SMTPConfig, Email
5
5
  export { SMTPEmailService, createEmailService } from "./smtp-email-service.js";
6
6
  export { createUnconfiguredEmailService } from "./unconfigured.js";
7
7
  export { html, raw, escapeHtml, RawHtml } from "./html.js";
8
- export { createDevEmailSink, extractLinks, registerDevEmailSink, activeDevEmailSink, clearActiveDevEmailSink } from "./dev-sink.js";
8
+ export { createDevEmailSink, extractLinks, registerDevEmailSink, activeDevEmailSink, devEmailSinkFor, clearActiveDevEmailSink } from "./dev-sink.js";
9
9
  export type { DevEmailSink, CapturedEmail } from "./dev-sink.js";
10
10
  export { resolveEmailLinkBase, assertEmailLinkBases } from "./link-base.js";
11
11
  export type { EmailLinkKind } from "./link-base.js";