@rebasepro/server 0.24.0 → 0.24.1-canary.gcb918fc

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 (224) hide show
  1. package/README.md +27 -2
  2. package/dist/GCSStorageController-fv6JAZ87.js +402 -0
  3. package/dist/GCSStorageController-fv6JAZ87.js.map +1 -0
  4. package/dist/S3StorageController-BNg-pXiY.js +480 -0
  5. package/dist/S3StorageController-BNg-pXiY.js.map +1 -0
  6. package/dist/api/mount-path.d.ts +30 -0
  7. package/dist/api/mount.d.ts +6 -13
  8. package/dist/api/rest/api-generator.d.ts +53 -1
  9. package/dist/api/rest/auth-collection-writes.d.ts +28 -21
  10. package/dist/api/rest/field-access-query.d.ts +28 -8
  11. package/dist/api/rest/field-ops.d.ts +19 -7
  12. package/dist/api/rest/query-body.d.ts +10 -0
  13. package/dist/api/rest/write-validation.d.ts +7 -5
  14. package/dist/api/schema-editor-routes.d.ts +10 -1
  15. package/dist/api/sql-export-routes.d.ts +8 -0
  16. package/dist/api/types.d.ts +35 -0
  17. package/dist/{ast-schema-editor-CWqS_sLJ.js → ast-schema-editor-mCeSC03x.js} +3 -3
  18. package/dist/{ast-schema-editor-CWqS_sLJ.js.map → ast-schema-editor-mCeSC03x.js.map} +1 -1
  19. package/dist/auth/access.d.ts +57 -8
  20. package/dist/auth/admin-user-ops.d.ts +24 -2
  21. package/dist/auth/admin-users-route.d.ts +1 -0
  22. package/dist/auth/api-keys/api-key-middleware.d.ts +10 -3
  23. package/dist/auth/api-keys/api-key-routes.d.ts +4 -1
  24. package/dist/auth/builtin-auth-adapter.d.ts +13 -0
  25. package/dist/auth/email-change-routes.d.ts +3 -0
  26. package/dist/auth/expired-token-sweep.d.ts +5 -1
  27. package/dist/auth/interfaces.d.ts +10 -0
  28. package/dist/auth/jwks-routes.d.ts +5 -1
  29. package/dist/auth/jwt-crypto.d.ts +7 -0
  30. package/dist/auth/jwt-keys.d.ts +4 -0
  31. package/dist/auth/jwt.d.ts +282 -166
  32. package/dist/auth/magic-link-routes.d.ts +3 -0
  33. package/dist/auth/mfa-crypto.d.ts +9 -3
  34. package/dist/auth/mfa-gate.d.ts +4 -2
  35. package/dist/auth/mfa-routes.d.ts +6 -0
  36. package/dist/auth/middleware.d.ts +9 -2
  37. package/dist/auth/otp-routes.d.ts +3 -0
  38. package/dist/auth/rate-limiter.d.ts +61 -34
  39. package/dist/auth/registration-policy.d.ts +17 -0
  40. package/dist/auth/require-auth.d.ts +1 -1
  41. package/dist/auth/routes.d.ts +16 -0
  42. package/dist/auth/session-routes.d.ts +6 -0
  43. package/dist/auth/user-picking.d.ts +37 -0
  44. package/dist/auth/verify-credential.d.ts +25 -4
  45. package/dist/{auth-DMLngxn_.js → auth-GnONzteu.js} +2869 -1871
  46. package/dist/auth-GnONzteu.js.map +1 -0
  47. package/dist/auth_adapter-CIItCTc8.js +21 -0
  48. package/dist/auth_adapter-CIItCTc8.js.map +1 -0
  49. package/dist/backend/create-backend.d.ts +281 -0
  50. package/dist/backend/default-backend.d.ts +35 -0
  51. package/dist/backend/options.d.ts +912 -0
  52. package/dist/backend/scope.d.ts +23 -0
  53. package/dist/backend/services.d.ts +54 -0
  54. package/dist/backend-DTAOsLQc.js.map +1 -1
  55. package/dist/backup/backup-common.d.ts +14 -3
  56. package/dist/backup/backup-routes.d.ts +23 -2
  57. package/dist/backup/backup-storage.d.ts +3 -1
  58. package/dist/backup/index.d.ts +3 -1
  59. package/dist/{backup-CN0s50D2.js → backup-BnvkSLHi.js} +176 -35
  60. package/dist/backup-BnvkSLHi.js.map +1 -0
  61. package/dist/boot/boot.d.ts +2 -2
  62. package/dist/boot/driver.d.ts +3 -0
  63. package/dist/boot/env.d.ts +9 -2
  64. package/dist/boot/http-server.d.ts +22 -0
  65. package/dist/boot/options.d.ts +16 -1
  66. package/dist/boot/provision.d.ts +4 -2
  67. package/dist/boot/runtime-cors.d.ts +17 -0
  68. package/dist/boot/sources.d.ts +2 -0
  69. package/dist/boot/static-routing.d.ts +9 -0
  70. package/dist/{contract-routes-fz8i4pxs.js → contract-routes-Ey6ftnKH.js} +4 -20
  71. package/dist/contract-routes-Ey6ftnKH.js.map +1 -0
  72. package/dist/cron/cron-loader.d.ts +77 -18
  73. package/dist/cron/cron-routes.d.ts +1 -1
  74. package/dist/cron/cron-scheduler.d.ts +69 -3
  75. package/dist/cron/cron-store.d.ts +33 -5
  76. package/dist/cron/define-cron.d.ts +6 -2
  77. package/dist/cron/index.d.ts +3 -3
  78. package/dist/cron-loader-B-u0snqq.js +202 -0
  79. package/dist/cron-loader-B-u0snqq.js.map +1 -0
  80. package/dist/{cron-routes-Bc-SB0Se.js → cron-routes-OD6BstUj.js} +27 -4
  81. package/dist/cron-routes-OD6BstUj.js.map +1 -0
  82. package/dist/{cron-scheduler-CYQgco86.js → cron-scheduler-idtLPoN-.js} +299 -33
  83. package/dist/cron-scheduler-idtLPoN-.js.map +1 -0
  84. package/dist/cron-store-CZ30ZBi-.js +577 -0
  85. package/dist/cron-store-CZ30ZBi-.js.map +1 -0
  86. package/dist/email/smtp-email-service.d.ts +23 -0
  87. package/dist/email/types.d.ts +18 -0
  88. package/dist/env.d.ts +0 -37
  89. package/dist/filter-dialect-O1HK6Nvr.js +1628 -0
  90. package/dist/filter-dialect-O1HK6Nvr.js.map +1 -0
  91. package/dist/{function-loader-D7o5Epjj.js → function-loader-iKhOZatS.js} +68 -38
  92. package/dist/function-loader-iKhOZatS.js.map +1 -0
  93. package/dist/{function-routes-CaNG4waN.js → function-routes-j9TwpvQe.js} +5 -4
  94. package/dist/function-routes-j9TwpvQe.js.map +1 -0
  95. package/dist/functions/context.d.ts +9 -0
  96. package/dist/functions/define-function.d.ts +10 -6
  97. package/dist/functions/function-loader.d.ts +75 -18
  98. package/dist/functions/function-routes.d.ts +2 -1
  99. package/dist/functions/index.d.ts +5 -4
  100. package/dist/functions/index.js +42 -13
  101. package/dist/functions/index.js.map +1 -1
  102. package/dist/functions/internal.d.ts +2 -2
  103. package/dist/functions/proxy.d.ts +6 -2
  104. package/dist/functions/request-timeout.d.ts +7 -5
  105. package/dist/history/history-routes.d.ts +9 -0
  106. package/dist/history/retention-sweep.d.ts +81 -0
  107. package/dist/{history-recorder-Nr8zLvoU.js → history-recorder-BfWRwmPy.js} +3 -3
  108. package/dist/{history-recorder-Nr8zLvoU.js.map → history-recorder-BfWRwmPy.js.map} +1 -1
  109. package/dist/{history-store-rcAm_xFR.js → history-store-DPofGVDp.js} +2 -2
  110. package/dist/{history-store-rcAm_xFR.js.map → history-store-DPofGVDp.js.map} +1 -1
  111. package/dist/index.d.ts +23 -10
  112. package/dist/index.es.js +24423 -20038
  113. package/dist/index.es.js.map +1 -1
  114. package/dist/init/docs.d.ts +27 -10
  115. package/dist/init/process-safety.d.ts +7 -2
  116. package/dist/init/shutdown.d.ts +18 -3
  117. package/dist/init.d.ts +49 -865
  118. package/dist/internal-tables-L2t39RdT.js +136 -0
  119. package/dist/internal-tables-L2t39RdT.js.map +1 -0
  120. package/dist/jobs/job-queue.d.ts +8 -0
  121. package/dist/jobs/job-store.d.ts +24 -0
  122. package/dist/jobs/types.d.ts +25 -4
  123. package/dist/{jobs-DqYNfquG.js → jobs-BCbflZj2.js} +234 -24
  124. package/dist/jobs-BCbflZj2.js.map +1 -0
  125. package/dist/{jwt-R6bSPMjk.js → jwt-BlvuKoR6.js} +529 -454
  126. package/dist/jwt-BlvuKoR6.js.map +1 -0
  127. package/dist/{keys-GAVZqbqx.js → keys-COzQ2aE8.js} +2 -2
  128. package/dist/{keys-GAVZqbqx.js.map → keys-COzQ2aE8.js.map} +1 -1
  129. package/dist/load-failure-L5hvfDoG.js +80 -0
  130. package/dist/load-failure-L5hvfDoG.js.map +1 -0
  131. package/dist/mcp/mcp-routes.d.ts +6 -0
  132. package/dist/mcp/mcp-tools.d.ts +8 -0
  133. package/dist/mcp/oauth-routes.d.ts +7 -0
  134. package/dist/metrics/index.d.ts +18 -0
  135. package/dist/metrics/pool-wait.d.ts +50 -0
  136. package/dist/offline-QNQgsPOq-B2yGeXkn.js +3305 -0
  137. package/dist/offline-QNQgsPOq-B2yGeXkn.js.map +1 -0
  138. package/dist/offline-store-Cm5sAX81-CALiO4Gn.js +1915 -0
  139. package/dist/offline-store-Cm5sAX81-CALiO4Gn.js.map +1 -0
  140. package/dist/{openapi-generator-DAq_XVDu.js → openapi-generator-D9zUQmEp.js} +366 -82
  141. package/dist/openapi-generator-D9zUQmEp.js.map +1 -0
  142. package/dist/policy-PdzX-Opd.js +121 -0
  143. package/dist/policy-PdzX-Opd.js.map +1 -0
  144. package/dist/project_manifest-R6y_KHms.js +59 -0
  145. package/dist/project_manifest-R6y_KHms.js.map +1 -0
  146. package/dist/{proxy-qRlqeUmO.js → proxy-CkTlMGmh.js} +4 -3
  147. package/dist/proxy-CkTlMGmh.js.map +1 -0
  148. package/dist/{query-parser-BgiKJKvc.js → query-parser-4WkMoA_K.js} +510 -15
  149. package/dist/query-parser-4WkMoA_K.js.map +1 -0
  150. package/dist/{request-timeout-DgH7j8qO.js → request-timeout-Dnl2_OBc.js} +15 -8
  151. package/dist/request-timeout-Dnl2_OBc.js.map +1 -0
  152. package/dist/resumable-upload-threshold-CKv-Ry0l.js +24 -0
  153. package/dist/resumable-upload-threshold-CKv-Ry0l.js.map +1 -0
  154. package/dist/schema-edit/apply-schema-change.d.ts +10 -0
  155. package/dist/schema-edit/local-source.d.ts +29 -0
  156. package/dist/schema-edit/remote-source.d.ts +13 -3
  157. package/dist/schema-edit/source-editor.d.ts +28 -0
  158. package/dist/{schema-editor-routes-oIyuWl3L.js → schema-editor-routes-C11Tumyd.js} +54 -10
  159. package/dist/schema-editor-routes-C11Tumyd.js.map +1 -0
  160. package/dist/services/outbound-post.d.ts +50 -0
  161. package/dist/services/outbound-url-guard.d.ts +34 -5
  162. package/dist/services/routed-realtime-service.d.ts +5 -0
  163. package/dist/services/webhook-service.d.ts +66 -14
  164. package/dist/singleton.d.ts +9 -5
  165. package/dist/soft-delete-params-DfV3vVN5.js +668 -0
  166. package/dist/soft-delete-params-DfV3vVN5.js.map +1 -0
  167. package/dist/sql-export-routes-ClRwzIsC.js +240 -0
  168. package/dist/sql-export-routes-ClRwzIsC.js.map +1 -0
  169. package/dist/{src-CatHFUym.js → src-D5Y3N-S4.js} +2 -1
  170. package/dist/{src-CatHFUym.js.map → src-D5Y3N-S4.js.map} +1 -1
  171. package/dist/storage/GCSStorageController.d.ts +46 -2
  172. package/dist/storage/LocalStorageController.d.ts +67 -2
  173. package/dist/storage/S3StorageController.d.ts +37 -3
  174. package/dist/storage/image-transform.d.ts +5 -0
  175. package/dist/storage/maintenance-routes.d.ts +23 -0
  176. package/dist/storage/maintenance.d.ts +126 -0
  177. package/dist/storage/rendition-cache.d.ts +9 -11
  178. package/dist/storage/request-principals.d.ts +30 -0
  179. package/dist/storage/routes.d.ts +20 -0
  180. package/dist/storage/source-variables.d.ts +10 -0
  181. package/dist/storage/streams.d.ts +42 -0
  182. package/dist/storage/tus-handler.d.ts +46 -2
  183. package/dist/storage/types.d.ts +82 -7
  184. package/dist/storage/upload-validation.d.ts +13 -0
  185. package/dist/storage-resumable-VO-cMpse-C_YX71pO.js +125 -0
  186. package/dist/storage-resumable-VO-cMpse-C_YX71pO.js.map +1 -0
  187. package/dist/strings.es-BeZWAodn.js +256 -0
  188. package/dist/strings.es-BeZWAodn.js.map +1 -0
  189. package/dist/upload-validation-Dd-fnsbA.js +172 -0
  190. package/dist/upload-validation-Dd-fnsbA.js.map +1 -0
  191. package/dist/utils/byte-size.d.ts +10 -0
  192. package/dist/utils/compression.d.ts +6 -0
  193. package/dist/utils/load-failure.d.ts +14 -0
  194. package/package.json +5 -5
  195. package/dist/GCSStorageController-BSiP1c-f.js +0 -256
  196. package/dist/GCSStorageController-BSiP1c-f.js.map +0 -1
  197. package/dist/S3StorageController-CAwFRgjV.js +0 -276
  198. package/dist/S3StorageController-CAwFRgjV.js.map +0 -1
  199. package/dist/auth-DMLngxn_.js.map +0 -1
  200. package/dist/backup-CN0s50D2.js.map +0 -1
  201. package/dist/contract-routes-fz8i4pxs.js.map +0 -1
  202. package/dist/cron-loader-CwaANlOG.js +0 -118
  203. package/dist/cron-loader-CwaANlOG.js.map +0 -1
  204. package/dist/cron-routes-Bc-SB0Se.js.map +0 -1
  205. package/dist/cron-scheduler-CYQgco86.js.map +0 -1
  206. package/dist/cron-store-D2Q9-Aco.js +0 -300
  207. package/dist/cron-store-D2Q9-Aco.js.map +0 -1
  208. package/dist/dynamic-import-X40pTZUQ.js +0 -23
  209. package/dist/dynamic-import-X40pTZUQ.js.map +0 -1
  210. package/dist/function-loader-D7o5Epjj.js.map +0 -1
  211. package/dist/function-routes-CaNG4waN.js.map +0 -1
  212. package/dist/jobs-DqYNfquG.js.map +0 -1
  213. package/dist/jwt-R6bSPMjk.js.map +0 -1
  214. package/dist/openapi-generator-DAq_XVDu.js.map +0 -1
  215. package/dist/proxy-qRlqeUmO.js.map +0 -1
  216. package/dist/query-parser-BgiKJKvc.js.map +0 -1
  217. package/dist/request-timeout-DgH7j8qO.js.map +0 -1
  218. package/dist/schema-editor-routes-oIyuWl3L.js.map +0 -1
  219. package/dist/soft-delete-params-BWPilMPF.js +0 -59
  220. package/dist/soft-delete-params-BWPilMPF.js.map +0 -1
  221. package/dist/src-I3aG1PcY.js +0 -6870
  222. package/dist/src-I3aG1PcY.js.map +0 -1
  223. package/dist/types-BfKcm9do.js +0 -39
  224. package/dist/types-BfKcm9do.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron-store-CZ30ZBi-.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 } 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(driver: DataDriver): Required<CronStore> | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\"⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.\");\n return undefined;\n }\n\n const exec = (sqlText: string, options?: { params?: unknown[] }) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n /** 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 if (!claimsReady) {\n logger.error(\n `❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. ` +\n \"With more than one app instance, every instance will now run every job on every tick.\"\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);\n }\n if (!stateReady) {\n logger.warn(\n `⚠️ [cron-store] ${STATE_TABLE} is unavailable — pausing a job reaches only the process ` +\n \"that served the request, and jobs run as their code declares on every other one. \" +\n \"A manual trigger is not kept off a run another process is making.\"\n );\n }\n 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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"}
