@rebasepro/server 0.25.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{GCSStorageController-C7ARrDAp.js → GCSStorageController-CRd9bFrc.js} +3 -3
- package/dist/{GCSStorageController-C7ARrDAp.js.map → GCSStorageController-CRd9bFrc.js.map} +1 -1
- package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-D00EFa5P.js} +3 -3
- package/dist/{S3StorageController-qevJ0pVE.js.map → S3StorageController-D00EFa5P.js.map} +1 -1
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/{auth-CteGWxsL.js → auth-Dkvo2tTJ.js} +26 -18
- package/dist/auth-Dkvo2tTJ.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +4 -0
- package/dist/{backup-Dq0lyJ_D.js → backup-B9m3cb3X.js} +7 -7
- package/dist/{backup-Dq0lyJ_D.js.map → backup-B9m3cb3X.js.map} +1 -1
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CsFXW7j4.js} +2 -2
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CsFXW7j4.js.map} +1 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-Dj6lA4HQ.js} +2 -2
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-Dj6lA4HQ.js.map} +1 -1
- package/dist/{cron-routes-Dhf82HzT.js → cron-routes-CwbLZguO.js} +5 -5
- package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-CwbLZguO.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-B04ACoUE.js} +2 -2
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-B04ACoUE.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DkyfuLOH.js} +3 -3
- package/dist/{cron-store-DEpVstUU.js.map → cron-store-DkyfuLOH.js.map} +1 -1
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-Ck20GSIX.js} +2 -2
- package/dist/{ddl-bootstrap-BNqi2NZe.js.map → ddl-bootstrap-Ck20GSIX.js.map} +1 -1
- package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{function-loader-DbCSoNJj.js → function-loader-KKDqTcIr.js} +2 -2
- package/dist/{function-loader-DbCSoNJj.js.map → function-loader-KKDqTcIr.js.map} +1 -1
- package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/index.js +18 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-B2rkuhuy.js} +2 -2
- package/dist/{history-recorder-RZ1Ow4YY.js.map → history-recorder-B2rkuhuy.js.map} +1 -1
- package/dist/index.es.js +529 -113
- package/dist/index.es.js.map +1 -1
- package/dist/{jobs-8G-_agld.js → jobs-h1WRq3cp.js} +3 -3
- package/dist/{jobs-8G-_agld.js.map → jobs-h1WRq3cp.js.map} +1 -1
- package/dist/{jwt-YhciR93H.js → jwt-D3oFauZI.js} +2 -2
- package/dist/{jwt-YhciR93H.js.map → jwt-D3oFauZI.js.map} +1 -1
- package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
- package/dist/logger-CW8pmTS6.js.map +1 -0
- package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-BjHr6upl.js} +2 -2
- package/dist/{openapi-generator-BamY6-ga.js.map → openapi-generator-BjHr6upl.js.map} +1 -1
- package/dist/{proxy-BEKbsil6.js → proxy-CH8Jsovg.js} +2 -2
- package/dist/{proxy-BEKbsil6.js.map → proxy-CH8Jsovg.js.map} +1 -1
- package/dist/{query-parser-CGohjyWI.js → query-parser-otFZqJsC.js} +3 -3
- package/dist/{query-parser-CGohjyWI.js.map → query-parser-otFZqJsC.js.map} +1 -1
- package/dist/{request-timeout-CRny5KD0.js → request-timeout-59crS-9u.js} +3 -3
- package/dist/{request-timeout-CRny5KD0.js.map → request-timeout-59crS-9u.js.map} +1 -1
- package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Bsut0bCC.js} +2 -2
- package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Bsut0bCC.js.map} +1 -1
- package/dist/{soft-delete-params-BAgpmBvp.js → soft-delete-params-BmlVwecC.js} +2 -2
- package/dist/{soft-delete-params-BAgpmBvp.js.map → soft-delete-params-BmlVwecC.js.map} +1 -1
- package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-DmB_uZ4l.js} +3 -3
- package/dist/{sql-export-routes-BgTJ7K3S.js.map → sql-export-routes-DmB_uZ4l.js.map} +1 -1
- package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/{upload-validation-I9cCUBIW.js.map → upload-validation-Bgo626uY.js.map} +1 -1
- package/dist/utils/dev-dependency-command.d.ts +15 -0
- package/dist/utils/dev-port.d.ts +8 -0
- package/dist/utils/logger.d.ts +24 -0
- package/package.json +5 -5
- package/dist/auth-CteGWxsL.js.map +0 -1
- package/dist/errors-_ytoGi8A.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cron-store-DEpVstUU.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
|
+
{"version":3,"file":"cron-store-DkyfuLOH.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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"}
|
|
@@ -2,7 +2,7 @@ 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 {
|
|
5
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
6
6
|
//#region src/boot/ddl-bootstrap.ts
|
|
7
7
|
/**
|
|
8
8
|
* SQLSTATEs a *simultaneous boot* can raise from statements that are otherwise
|
|
@@ -144,4 +144,4 @@ function createDdlBootstrapper(exec, scope) {
|
|
|
144
144
|
//#endregion
|
|
145
145
|
export { isDuplicateObjectRace as a, isConcurrentDdlRace as i, createDdlBootstrapper as n, hasInCauseChain as r, CONCURRENT_DDL_SQLSTATES as t };
|
|
146
146
|
|
|
147
|
-
//# sourceMappingURL=ddl-bootstrap-
|
|
147
|
+
//# sourceMappingURL=ddl-bootstrap-Ck20GSIX.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ddl-bootstrap-BNqi2NZe.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/**\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 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":";;;;;;;;;;;AAmCA,IAAa,2CAA2B,IAAI,IAAI;CAC5C;CACA;CACA;CACA;CACA;AACJ,CAAC;AAID,IAAM,oBAAoB;;;;;AAM1B,SAAgB,gBAAgB,KAAc,OAAyD;CACnG,IAAI,UAAmB;CACvB,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,SAAS,SAAS;EAChD,IAAI,OAAO,YAAY,UAAU;EACjC,MAAM,IAAI;EACV,IAAI,MAAM,CAAC,GAAG,OAAO;EACrB,UAAU,EAAE;CAChB;CACA,OAAO;AACX;;;;;;;;AASA,SAAgB,oBAAoB,KAAuB;CACvD,OAAO,gBAAgB,MAAM,MACxB,OAAO,EAAE,SAAS,YAAY,yBAAyB,IAAI,EAAE,IAAI,KAEjE,OAAO,EAAE,YAAY,YAAY,kBAAkB,KAAK,EAAE,OAAO,CACtE;AACJ;;;;;;;;;;AAWA,IAAM,6CAA6B,IAAI,IAAI;CACvC;CACA;CACA;AACJ,CAAC;;;;;;;;;;;;;;;;;AAkBD,SAAgB,sBAAsB,KAAuB;CACzD,OAAO,gBAAgB,MAAM,MAAM;EAC/B,IAAI,OAAO,EAAE,SAAS,UAAU,OAAO;EACvC,IAAI,2BAA2B,IAAI,EAAE,IAAI,GAAG,OAAO;EACnD,IAAI,EAAE,SAAS,SAAS,OAAO;EAG/B,MAAM,aAAa,OAAO,EAAE,eAAe,WAAW,EAAE,aAAa;EACrE,MAAM,SAAS,OAAO,EAAE,WAAW,WAAW,EAAE,SAAS;EACzD,KAAK,MAAM,SAAS,uBAChB,IAAI,eAAe,SAAS,OAAO,SAAS,KAAK,GAAG,OAAO;EAE/D,OAAO,WAAW,WAAW,KAAK,KAAK,uBAAuB,KAAK,MAAM;CAC7E,CAAC;AACL;;;;;;;;;;;;;;;;;;;;;AAsBA,IAAM,wBAAwB,CAAC,2BAA2B;;;;;AA0B1D,SAAgB,sBAAsB,MAAe,OAAgC;;CAEjF,MAAM,WAAW,YACb,IAAI,SAAQ,YAAW,WAAW,SAAS,oBAAoB,WAAW,IAAI,KAAK,OAAO,EAAE,CAAC;CAEjG,MAAM,OAAgC,OAAO,OAAO,QAAQ;EACxD,IAAI;GACA,MAAM,IAAI;EACd,SAAS,KAAK;GACV,OAAO,MAAM,IAAI,MAAM,IAAI,MAAM,UAAU,EAAE,OAAO,IAAI,CAAC;EAC7D;CACJ;CAEA,OAAO;EACH;EAEA,aAAa,OAAO,SAAS;GACzB,OAAO,KAAK,OAAO,YAAY;IAC3B,KAAK,IAAI,UAAU,IAAK,WACpB,IAAI;KACA,MAAM,KAAK,OAAO;KAClB;IACJ,SAAS,KAAK;KACV,IAAI,CAAC,oBAAoB,GAAG,KAAK,WAAA,GAAyB,MAAM;KAChE,OAAO,MACH,IAAI,MAAM,2BAA2B,MAAM,kCAC/B,QAAQ,eACxB;KACA,MAAM,QAAQ,OAAO;IACzB;GAER,CAAC;EACL;EAEA,MAAM,WAAW,OAAO;GACpB,IAAI;IACA,MAAM,KAAK,iBAAiB,MAAM,aAAa;IAC/C,OAAO;GACX,QAAQ;IACJ,OAAO;GACX;EACJ;CACJ;AACJ"}
|
|
1
|
+
{"version":3,"file":"ddl-bootstrap-Ck20GSIX.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/**\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 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":";;;;;;;;;;;AAmCA,IAAa,2CAA2B,IAAI,IAAI;CAC5C;CACA;CACA;CACA;CACA;AACJ,CAAC;AAID,IAAM,oBAAoB;;;;;AAM1B,SAAgB,gBAAgB,KAAc,OAAyD;CACnG,IAAI,UAAmB;CACvB,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,SAAS,SAAS;EAChD,IAAI,OAAO,YAAY,UAAU;EACjC,MAAM,IAAI;EACV,IAAI,MAAM,CAAC,GAAG,OAAO;EACrB,UAAU,EAAE;CAChB;CACA,OAAO;AACX;;;;;;;;AASA,SAAgB,oBAAoB,KAAuB;CACvD,OAAO,gBAAgB,MAAM,MACxB,OAAO,EAAE,SAAS,YAAY,yBAAyB,IAAI,EAAE,IAAI,KAEjE,OAAO,EAAE,YAAY,YAAY,kBAAkB,KAAK,EAAE,OAAO,CACtE;AACJ;;;;;;;;;;AAWA,IAAM,6CAA6B,IAAI,IAAI;CACvC;CACA;CACA;AACJ,CAAC;;;;;;;;;;;;;;;;;AAkBD,SAAgB,sBAAsB,KAAuB;CACzD,OAAO,gBAAgB,MAAM,MAAM;EAC/B,IAAI,OAAO,EAAE,SAAS,UAAU,OAAO;EACvC,IAAI,2BAA2B,IAAI,EAAE,IAAI,GAAG,OAAO;EACnD,IAAI,EAAE,SAAS,SAAS,OAAO;EAG/B,MAAM,aAAa,OAAO,EAAE,eAAe,WAAW,EAAE,aAAa;EACrE,MAAM,SAAS,OAAO,EAAE,WAAW,WAAW,EAAE,SAAS;EACzD,KAAK,MAAM,SAAS,uBAChB,IAAI,eAAe,SAAS,OAAO,SAAS,KAAK,GAAG,OAAO;EAE/D,OAAO,WAAW,WAAW,KAAK,KAAK,uBAAuB,KAAK,MAAM;CAC7E,CAAC;AACL;;;;;;;;;;;;;;;;;;;;;AAsBA,IAAM,wBAAwB,CAAC,2BAA2B;;;;;AA0B1D,SAAgB,sBAAsB,MAAe,OAAgC;;CAEjF,MAAM,WAAW,YACb,IAAI,SAAQ,YAAW,WAAW,SAAS,oBAAoB,WAAW,IAAI,KAAK,OAAO,EAAE,CAAC;CAEjG,MAAM,OAAgC,OAAO,OAAO,QAAQ;EACxD,IAAI;GACA,MAAM,IAAI;EACd,SAAS,KAAK;GACV,OAAO,MAAM,IAAI,MAAM,IAAI,MAAM,UAAU,EAAE,OAAO,IAAI,CAAC;EAC7D;CACJ;CAEA,OAAO;EACH;EAEA,aAAa,OAAO,SAAS;GACzB,OAAO,KAAK,OAAO,YAAY;IAC3B,KAAK,IAAI,UAAU,IAAK,WACpB,IAAI;KACA,MAAM,KAAK,OAAO;KAClB;IACJ,SAAS,KAAK;KACV,IAAI,CAAC,oBAAoB,GAAG,KAAK,WAAA,GAAyB,MAAM;KAChE,OAAO,MACH,IAAI,MAAM,2BAA2B,MAAM,kCAC/B,QAAQ,eACxB;KACA,MAAM,QAAQ,OAAO;IACzB;GAER,CAAC;EACL;EAEA,MAAM,WAAW,OAAO;GACpB,IAAI;IACA,MAAM,KAAK,iBAAiB,MAAM,aAAa;IAC/C,OAAO;GACX,QAAQ;IACJ,OAAO;GACX;EACJ;CACJ;AACJ"}
|
|
@@ -2,7 +2,66 @@ 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 {
|
|
5
|
+
import { a as logger, d as hostEnv } from "./logger-CW8pmTS6.js";
|
|
6
|
+
//#region src/api/closed-database.ts
|
|
7
|
+
/**
|
|
8
|
+
* A database closed to new connections (`ALLOW_CONNECTIONS false`), read off
|
|
9
|
+
* the error a refused connection raises, and the operator's line that names it.
|
|
10
|
+
*
|
|
11
|
+
* Its own module rather than in `errors.ts`, which the package root re-exports
|
|
12
|
+
* whole: these are the server's to use, not part of its API.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
/** How often the closed-database line is said: once a minute, as the other database lines in `errors.ts` are. */
|
|
17
|
+
var CLOSED_WARNING_INTERVAL_MS = 6e4;
|
|
18
|
+
/** When the closed-database line was last said. */
|
|
19
|
+
var lastClosedWarningAt = 0;
|
|
20
|
+
/**
|
|
21
|
+
* The database that refused the connection because it is closed to new ones
|
|
22
|
+
* (`ALLOW_CONNECTIONS false`), or null.
|
|
23
|
+
*
|
|
24
|
+
* Postgres says it as `database "x" is not currently accepting connections`,
|
|
25
|
+
* SQLSTATE 55000. That code also covers statements the database judged
|
|
26
|
+
* (`lastval is not yet defined in this session`), so the sentence is what is
|
|
27
|
+
* matched — through Drizzle's `cause` and a dual-stack `AggregateError`. The
|
|
28
|
+
* driver's CLI reads it the same way (`closedDatabaseName` in
|
|
29
|
+
* `@rebasepro/server-postgres`'s `cli-errors.ts`).
|
|
30
|
+
*/
|
|
31
|
+
function closedDatabaseName(error, depth = 0) {
|
|
32
|
+
if (typeof error !== "object" || error === null || depth > 8) return null;
|
|
33
|
+
const message = Reflect.get(error, "message");
|
|
34
|
+
if (typeof message === "string") {
|
|
35
|
+
const named = /database "(.+)" is not currently accepting connections/.exec(message);
|
|
36
|
+
if (named) return named[1];
|
|
37
|
+
}
|
|
38
|
+
const inner = Reflect.get(error, "errors");
|
|
39
|
+
if (Array.isArray(inner)) for (const item of inner) {
|
|
40
|
+
const named = closedDatabaseName(item, depth + 1);
|
|
41
|
+
if (named) return named;
|
|
42
|
+
}
|
|
43
|
+
return closedDatabaseName(Reflect.get(error, "cause"), depth + 1);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The operator's line for a closed database, at most once a minute, when
|
|
47
|
+
* `error` is one: which database, that nothing is lost, and what opens it. `true`
|
|
48
|
+
* when it was one — said now or within the minute — so the caller says nothing
|
|
49
|
+
* more general about it.
|
|
50
|
+
*
|
|
51
|
+
* Nothing in a development database closes one but `rebase db branch create
|
|
52
|
+
* --force`, which closes its source for the copy; one stopped part-way leaves
|
|
53
|
+
* it closed, and every `rebase db branch` command opens it again first.
|
|
54
|
+
*/
|
|
55
|
+
function warnIfDatabaseClosed(error) {
|
|
56
|
+
const database = closedDatabaseName(error);
|
|
57
|
+
if (database === null) return false;
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (now - lastClosedWarningAt < CLOSED_WARNING_INTERVAL_MS) return true;
|
|
60
|
+
lastClosedWarningAt = now;
|
|
61
|
+
logger.error(`❌ Database "${database}" is closed to new connections (ALLOW_CONNECTIONS false), so requests that need it are answered 503 DATABASE_UNAVAILABLE. Nothing in it is lost: \`rebase db branch create --force\` closes its source for the copy, and one stopped part-way leaves it closed. Open it again with \`rebase db branch list\` (every \`rebase db branch\` command opens it first), or run ALTER DATABASE "${database.replace(/"/g, "\"\"")}" ALLOW_CONNECTIONS true; connected to the server's postgres database. Not repeated for a minute.`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
6
65
|
//#region src/api/errors.ts
|
|
7
66
|
/**
|
|
8
67
|
* A stale caller's schema stamp, as the cause of the error it explains.
|
|
@@ -193,6 +252,7 @@ function isSocketFailure(error, depth = 0) {
|
|
|
193
252
|
*/
|
|
194
253
|
function isDatabaseUnreachable(error) {
|
|
195
254
|
if (typeof error !== "object" || error === null) return false;
|
|
255
|
+
if (closedDatabaseName(error) !== null) return true;
|
|
196
256
|
const reported = databaseReport(error);
|
|
197
257
|
if (reported !== void 0) return reported;
|
|
198
258
|
const socketFailureOf = (candidate) => typeof candidate === "object" && candidate !== null && isSocketFailure(candidate) && Reflect.get(candidate, "$metadata") === void 0;
|
|
@@ -530,9 +590,10 @@ var errorHandler = (err, c) => {
|
|
|
530
590
|
} else if (unreachable) {
|
|
531
591
|
code = "DATABASE_UNAVAILABLE";
|
|
532
592
|
statusCode = 503;
|
|
593
|
+
const closedDatabase = closedDatabaseName(error);
|
|
533
594
|
const reason = describeConnectionFailure(error);
|
|
534
|
-
logMessage = `The database could not be reached: ${reason}.`;
|
|
535
|
-
warnDatabaseUnreachable(reason);
|
|
595
|
+
logMessage = closedDatabase !== null ? `Database "${closedDatabase}" is closed to new connections.` : `The database could not be reached: ${reason}.`;
|
|
596
|
+
if (!warnIfDatabaseClosed(error)) warnDatabaseUnreachable(reason);
|
|
536
597
|
c.header("Retry-After", String(DATABASE_RETRY_AFTER_SECONDS));
|
|
537
598
|
} else if (upstream) {
|
|
538
599
|
code = "SERVICE_UNAVAILABLE";
|
|
@@ -669,6 +730,6 @@ function codeToStatus(code) {
|
|
|
669
730
|
}[code];
|
|
670
731
|
}
|
|
671
732
|
//#endregion
|
|
672
|
-
export { isDataException as a, errorHandler as i, DATABASE_UNAVAILABLE_MESSAGE as n, isDatabaseUnreachable as o, declaredErrorAnswer as r, schemaDriftRemedy as s, ApiError as t };
|
|
733
|
+
export { isDataException as a, warnIfDatabaseClosed as c, errorHandler as i, DATABASE_UNAVAILABLE_MESSAGE as n, isDatabaseUnreachable as o, declaredErrorAnswer as r, schemaDriftRemedy as s, ApiError as t };
|
|
673
734
|
|
|
674
|
-
//# sourceMappingURL=errors-
|
|
735
|
+
//# sourceMappingURL=errors-OMWkKSH-.js.map
|