@@ -1,4 +1,10 @@
1
1
  import { EmailConfig, EmailSendOptions, EmailSendResult, EmailService } from "./types.js";
2
+ /**
3
+ * Connect, greeting and socket-silence timeout for SMTP, unless
4
+ * `smtp.timeoutMs` says otherwise. Under the 60 seconds a proxy commonly
5
+ * allows, because the auth routes await their mail inside the request.
6
+ */
7
+ export declare const DEFAULT_SMTP_TIMEOUT_MS = 30000;
2
8
  /**
3
9
  * SMTP Email Service implementation using Nodemailer
4
10
  */
@@ -17,6 +23,10 @@ export declare class SMTPEmailService implements EmailService {
17
23
  * life of the process.
18
24
  */
19
25
  private transporterReady;
26
+ /** Sends between their start and their end — the pool is never closed under one. */
27
+ private sending;
28
+ /** Closes the pool once it has been unused for {@link POOL_IDLE_CLOSE_MS}. */
29
+ private idleTimer;
20
30
  constructor(config: EmailConfig);
21
31
  /**
22
32
  * Lazily initialize the SMTP transporter on first use
@@ -31,6 +41,19 @@ export declare class SMTPEmailService implements EmailService {
31
41
  * Send an email using SMTP or custom send function
32
42
  */
33
43
  send(options: EmailSendOptions): Promise<EmailSendResult>;
44
+ private sendOverSmtp;
45
+ /** Arm the idle close once nothing is sending. */
46
+ private closeWhenIdle;
47
+ /**
48
+ * Close the pool and forget it; the next send builds a fresh one. A closed
49
+ * nodemailer pool accepts nothing more, so it cannot be kept and reused.
50
+ */
51
+ private releaseTransporter;
52
+ /**
53
+ * Close the connection pool now, rather than when it next goes idle — for
54
+ * a shutdown. A send after this opens a new pool.
55
+ */
56
+ close(): void;
34
57
  /**
35
58
  * Verify SMTP connection (useful for startup checks)
36
59
  */
@@ -20,6 +20,24 @@ export interface SMTPConfig {
20
20
  pass: string;
21
21
  };
22
22
  name?: string;
23
+ /**
24
+ * How many connections to the server a burst of mail may hold open at
25
+ * once. Default 5. Connections are reused from one message to the next
26
+ * and closed a few seconds after the last send, so a quiet server holds
27
+ * none. Lower it for a provider that caps concurrent connections per
28
+ * mailbox — Office 365 SMTP AUTH allows 3.
29
+ */
30
+ maxConnections?: number;
31
+ /**
32
+ * How long to wait for the server to accept the connection, to greet, and
33
+ * between any two of its replies, in milliseconds. Default 30000.
34
+ *
35
+ * Invitation, verification and reset mails are sent while the request that
36
+ * asked for them waits, so this bounds that request too. nodemailer's own
37
+ * defaults — two minutes to connect, ten minutes of silence — outlast
38
+ * every proxy in front of the server.
39
+ */
40
+ timeoutMs?: number;
23
41
  }
24
42
  /**
25
43
  * Template function for password reset emails
package/dist/env.d.ts CHANGED
@@ -118,43 +118,6 @@ export declare function areSecretsEphemeral(input: {
118
118
  /** Whether the cache can be written to at all. */
119
119
  storeUsable: boolean;
120
120
  }): boolean;
121
- /**
122
- * Load and validate the Rebase environment configuration from `process.env`.
123
- *
124
- * Call this **after** your `.env` file has been loaded (via `dotenv`, `--env-file`,
125
- * container injection, etc.). This function does not load `.env` files itself —
126
- * that is a deployment concern, not a framework concern.
127
- *
128
- * Behavior:
129
- * - Auto-generates ephemeral `JWT_SECRET` and `REBASE_SERVICE_KEY` in
130
- * non-production mode so developers can start without manual setup.
131
- * - Blocks auto-generated secrets in production.
132
- * - Returns a fully typed, validated env object.
133
- *
134
- * Use `extend` to add your own typed env variables on top of the base Rebase schema:
135
- *
136
- * @example
137
- * ```ts
138
- * import dotenv from "dotenv";
139
- * import { z } from "zod";
140
- * import { loadEnv } from "@rebasepro/server";
141
- *
142
- * dotenv.config({ path: "../../.env" });
143
- *
144
- * // Basic — just Rebase env vars:
145
- * export const env = loadEnv();
146
- *
147
- * // Extended — add your own typed vars:
148
- * export const env = loadEnv({
149
- * extend: z.object({
150
- * SMTP_HOST: z.string().optional(),
151
- * STRIPE_SECRET_KEY: z.string(),
152
- * })
153
- * });
154
- * // env.SMTP_HOST → string | undefined (fully typed)
155
- * // env.STRIPE_SECRET_KEY → string (validated, required)
156
- * ```
157
- */
158
121
  export declare function loadEnv(): RebaseEnv;
159
122
  export declare function loadEnv<E extends z.ZodObject<z.ZodRawShape>>(options: {
160
123
  extend: E;