@rebasepro/server 0.17.0 → 0.17.1-canary.gdc3e9e4

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.
@@ -58,6 +58,19 @@ export interface BootOptions {
58
58
  * rather than which of its processes does.
59
59
  */
60
60
  provisionSchema?: boolean;
61
+ /**
62
+ * A bundle already on disk to fall back to if the fetch fails.
63
+ *
64
+ * Set by the container entrypoint when it decides to re-fetch a bundle that
65
+ * is *already there* — see `STALE_ON_DISK` in `infra/docker/entrypoint.mjs`.
66
+ * Without it, a failed download means the runtime dies holding a complete,
67
+ * working bundle it was told to ignore.
68
+ *
69
+ * Only consulted when the fetch throws. A successful fetch always wins,
70
+ * because preferring the freshly-downloaded copy is the entire point of the
71
+ * staleness check.
72
+ */
73
+ bundleFallbackDir?: string;
61
74
  }
62
75
  /**
63
76
  * Boot a Rebase runtime from a built bundle.
@@ -35,6 +35,18 @@ export declare const BUNDLE_FETCH_DIR_ENV = "REBASE_BUNDLE_FETCH_DIR";
35
35
  export declare const BUNDLE_SOURCE_FILENAME = ".rebase-bundle-source";
36
36
  /** What the tree in `destination` was fetched from, or null if unknown. */
37
37
  export declare function unpackedBundleSource(destination: string): string | null;
38
+ /**
39
+ * A bundle directory worth falling back to, or undefined.
40
+ *
41
+ * "Worth" means it has a manifest — the same test the entrypoint and
42
+ * `loadBundle` use for "there is a bundle here". A path that points at an empty
43
+ * or half-unpacked directory is not a fallback, it is a second failure.
44
+ *
45
+ * Extracted so the decision can be tested: the code path that uses it only runs
46
+ * when a download has already failed, which is not a state a boot test can
47
+ * reach without a database.
48
+ */
49
+ export declare function usableBundleFallback(dir: string | undefined): string | undefined;
38
50
  export interface FetchBundleOptions {
39
51
  url: string;
40
52
  token?: string;
@@ -6,7 +6,7 @@ import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
6
  import "./src-B-CmIFMr.js";
7
7
  import "./src-Cdsw7DqV.js";
8
8
  import { n as createDdlBootstrapper, o as isSQLAdmin, r as hasInCauseChain } from "./ddl-bootstrap-5YZCZ8qk.js";
9
- import { t as revokeInternalTableSql } from "./internal-tables-DpxfPaEB.js";
9
+ import { t as revokeInternalTableSql } from "./internal-tables-DYVcFFSv.js";
10
10
  import { t as logger } from "./logger-DS03e908.js";
11
11
  //#region src/cron/cron-store.ts
12
12
  var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
@@ -182,4 +182,4 @@ function rowToLogEntry(row) {
182
182
  //#endregion
183
183
  export { cron_store_exports as n, createCronStore as t };
184
184
 
185
- //# sourceMappingURL=cron-store-CbFfQhbg.js.map
185
+ //# sourceMappingURL=cron-store-dof07MjG.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cron-store-CbFfQhbg.js","names":[],"sources":["../src/cron/cron-store.ts"],"sourcesContent":["import type { CronJobLogEntry } from \"@rebasepro/types\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain } from \"../boot/ddl-bootstrap.js\";\n\n/**\n * Persistence layer for cron job execution logs.\n *\n * Uses the DataDriver's `admin.executeSql` capability to store logs in a\n * `rebase.cron_logs` table. Falls back gracefully if the driver doesn't\n * support SQL (e.g. MongoDB) — in that case, no persistence occurs.\n */\nexport interface CronStore {\n /** Ensure the backing table exists. Called once on startup. */\n ensureTable(): Promise<void>;\n\n /** Persist a single log entry after execution. */\n insertLog(entry: CronJobLogEntry): Promise<void>;\n\n /**\n * Fetch the most recent logs for a job.\n * @param jobId The job identifier\n * @param limit Max entries to return (default 50)\n * @returns Logs sorted newest-first\n */\n fetchLogs(jobId: string, limit?: number): Promise<CronJobLogEntry[]>;\n\n /**\n * Fetch aggregate stats for all jobs (totalRuns, totalFailures, lastRunAt).\n * Used to seed in-memory counters on startup.\n */\n fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>>;\n\n /**\n * Atomically claim a scheduled run slot for a job.\n *\n * `slot` is the *scheduled* fire time (ISO string) derived from the cron\n * expression — deterministic across instances regardless of timer drift,\n * so all instances contend on the same (jobId, slot) key. Exactly one\n * caller wins the insert against the unique constraint and executes;\n * the rest skip.\n *\n * Fails open (returns true) on unexpected store errors, so a broken\n * claims table degrades to uncoordinated execution rather than silently\n * never running jobs.\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\n// ─── SQL-based implementation ────────────────────────────────────────\n\nconst TABLE = \"rebase.cron_logs\";\nconst CLAIMS_TABLE = \"rebase.cron_claims\";\n\n/** Claims older than this are garbage-collected on startup. */\nconst CLAIM_RETENTION_DAYS = 7;\n\n/**\n * How far ahead a claim may legitimately sit. Slots are claimed as they fire,\n * so anything beyond this is a clock-skewed peer at best and a stranded claim\n * at worst; see the sweep in `ensureTable`.\n */\nconst FUTURE_CLAIM_SKEW_MINUTES = 2;\n\n/**\n * Detect a unique-constraint violation anywhere in an error's cause chain.\n * Match the SQLSTATE code, never message text. Also covers SQLite\n * (\"UNIQUE constraint failed\") and MySQL (ER_DUP_ENTRY 1062) for future SQL\n * drivers.\n *\n * Distinct from `isConcurrentDdlRace` in `boot/ddl-bootstrap.ts`, which shares\n * the 23505 code but asks a different question — that one is about a losing\n * `CREATE`, this one is about a losing claim, and only the latter means\n * \"another instance already has this slot\".\n */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n e.code === \"23505\" ||\n e.errno === 1062 ||\n (typeof e.message === \"string\" && e.message.includes(\"UNIQUE constraint failed\"))\n );\n}\n\nexport function createCronStore(driver: DataDriver): CronStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\"⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.\");\n return undefined;\n }\n\n const exec = (sqlText: string, options?: { params?: unknown[] }) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n return {\n async ensureTable(): Promise<void> {\n // Creation. Every statement here is idempotent, so losing the race\n // to a peer that booted at the same moment is survivable — but only\n // if the loser retries rather than abandoning everything below it.\n // One step each, so a hard failure on any one of them does not take\n // the others with it. The claims table in particular must not be\n // lost because an index on the *logs* table could not be built.\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n job_id TEXT NOT NULL,\n started_at TIMESTAMPTZ NOT NULL,\n finished_at TIMESTAMPTZ NOT NULL,\n duration_ms INTEGER NOT NULL,\n success BOOLEAN NOT NULL DEFAULT true,\n error TEXT,\n result JSONB,\n logs JSONB,\n manual BOOLEAN NOT NULL DEFAULT false\n )\n `);\n\n await ddl.ensureObject(\"Creating idx_cron_logs_job\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job\n ON ${TABLE}(job_id, started_at DESC)\n `);\n\n 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 // 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] = await Promise.all([\n ddl.isReadable(TABLE),\n ddl.isReadable(CLAIMS_TABLE)\n ]);\n\n if (claimsReady) {\n // Garbage-collect old claims — they are only needed while\n // instances could still contend on the same slot.\n await ddl.step(\"Claim retention sweep\", async () => {\n await exec(\n `DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`,\n { params: [CLAIM_RETENTION_DAYS] }\n );\n });\n\n // Drop claims for slots that have not happened yet. A slot is\n // claimed at the moment it fires, so a future one can only come\n // from a timer that woke early — and because claims are\n // permanent, that claim would silently skip the real run when it\n // finally came due. The margin keeps a legitimate claim made\n // moments early by a clock-skewed peer.\n await ddl.step(\"Future-slot claim sweep\", async () => {\n const stranded = await exec(\n `DELETE FROM ${CLAIMS_TABLE}\n WHERE slot > now() + make_interval(mins => $1)\n RETURNING job_id, slot`,\n { params: [FUTURE_CLAIM_SKEW_MINUTES] }\n );\n // A driver that does not honour RETURNING gives back\n // nothing; the rows are only used to report, so treat that\n // as \"none\".\n for (const row of (stranded ?? []) as { job_id: string; slot: string }[]) {\n logger.warn(\n `[cron-store] Released a claim on the future slot ${new Date(row.slot).toISOString()} ` +\n `for \"${row.job_id}\" — it was claimed by a timer that fired early, and would ` +\n \"otherwise have skipped that run\"\n );\n }\n });\n }\n\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\n if (logsReady && claimsReady) {\n logger.info(\"✅ Cron logs table ready\");\n return;\n }\n // Say which capability is gone, and what it costs. \"Continuing\n // without cron log persistence\" undersold this: the claims table is\n // the only thing stopping every instance from running every job.\n if (!claimsReady) {\n logger.error(\n `❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. ` +\n \"With more than one app instance, every instance will now run every job on every tick.\"\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);\n }\n },\n\n async insertLog(entry: CronJobLogEntry): Promise<void> {\n try {\n const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;\n const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;\n\n await exec(\n `INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)\n VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,\n { params: [\n entry.jobId,\n entry.startedAt,\n entry.finishedAt,\n entry.durationMs,\n entry.success,\n entry.error || null,\n resultJson,\n logsJson,\n entry.manual\n ]}\n );\n } catch (err) {\n // Non-blocking — log persistence should never crash the scheduler\n logger.error(`[cron-store] Failed to persist log for \"${entry.jobId}\"`, { error: err });\n }\n },\n\n async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {\n try {\n const rows = await exec(\n `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\n FROM ${TABLE}\n WHERE job_id = $1\n ORDER BY started_at DESC\n LIMIT $2`,\n { params: [jobId, limit] }\n );\n\n return rows.map(rowToLogEntry);\n } catch (err) {\n logger.error(`[cron-store] Failed to fetch logs for \"${jobId}\"`, { error: err });\n return [];\n }\n },\n\n async fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>> {\n const stats = new Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>();\n try {\n const rows = await exec(`\n SELECT\n job_id,\n COUNT(*)::int AS total_runs,\n COUNT(*) FILTER (WHERE NOT success)::int AS total_failures,\n MAX(started_at) AS last_run_at\n FROM ${TABLE}\n GROUP BY job_id\n `);\n\n for (const row of rows) {\n stats.set(row.job_id as string, {\n totalRuns: row.total_runs as number,\n totalFailures: row.total_failures as number,\n lastRunAt: row.last_run_at ? new Date(row.last_run_at as string).toISOString() : undefined\n });\n }\n } catch (err) {\n logger.error(\"[cron-store] Failed to fetch job stats\", { error: err });\n }\n return stats;\n },\n\n async tryClaimRun(jobId: string, slot: string): Promise<boolean> {\n try {\n const rows = await exec(\n `INSERT INTO ${CLAIMS_TABLE} (job_id, slot)\n VALUES ($1, $2)\n ON CONFLICT (job_id, slot) DO NOTHING\n RETURNING job_id`,\n { params: [jobId, slot] }\n );\n return rows.length > 0;\n } catch (err) {\n if (isUniqueViolation(err)) {\n // Another instance won the race for this slot\n return false;\n }\n // Fail open: better to risk a duplicate run than to have a\n // broken claims table silently stop all cron execution.\n logger.warn(`[cron-store] Claim check failed for \"${jobId}\" — running uncoordinated`, { error: err });\n return true;\n }\n }\n };\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────\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-dof07MjG.js","names":[],"sources":["../src/cron/cron-store.ts"],"sourcesContent":["import type { CronJobLogEntry } from \"@rebasepro/types\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain } from \"../boot/ddl-bootstrap.js\";\n\n/**\n * Persistence layer for cron job execution logs.\n *\n * Uses the DataDriver's `admin.executeSql` capability to store logs in a\n * `rebase.cron_logs` table. Falls back gracefully if the driver doesn't\n * support SQL (e.g. MongoDB) — in that case, no persistence occurs.\n */\nexport interface CronStore {\n /** Ensure the backing table exists. Called once on startup. */\n ensureTable(): Promise<void>;\n\n /** Persist a single log entry after execution. */\n insertLog(entry: CronJobLogEntry): Promise<void>;\n\n /**\n * Fetch the most recent logs for a job.\n * @param jobId The job identifier\n * @param limit Max entries to return (default 50)\n * @returns Logs sorted newest-first\n */\n fetchLogs(jobId: string, limit?: number): Promise<CronJobLogEntry[]>;\n\n /**\n * Fetch aggregate stats for all jobs (totalRuns, totalFailures, lastRunAt).\n * Used to seed in-memory counters on startup.\n */\n fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>>;\n\n /**\n * Atomically claim a scheduled run slot for a job.\n *\n * `slot` is the *scheduled* fire time (ISO string) derived from the cron\n * expression — deterministic across instances regardless of timer drift,\n * so all instances contend on the same (jobId, slot) key. Exactly one\n * caller wins the insert against the unique constraint and executes;\n * the rest skip.\n *\n * Fails open (returns true) on unexpected store errors, so a broken\n * claims table degrades to uncoordinated execution rather than silently\n * never running jobs.\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\n// ─── SQL-based implementation ────────────────────────────────────────\n\nconst TABLE = \"rebase.cron_logs\";\nconst CLAIMS_TABLE = \"rebase.cron_claims\";\n\n/** Claims older than this are garbage-collected on startup. */\nconst CLAIM_RETENTION_DAYS = 7;\n\n/**\n * How far ahead a claim may legitimately sit. Slots are claimed as they fire,\n * so anything beyond this is a clock-skewed peer at best and a stranded claim\n * at worst; see the sweep in `ensureTable`.\n */\nconst FUTURE_CLAIM_SKEW_MINUTES = 2;\n\n/**\n * Detect a unique-constraint violation anywhere in an error's cause chain.\n * Match the SQLSTATE code, never message text. Also covers SQLite\n * (\"UNIQUE constraint failed\") and MySQL (ER_DUP_ENTRY 1062) for future SQL\n * drivers.\n *\n * Distinct from `isConcurrentDdlRace` in `boot/ddl-bootstrap.ts`, which shares\n * the 23505 code but asks a different question — that one is about a losing\n * `CREATE`, this one is about a losing claim, and only the latter means\n * \"another instance already has this slot\".\n */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n e.code === \"23505\" ||\n e.errno === 1062 ||\n (typeof e.message === \"string\" && e.message.includes(\"UNIQUE constraint failed\"))\n );\n}\n\nexport function createCronStore(driver: DataDriver): CronStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\"⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.\");\n return undefined;\n }\n\n const exec = (sqlText: string, options?: { params?: unknown[] }) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n return {\n async ensureTable(): Promise<void> {\n // Creation. Every statement here is idempotent, so losing the race\n // to a peer that booted at the same moment is survivable — but only\n // if the loser retries rather than abandoning everything below it.\n // One step each, so a hard failure on any one of them does not take\n // the others with it. The claims table in particular must not be\n // lost because an index on the *logs* table could not be built.\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n job_id TEXT NOT NULL,\n started_at TIMESTAMPTZ NOT NULL,\n finished_at TIMESTAMPTZ NOT NULL,\n duration_ms INTEGER NOT NULL,\n success BOOLEAN NOT NULL DEFAULT true,\n error TEXT,\n result JSONB,\n logs JSONB,\n manual BOOLEAN NOT NULL DEFAULT false\n )\n `);\n\n await ddl.ensureObject(\"Creating idx_cron_logs_job\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job\n ON ${TABLE}(job_id, started_at DESC)\n `);\n\n 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 // 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] = await Promise.all([\n ddl.isReadable(TABLE),\n ddl.isReadable(CLAIMS_TABLE)\n ]);\n\n if (claimsReady) {\n // Garbage-collect old claims — they are only needed while\n // instances could still contend on the same slot.\n await ddl.step(\"Claim retention sweep\", async () => {\n await exec(\n `DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`,\n { params: [CLAIM_RETENTION_DAYS] }\n );\n });\n\n // Drop claims for slots that have not happened yet. A slot is\n // claimed at the moment it fires, so a future one can only come\n // from a timer that woke early — and because claims are\n // permanent, that claim would silently skip the real run when it\n // finally came due. The margin keeps a legitimate claim made\n // moments early by a clock-skewed peer.\n await ddl.step(\"Future-slot claim sweep\", async () => {\n const stranded = await exec(\n `DELETE FROM ${CLAIMS_TABLE}\n WHERE slot > now() + make_interval(mins => $1)\n RETURNING job_id, slot`,\n { params: [FUTURE_CLAIM_SKEW_MINUTES] }\n );\n // A driver that does not honour RETURNING gives back\n // nothing; the rows are only used to report, so treat that\n // as \"none\".\n for (const row of (stranded ?? []) as { job_id: string; slot: string }[]) {\n logger.warn(\n `[cron-store] Released a claim on the future slot ${new Date(row.slot).toISOString()} ` +\n `for \"${row.job_id}\" — it was claimed by a timer that fired early, and would ` +\n \"otherwise have skipped that run\"\n );\n }\n });\n }\n\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\n if (logsReady && claimsReady) {\n logger.info(\"✅ Cron logs table ready\");\n return;\n }\n // Say which capability is gone, and what it costs. \"Continuing\n // without cron log persistence\" undersold this: the claims table is\n // the only thing stopping every instance from running every job.\n if (!claimsReady) {\n logger.error(\n `❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. ` +\n \"With more than one app instance, every instance will now run every job on every tick.\"\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);\n }\n },\n\n async insertLog(entry: CronJobLogEntry): Promise<void> {\n try {\n const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;\n const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;\n\n await exec(\n `INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)\n VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,\n { params: [\n entry.jobId,\n entry.startedAt,\n entry.finishedAt,\n entry.durationMs,\n entry.success,\n entry.error || null,\n resultJson,\n logsJson,\n entry.manual\n ]}\n );\n } catch (err) {\n // Non-blocking — log persistence should never crash the scheduler\n logger.error(`[cron-store] Failed to persist log for \"${entry.jobId}\"`, { error: err });\n }\n },\n\n async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {\n try {\n const rows = await exec(\n `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\n FROM ${TABLE}\n WHERE job_id = $1\n ORDER BY started_at DESC\n LIMIT $2`,\n { params: [jobId, limit] }\n );\n\n return rows.map(rowToLogEntry);\n } catch (err) {\n logger.error(`[cron-store] Failed to fetch logs for \"${jobId}\"`, { error: err });\n return [];\n }\n },\n\n async fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>> {\n const stats = new Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>();\n try {\n const rows = await exec(`\n SELECT\n job_id,\n COUNT(*)::int AS total_runs,\n COUNT(*) FILTER (WHERE NOT success)::int AS total_failures,\n MAX(started_at) AS last_run_at\n FROM ${TABLE}\n GROUP BY job_id\n `);\n\n for (const row of rows) {\n stats.set(row.job_id as string, {\n totalRuns: row.total_runs as number,\n totalFailures: row.total_failures as number,\n lastRunAt: row.last_run_at ? new Date(row.last_run_at as string).toISOString() : undefined\n });\n }\n } catch (err) {\n logger.error(\"[cron-store] Failed to fetch job stats\", { error: err });\n }\n return stats;\n },\n\n async tryClaimRun(jobId: string, slot: string): Promise<boolean> {\n try {\n const rows = await exec(\n `INSERT INTO ${CLAIMS_TABLE} (job_id, slot)\n VALUES ($1, $2)\n ON CONFLICT (job_id, slot) DO NOTHING\n RETURNING job_id`,\n { params: [jobId, slot] }\n );\n return rows.length > 0;\n } catch (err) {\n if (isUniqueViolation(err)) {\n // Another instance won the race for this slot\n return false;\n }\n // Fail open: better to risk a duplicate run than to have a\n // broken claims table silently stop all cron execution.\n logger.warn(`[cron-store] Claim check failed for \"${jobId}\" — running uncoordinated`, { error: err });\n return true;\n }\n }\n };\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────\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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"}
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { t as logger } from "./logger-DS03e908.js";
6
- import { a as recordSamples, i as readSeries, n as SAMPLE_INTERVAL_MS, o as sampleSelf, r as ensureMetricsHistory } from "./history-store-oiqhb3HU.js";
6
+ import { a as recordSamples, i as readSeries, n as SAMPLE_INTERVAL_MS, o as sampleSelf, r as ensureMetricsHistory } from "./history-store-Dtsip8nH.js";
7
7
  import { monitorEventLoopDelay } from "node:perf_hooks";
8
8
  //#region src/metrics/history-recorder.ts
9
9
  /**
@@ -73,4 +73,4 @@ function createMetricsHistory(driver) {
73
73
  //#endregion
74
74
  export { createMetricsHistory };
75
75
 
76
- //# sourceMappingURL=history-recorder-B67maiTi.js.map
76
+ //# sourceMappingURL=history-recorder-BMSFyySP.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"history-recorder-B67maiTi.js","names":[],"sources":["../src/metrics/history-recorder.ts"],"sourcesContent":["/**\n * Wiring the history store to a driver and a timer.\n *\n * Split from `history-store.ts` so the store stays pure — every rule in it is\n * testable with a fake executor, and nothing there knows what a DataDriver is.\n * This half is the part that cannot be unit-tested meaningfully: it opens a\n * connection and starts an interval.\n */\nimport { monitorEventLoopDelay } from \"node:perf_hooks\";\nimport { logger } from \"../utils/logger.js\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport {\n ensureMetricsHistory,\n recordSamples,\n readSeries,\n sampleSelf,\n SAMPLE_INTERVAL_MS,\n type Exec,\n type MetricSeries,\n type SeriesPoint\n} from \"./history-store.js\";\n\nexport interface MetricsHistory {\n /** Create the table and sweep what has aged out. */\n ensure(): Promise<void>;\n /** Begin sampling this process. Returns a stop. */\n start(): () => void;\n /** Read one series, for the route and for anything else that asks. */\n read(series: MetricSeries, sinceMinutes: number): Promise<SeriesPoint[]>;\n}\n\n/** Narrow structural check, matching how the job store decides the same thing. */\nfunction sqlExecutorOf(driver: DataDriver): Exec | undefined {\n const admin = (driver as { admin?: { executeSql?: unknown } }).admin;\n if (!admin || typeof admin.executeSql !== \"function\") return undefined;\n const executeSql = admin.executeSql as (sql: string, opts?: { params?: unknown[] }) => Promise<unknown>;\n return (sql, params) => executeSql(sql, params ? { params } : undefined);\n}\n\n/**\n * History for this deployment, or `undefined` when it cannot have any.\n *\n * Undefined rather than a no-op: the route turns it into a named 501, because a\n * chart that renders empty is indistinguishable from a quiet period and this is\n * exactly the class of silence the panel it feeds exists to remove.\n */\nexport function createMetricsHistory(driver: DataDriver): MetricsHistory | undefined {\n const exec = sqlExecutorOf(driver);\n if (!exec) {\n logger.debug(\"[metrics] driver has no SQL admin — no metrics history will be kept.\");\n return undefined;\n }\n\n return {\n ensure: () => ensureMetricsHistory(exec),\n read: (series, sinceMinutes) => readSeries(exec, series, sinceMinutes),\n start(): () => void {\n // Which process this is. A tenant's replicas share one database and\n // each records its own numbers, so a row needs to say whose they\n // are — without it they overwrite each other and a scaled-out app\n // charts one arbitrary pod.\n //\n // HOSTNAME is the pod name on Kubernetes and the container id under\n // Docker; the pid fallback keeps two local processes distinct.\n const instance = process.env.HOSTNAME?.trim() || `pid-${process.pid}`;\n let cursor: { cpu: NodeJS.CpuUsage; at: number } | null = null;\n let stopped = false;\n\n // Event-loop delay: how long a callback waited past its schedule.\n // The one number here that says whether this process is *healthy*\n // rather than how much it is consuming — a pod can sit at 20% CPU\n // and still be unable to answer, and nothing else recorded would\n // show it.\n //\n // `monitorEventLoopDelay` samples in libuv at a fixed resolution and\n // costs effectively nothing; `.enable()` is required, and the\n // histogram is reset each tick so every sample describes its own\n // minute rather than the process's whole life.\n interface LoopHistogram { mean: number; enable(): void; reset(): void }\n let loop: LoopHistogram | null = null;\n try {\n loop = monitorEventLoopDelay({ resolution: 20 }) as unknown as LoopHistogram;\n loop.enable();\n } catch {\n // A runtime without it still records the other two.\n loop = null;\n }\n\n const tick = async () => {\n if (stopped) return;\n // Nanoseconds from the histogram, milliseconds on the wire.\n const delayMs = loop ? loop.mean / 1e6 : undefined;\n loop?.reset();\n const { samples, cursor: next } = sampleSelf(cursor, Date.now(), delayMs);\n cursor = next;\n try {\n await recordSamples(exec, samples, instance);\n } catch (err) {\n // Never fatal, and never noisy: a sampler that crash-loops a\n // pod over a chart would be a far worse trade than a gap in\n // one. The gap is visible in the data; a restart loop is not.\n logger.debug(\"[metrics] could not record a sample\", { err });\n }\n };\n\n // The first tick establishes the CPU cursor and publishes memory;\n // the rate needs a second reading, which is why nothing claims a CPU\n // figure until one interval has passed.\n void tick();\n const timer = setInterval(() => void tick(), SAMPLE_INTERVAL_MS);\n // Not the reason this process should stay alive.\n timer.unref?.();\n\n return () => { stopped = true; clearInterval(timer); };\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgCA,SAAS,cAAc,QAAsC;CACzD,MAAM,QAAS,OAAgD;CAC/D,IAAI,CAAC,SAAS,OAAO,MAAM,eAAe,YAAY,OAAO,KAAA;CAC7D,MAAM,aAAa,MAAM;CACzB,QAAQ,KAAK,WAAW,WAAW,KAAK,SAAS,EAAE,OAAO,IAAI,KAAA,CAAS;AAC3E;;;;;;;;AASA,SAAgB,qBAAqB,QAAgD;CACjF,MAAM,OAAO,cAAc,MAAM;CACjC,IAAI,CAAC,MAAM;EACP,OAAO,MAAM,sEAAsE;EACnF;CACJ;CAEA,OAAO;EACH,cAAc,qBAAqB,IAAI;EACvC,OAAO,QAAQ,iBAAiB,WAAW,MAAM,QAAQ,YAAY;EACrE,QAAoB;GAQhB,MAAM,WAAW,QAAQ,IAAI,UAAU,KAAK,KAAK,OAAO,QAAQ;GAChE,IAAI,SAAsD;GAC1D,IAAI,UAAU;GAad,IAAI,OAA6B;GACjC,IAAI;IACA,OAAO,sBAAsB,EAAE,YAAY,GAAG,CAAC;IAC/C,KAAK,OAAO;GAChB,QAAQ;IAEJ,OAAO;GACX;GAEA,MAAM,OAAO,YAAY;IACrB,IAAI,SAAS;IAEb,MAAM,UAAU,OAAO,KAAK,OAAO,MAAM,KAAA;IACzC,MAAM,MAAM;IACZ,MAAM,EAAE,SAAS,QAAQ,SAAS,WAAW,QAAQ,KAAK,IAAI,GAAG,OAAO;IACxE,SAAS;IACT,IAAI;KACA,MAAM,cAAc,MAAM,SAAS,QAAQ;IAC/C,SAAS,KAAK;KAIV,OAAO,MAAM,uCAAuC,EAAE,IAAI,CAAC;IAC/D;GACJ;GAKA,KAAU;GACV,MAAM,QAAQ,kBAAkB,KAAK,KAAK,GAAG,kBAAkB;GAE/D,MAAM,QAAQ;GAEd,aAAa;IAAE,UAAU;IAAM,cAAc,KAAK;GAAG;EACzD;CACJ;AACJ"}
1
+ {"version":3,"file":"history-recorder-BMSFyySP.js","names":[],"sources":["../src/metrics/history-recorder.ts"],"sourcesContent":["/**\n * Wiring the history store to a driver and a timer.\n *\n * Split from `history-store.ts` so the store stays pure — every rule in it is\n * testable with a fake executor, and nothing there knows what a DataDriver is.\n * This half is the part that cannot be unit-tested meaningfully: it opens a\n * connection and starts an interval.\n */\nimport { monitorEventLoopDelay } from \"node:perf_hooks\";\nimport { logger } from \"../utils/logger.js\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport {\n ensureMetricsHistory,\n recordSamples,\n readSeries,\n sampleSelf,\n SAMPLE_INTERVAL_MS,\n type Exec,\n type MetricSeries,\n type SeriesPoint\n} from \"./history-store.js\";\n\nexport interface MetricsHistory {\n /** Create the table and sweep what has aged out. */\n ensure(): Promise<void>;\n /** Begin sampling this process. Returns a stop. */\n start(): () => void;\n /** Read one series, for the route and for anything else that asks. */\n read(series: MetricSeries, sinceMinutes: number): Promise<SeriesPoint[]>;\n}\n\n/** Narrow structural check, matching how the job store decides the same thing. */\nfunction sqlExecutorOf(driver: DataDriver): Exec | undefined {\n const admin = (driver as { admin?: { executeSql?: unknown } }).admin;\n if (!admin || typeof admin.executeSql !== \"function\") return undefined;\n const executeSql = admin.executeSql as (sql: string, opts?: { params?: unknown[] }) => Promise<unknown>;\n return (sql, params) => executeSql(sql, params ? { params } : undefined);\n}\n\n/**\n * History for this deployment, or `undefined` when it cannot have any.\n *\n * Undefined rather than a no-op: the route turns it into a named 501, because a\n * chart that renders empty is indistinguishable from a quiet period and this is\n * exactly the class of silence the panel it feeds exists to remove.\n */\nexport function createMetricsHistory(driver: DataDriver): MetricsHistory | undefined {\n const exec = sqlExecutorOf(driver);\n if (!exec) {\n logger.debug(\"[metrics] driver has no SQL admin — no metrics history will be kept.\");\n return undefined;\n }\n\n return {\n ensure: () => ensureMetricsHistory(exec),\n read: (series, sinceMinutes) => readSeries(exec, series, sinceMinutes),\n start(): () => void {\n // Which process this is. A tenant's replicas share one database and\n // each records its own numbers, so a row needs to say whose they\n // are — without it they overwrite each other and a scaled-out app\n // charts one arbitrary pod.\n //\n // HOSTNAME is the pod name on Kubernetes and the container id under\n // Docker; the pid fallback keeps two local processes distinct.\n const instance = process.env.HOSTNAME?.trim() || `pid-${process.pid}`;\n let cursor: { cpu: NodeJS.CpuUsage; at: number } | null = null;\n let stopped = false;\n\n // Event-loop delay: how long a callback waited past its schedule.\n // The one number here that says whether this process is *healthy*\n // rather than how much it is consuming — a pod can sit at 20% CPU\n // and still be unable to answer, and nothing else recorded would\n // show it.\n //\n // `monitorEventLoopDelay` samples in libuv at a fixed resolution and\n // costs effectively nothing; `.enable()` is required, and the\n // histogram is reset each tick so every sample describes its own\n // minute rather than the process's whole life.\n interface LoopHistogram { mean: number; enable(): void; reset(): void }\n let loop: LoopHistogram | null = null;\n try {\n loop = monitorEventLoopDelay({ resolution: 20 }) as unknown as LoopHistogram;\n loop.enable();\n } catch {\n // A runtime without it still records the other two.\n loop = null;\n }\n\n const tick = async () => {\n if (stopped) return;\n // Nanoseconds from the histogram, milliseconds on the wire.\n const delayMs = loop ? loop.mean / 1e6 : undefined;\n loop?.reset();\n const { samples, cursor: next } = sampleSelf(cursor, Date.now(), delayMs);\n cursor = next;\n try {\n await recordSamples(exec, samples, instance);\n } catch (err) {\n // Never fatal, and never noisy: a sampler that crash-loops a\n // pod over a chart would be a far worse trade than a gap in\n // one. The gap is visible in the data; a restart loop is not.\n logger.debug(\"[metrics] could not record a sample\", { err });\n }\n };\n\n // The first tick establishes the CPU cursor and publishes memory;\n // the rate needs a second reading, which is why nothing claims a CPU\n // figure until one interval has passed.\n void tick();\n const timer = setInterval(() => void tick(), SAMPLE_INTERVAL_MS);\n // Not the reason this process should stay alive.\n timer.unref?.();\n\n return () => { stopped = true; clearInterval(timer); };\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgCA,SAAS,cAAc,QAAsC;CACzD,MAAM,QAAS,OAAgD;CAC/D,IAAI,CAAC,SAAS,OAAO,MAAM,eAAe,YAAY,OAAO,KAAA;CAC7D,MAAM,aAAa,MAAM;CACzB,QAAQ,KAAK,WAAW,WAAW,KAAK,SAAS,EAAE,OAAO,IAAI,KAAA,CAAS;AAC3E;;;;;;;;AASA,SAAgB,qBAAqB,QAAgD;CACjF,MAAM,OAAO,cAAc,MAAM;CACjC,IAAI,CAAC,MAAM;EACP,OAAO,MAAM,sEAAsE;EACnF;CACJ;CAEA,OAAO;EACH,cAAc,qBAAqB,IAAI;EACvC,OAAO,QAAQ,iBAAiB,WAAW,MAAM,QAAQ,YAAY;EACrE,QAAoB;GAQhB,MAAM,WAAW,QAAQ,IAAI,UAAU,KAAK,KAAK,OAAO,QAAQ;GAChE,IAAI,SAAsD;GAC1D,IAAI,UAAU;GAad,IAAI,OAA6B;GACjC,IAAI;IACA,OAAO,sBAAsB,EAAE,YAAY,GAAG,CAAC;IAC/C,KAAK,OAAO;GAChB,QAAQ;IAEJ,OAAO;GACX;GAEA,MAAM,OAAO,YAAY;IACrB,IAAI,SAAS;IAEb,MAAM,UAAU,OAAO,KAAK,OAAO,MAAM,KAAA;IACzC,MAAM,MAAM;IACZ,MAAM,EAAE,SAAS,QAAQ,SAAS,WAAW,QAAQ,KAAK,IAAI,GAAG,OAAO;IACxE,SAAS;IACT,IAAI;KACA,MAAM,cAAc,MAAM,SAAS,QAAQ;IAC/C,SAAS,KAAK;KAIV,OAAO,MAAM,uCAAuC,EAAE,IAAI,CAAC;IAC/D;GACJ;GAKA,KAAU;GACV,MAAM,QAAQ,kBAAkB,KAAK,KAAK,GAAG,kBAAkB;GAE/D,MAAM,QAAQ;GAEd,aAAa;IAAE,UAAU;IAAM,cAAc,KAAK;GAAG;EACzD;CACJ;AACJ"}
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import "./src-B-CmIFMr.js";
6
- import { t as revokeInternalTableSql } from "./internal-tables-DpxfPaEB.js";
6
+ import { t as revokeInternalTableSql } from "./internal-tables-DYVcFFSv.js";
7
7
  //#region src/metrics/history-store.ts
8
8
  /**
9
9
  * A little history for the metrics this process already keeps.
@@ -207,4 +207,4 @@ async function readSeries(exec, series, sinceMinutes) {
207
207
  //#endregion
208
208
  export { recordSamples as a, readSeries as i, SAMPLE_INTERVAL_MS as n, sampleSelf as o, ensureMetricsHistory as r, METRIC_SERIES as t };
209
209
 
210
- //# sourceMappingURL=history-store-oiqhb3HU.js.map
210
+ //# sourceMappingURL=history-store-Dtsip8nH.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"history-store-oiqhb3HU.js","names":[],"sources":["../src/metrics/history-store.ts"],"sourcesContent":["/**\n * A little history for the metrics this process already keeps.\n *\n * ## Why this is in the framework and not in the cloud console\n *\n * The console wants to draw \"CPU over the last hour\". The obvious way to get it\n * on GKE is Cloud Monitoring, which already collects exactly this — and which\n * would make the panel unportable the day the platform moves, for a feature\n * every self-hoster also wants. So the history lives where the runtime lives:\n * the process samples ITSELF, into its OWN database, and anything that can read\n * the database can draw the chart. No cluster, no metrics-server, no vendor.\n *\n * That is the same rule the binder follows — the cloud is a better\n * implementation behind the same interface, never a different one.\n *\n * ## Why it stays cheap\n *\n * One row per series per instance per minute. Three series across two replicas\n * is 8,640 rows a day, and the sweep below bounds the window, so the table settles\n * at a size measured in megabytes. It is deliberately NOT a general time-series\n * store: no labels, no cardinality to explode, no per-request rows.\n *\n * A minute is the resolution because that is what the question needs — \"was it\n * slow at 15:40\", \"did my deploy cause that\" — and because a finer grain buys\n * nothing a reader can see on a chart of an hour.\n */\n/**\n * The positional-parameter shape every store in this package settles on.\n *\n * The bootstrapper's own `SqlExec` takes an options object; each store wraps it\n * once and reads better for it. Same two shapes, same reason, as `job-store`.\n */\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\n\nexport type Exec = (sql: string, params?: unknown[]) => Promise<unknown>;\n\n/** Where the samples live. Framework-owned, like `rebase.jobs`. */\nexport const METRICS_HISTORY_TABLE = \"rebase.metric_samples\";\n\n/**\n * How long a sample is kept.\n *\n * Two weeks answers \"is this worse than last week\" and stops well short of\n * being an archive. Anything that needs to outlive it — billing, capacity\n * planning — is a rollup somebody else owns, not a longer retention here.\n *\n * Read it as \"14 days, or since this process started, whichever is longer\": the\n * sweep runs at boot and nowhere else, so a pod up for 90 days holds 90 days.\n * That is a deliberate trade rather than an oversight — a cron for it would be\n * machinery for a table that stays trivial either way, and reads are bounded by\n * the index regardless — but the table does not \"reach a steady size and stay\n * there\" on a long-lived pod, which an earlier version of this comment claimed.\n */\nexport const RETENTION_DAYS = 14;\n\n/** How often the process samples itself. Matched to the resolution it stores. */\nexport const SAMPLE_INTERVAL_MS = 60_000;\n\n/**\n * The series this records. A closed set on purpose — see the cardinality note.\n *\n * A list rather than only a union, because the route validates against it: an\n * unknown `?series=` must be a named 400 rather than an empty chart, which\n * reads exactly like a quiet period.\n *\n * **It lists what `sampleSelf` actually writes, and nothing else.** It used to\n * also name `requests_total`, `errors_total` and `event_loop_delay_ms`, none of\n * which anything ever recorded — so those three were *valid* parameters that\n * returned `points: []`, which is precisely the empty chart the 400 two\n * paragraphs up exists to prevent. A declared-but-unwritten series is worse\n * than an absent one: the 400 tells you the name is wrong, and the empty array\n * tells you the app was quiet.\n *\n * `event_loop_delay_ms` kept its place by gaining a sampler, because it is the\n * one signal here that says whether the process is *healthy* rather than how\n * much it is using, and it has no counter semantics to get wrong.\n *\n * `requests_total` and `errors_total` were dropped rather than wired up. The\n * registry does hold them, but they reset on restart, and a resetting counter\n * summed across replicas is not monotonic — a rolling deploy would draw a\n * cliff that looks like traffic collapsing. That needs a deliberate decision\n * about deltas and resets, not a line added at 2am. Adding either back means\n * adding its sampler in the same commit.\n */\nexport const METRIC_SERIES = [\n \"cpu_millicores\",\n \"memory_bytes\",\n \"event_loop_delay_ms\"\n] as const;\n\nexport type MetricSeries = (typeof METRIC_SERIES)[number];\n\nexport interface MetricSample {\n at: Date;\n series: MetricSeries;\n value: number;\n}\n\n/**\n * Create the table and sweep what has aged out.\n *\n * Called at boot, beside the job and cron stores, and for the same reason they\n * do it there: the only moment the schema is guaranteed to be reachable and\n * nobody is mid-request.\n */\nexport async function ensureMetricsHistory(exec: Exec): Promise<void> {\n await exec(`\n CREATE TABLE IF NOT EXISTS ${METRICS_HISTORY_TABLE} (\n at timestamptz NOT NULL,\n series text NOT NULL,\n instance text NOT NULL,\n value double precision NOT NULL,\n PRIMARY KEY (series, instance, at)\n )\n `, []);\n\n // (series, at DESC) rather than the primary key's order: every read is\n // \"this series across all instances, bounded by time\", and the PK leads with\n // instance, which is the wrong first column for that scan.\n await exec(`\n CREATE INDEX IF NOT EXISTS metric_samples_series_recent\n ON ${METRICS_HISTORY_TABLE} (series, at DESC)\n `, []);\n\n // Framework-internal, so the end-user role must not be able to address it.\n //\n // The driver grants `rebase_user` full DML across the schemas a project\n // uses, plus `ALTER DEFAULT PRIVILEGES` so tables created later inherit it —\n // and this one is created later, at boot. Without the revoke, an\n // authenticated request could read and write another deployment's process\n // metrics, and `pnpm rls:check` correctly called that `[critical]\n // rls-disabled` the first time CI saw the table.\n //\n // REVOKE rather than `ENABLE ROW LEVEL SECURITY`, matching every other table\n // in `REBASE_INTERNAL_TABLES`: RLS with no policy denies the same rows but\n // is the weaker statement, because the grant survives and a later policy\n // reopens it. There is no row here any end user should reach, so \"this role\n // has no privilege at all\" is the honest encoding. The owner connection the\n // recorder runs on is unaffected.\n await exec(revokeInternalTableSql(\"rebase\", \"metric_samples\"), []);\n\n // Swept here rather than by a cron, so a deployment that runs no scheduler\n // still stays bounded. A DELETE is the right tool at this size — a few\n // thousand rows a day — and partitioning would be machinery for a table\n // that never gets big.\n await exec(\n `DELETE FROM ${METRICS_HISTORY_TABLE} WHERE at < now() - make_interval(days => $1)`,\n [RETENTION_DAYS]\n );\n}\n\n/**\n * What this process is using right now.\n *\n * `process.cpuUsage()` is cumulative, so a rate needs two readings and the gap\n * between them — which is why the previous one is threaded through rather than\n * held in a module global: a module global is shared by every test in a file\n * and makes the first assertion depend on whatever ran before it.\n */\nexport function sampleSelf(\n previous: { cpu: NodeJS.CpuUsage; at: number } | null,\n now = Date.now(),\n /**\n * Mean event-loop delay over the last window, in milliseconds, or undefined\n * where it cannot be measured. Passed in rather than read here so this\n * function stays pure: the histogram is a stateful handle the recorder owns.\n */\n eventLoopDelayMs?: number\n): { samples: Omit<MetricSample, \"at\">[]; cursor: { cpu: NodeJS.CpuUsage; at: number } } {\n const cpu = process.cpuUsage();\n const memory = process.memoryUsage();\n const samples: Omit<MetricSample, \"at\">[] = [\n { series: \"memory_bytes\", value: memory.rss }\n ];\n\n // Only when it was actually measured. Zero is a real and common reading for\n // an idle process, so a `?? 0` here would be indistinguishable from a\n // healthy one — the same substitution this module rejects everywhere else.\n if (typeof eventLoopDelayMs === \"number\" && Number.isFinite(eventLoopDelayMs)) {\n samples.push({ series: \"event_loop_delay_ms\", value: eventLoopDelayMs });\n }\n\n if (previous) {\n const elapsedMs = now - previous.at;\n if (elapsedMs > 0) {\n // Microseconds of CPU over milliseconds of wall clock, as\n // millicores: 1000m is one core saturated for the whole window.\n const usedMicros = (cpu.user - previous.cpu.user) + (cpu.system - previous.cpu.system);\n samples.push({ series: \"cpu_millicores\", value: (usedMicros / 1000 / elapsedMs) * 1000 });\n }\n }\n\n return { samples, cursor: { cpu, at: now } };\n}\n\n/**\n * Write one tick's samples, for one instance.\n *\n * ## Why `instance` is part of the key\n *\n * A tenant's replicas share one database, and each records its OWN process. Key\n * a row by `(series, minute)` alone and the pods overwrite each other every\n * tick: one pod at 5m and another at 500m leave whichever wrote last, so a\n * scaled-out tenant charts one arbitrary replica and calls it the app. Adding\n * the instance makes each pod its own row, and lets the read decide whether the\n * question is \"the whole deployment\" or \"which pod is hot\".\n *\n * Cardinality stays bounded: rows are series × replicas × minutes, and replicas\n * are capped by the autoscaling ceiling. Six pods is ~43k rows a day and a\n * fortnight of them is well under a million.\n */\nexport async function recordSamples(\n exec: Exec,\n samples: Omit<MetricSample, \"at\">[],\n instance: string,\n at: Date = new Date()\n): Promise<void> {\n if (samples.length === 0) return;\n // Truncated to the minute, so a pod restarting mid-minute overwrites its own\n // earlier row rather than adding a second one for the same instant.\n const bucket = new Date(Math.floor(at.getTime() / 60_000) * 60_000);\n for (const s of samples) {\n if (!Number.isFinite(s.value)) continue;\n await exec(\n `INSERT INTO ${METRICS_HISTORY_TABLE} (at, series, instance, value)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (series, instance, at) DO UPDATE SET value = EXCLUDED.value`,\n [bucket, s.series, instance, s.value]\n );\n }\n}\n\n/**\n * How a series combines across the replicas that reported it.\n *\n * Not one answer for everything, because the right one differs by what the\n * number means. CPU and memory are consumption: the deployment's figure is the\n * sum, and a mean would make scaling out look like it reduced usage. A queue\n * depth or an event-loop delay is a condition each process is independently in,\n * and summing those produces a number no single pod ever experienced.\n */\nconst COMBINE: Record<MetricSeries, \"sum\" | \"avg\"> = {\n cpu_millicores: \"sum\",\n memory_bytes: \"sum\",\n event_loop_delay_ms: \"avg\"\n};\n\nexport interface SeriesPoint {\n at: string;\n /** The deployment's figure, combined per `COMBINE`. */\n value: number;\n /** How many instances reported in this bucket. */\n instances: number;\n}\n\n/**\n * Read one series over a window, oldest first — the order a chart draws in.\n *\n * Combined across instances rather than returned per-pod. A chart of \"this\n * app's CPU\" is the question people ask; \"which pod is hot\" is answered by the\n * live panel, which already lists instances individually and does not need\n * history to do it.\n *\n * `instances` rides along because a sum whose contributor count changed is not\n * comparable with itself: CPU doubling because the app got busy and CPU\n * doubling because it scaled from one replica to two are different events, and\n * a line chart alone cannot tell them apart.\n */\nexport async function readSeries(\n exec: Exec,\n series: MetricSeries,\n sinceMinutes: number\n): Promise<SeriesPoint[]> {\n const combine = COMBINE[series] === \"avg\" ? \"avg\" : \"sum\";\n // The current minute is EXCLUDED, and that is not tidiness.\n //\n // Each replica samples on its own phase — `setInterval` from whenever that\n // pod booted — so at 10:45:20 the bucket for 10:45 holds rows from whichever\n // pods have ticked so far, typically one of three. The chart takes the last\n // point as its headline figure, so a three-replica app displayed one\n // replica's CPU as the deployment's, the line ended in a cliff, and the\n // instance step dropped underneath it — which the chart's own caption\n // explains to the reader as a scale-down that never happened.\n //\n // Every live pod has written bucket M-1 before the clock enters M, so the\n // newest bucket returned is complete and its `instances` is the true\n // contributor count. `date_trunc` rather than arithmetic because it matches\n // the recorder's `floor(t / 60_000) * 60_000` exactly, and is\n // timezone-independent on a timestamptz.\n const rows = await exec(\n `SELECT at, ${combine}(value) AS value, count(*) AS instances\n FROM ${METRICS_HISTORY_TABLE}\n WHERE series = $1\n AND at >= now() - make_interval(mins => $2)\n AND at < date_trunc('minute', now())\n GROUP BY at\n ORDER BY at ASC`,\n [series, sinceMinutes]\n ) as unknown as { rows?: RawPoint[] } | RawPoint[];\n\n const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);\n return list.map(r => ({\n at: r.at instanceof Date ? r.at.toISOString() : String(r.at),\n value: Number(r.value),\n instances: Number(r.instances ?? 1)\n }));\n}\n\ninterface RawPoint { at: Date | string; value: number; instances?: number | string }\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,IAAa,wBAAwB;;AAmBrC,IAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BlC,IAAa,gBAAgB;CACzB;CACA;CACA;AACJ;;;;;;;;AAiBA,eAAsB,qBAAqB,MAA2B;CAClE,MAAM,KAAK;qCACsB,sBAAsB;;;;;;;OAOpD,CAAC,CAAC;CAKL,MAAM,KAAK;;iBAEE,sBAAsB;OAChC,CAAC,CAAC;CAiBL,MAAM,KAAK,uBAAuB,UAAU,gBAAgB,GAAG,CAAC,CAAC;CAMjE,MAAM,KACF,eAAe,sBAAsB,gDACrC,CAAA,EAAe,CACnB;AACJ;;;;;;;;;AAUA,SAAgB,WACZ,UACA,MAAM,KAAK,IAAI,GAMf,kBACqF;CACrF,MAAM,MAAM,QAAQ,SAAS;CAE7B,MAAM,UAAsC,CACxC;EAAE,QAAQ;EAAgB,OAFf,QAAQ,YAEc,CAAA,CAAO;CAAI,CAChD;CAKA,IAAI,OAAO,qBAAqB,YAAY,OAAO,SAAS,gBAAgB,GACxE,QAAQ,KAAK;EAAE,QAAQ;EAAuB,OAAO;CAAiB,CAAC;CAG3E,IAAI,UAAU;EACV,MAAM,YAAY,MAAM,SAAS;EACjC,IAAI,YAAY,GAAG;GAGf,MAAM,aAAc,IAAI,OAAO,SAAS,IAAI,QAAS,IAAI,SAAS,SAAS,IAAI;GAC/E,QAAQ,KAAK;IAAE,QAAQ;IAAkB,OAAQ,aAAa,MAAO,YAAa;GAAK,CAAC;EAC5F;CACJ;CAEA,OAAO;EAAE;EAAS,QAAQ;GAAE;GAAK,IAAI;EAAI;CAAE;AAC/C;;;;;;;;;;;;;;;;;AAkBA,eAAsB,cAClB,MACA,SACA,UACA,qBAAW,IAAI,KAAK,GACP;CACb,IAAI,QAAQ,WAAW,GAAG;CAG1B,MAAM,yBAAS,IAAI,KAAK,KAAK,MAAM,GAAG,QAAQ,IAAI,GAAM,IAAI,GAAM;CAClE,KAAK,MAAM,KAAK,SAAS;EACrB,IAAI,CAAC,OAAO,SAAS,EAAE,KAAK,GAAG;EAC/B,MAAM,KACF,eAAe,sBAAsB;;uFAGrC;GAAC;GAAQ,EAAE;GAAQ;GAAU,EAAE;EAAK,CACxC;CACJ;AACJ;;;;;;;;;;AAWA,IAAM,UAA+C;CACjD,gBAAgB;CAChB,cAAc;CACd,qBAAqB;AACzB;;;;;;;;;;;;;;AAuBA,eAAsB,WAClB,MACA,QACA,cACsB;CAiBtB,MAAM,OAAO,MAAM,KACf,cAjBY,QAAQ,YAAY,QAAQ,QAAQ,MAiB1B;kBACZ,sBAAsB;;;;;4BAMhC,CAAC,QAAQ,YAAY,CACzB;CAGA,QADa,MAAM,QAAQ,IAAI,IAAI,OAAQ,MAAM,QAAQ,CAAC,EAAA,CAC9C,KAAI,OAAM;EAClB,IAAI,EAAE,cAAc,OAAO,EAAE,GAAG,YAAY,IAAI,OAAO,EAAE,EAAE;EAC3D,OAAO,OAAO,EAAE,KAAK;EACrB,WAAW,OAAO,EAAE,aAAa,CAAC;CACtC,EAAE;AACN"}
1
+ {"version":3,"file":"history-store-Dtsip8nH.js","names":[],"sources":["../src/metrics/history-store.ts"],"sourcesContent":["/**\n * A little history for the metrics this process already keeps.\n *\n * ## Why this is in the framework and not in the cloud console\n *\n * The console wants to draw \"CPU over the last hour\". The obvious way to get it\n * on GKE is Cloud Monitoring, which already collects exactly this — and which\n * would make the panel unportable the day the platform moves, for a feature\n * every self-hoster also wants. So the history lives where the runtime lives:\n * the process samples ITSELF, into its OWN database, and anything that can read\n * the database can draw the chart. No cluster, no metrics-server, no vendor.\n *\n * That is the same rule the binder follows — the cloud is a better\n * implementation behind the same interface, never a different one.\n *\n * ## Why it stays cheap\n *\n * One row per series per instance per minute. Three series across two replicas\n * is 8,640 rows a day, and the sweep below bounds the window, so the table settles\n * at a size measured in megabytes. It is deliberately NOT a general time-series\n * store: no labels, no cardinality to explode, no per-request rows.\n *\n * A minute is the resolution because that is what the question needs — \"was it\n * slow at 15:40\", \"did my deploy cause that\" — and because a finer grain buys\n * nothing a reader can see on a chart of an hour.\n */\n/**\n * The positional-parameter shape every store in this package settles on.\n *\n * The bootstrapper's own `SqlExec` takes an options object; each store wraps it\n * once and reads better for it. Same two shapes, same reason, as `job-store`.\n */\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\n\nexport type Exec = (sql: string, params?: unknown[]) => Promise<unknown>;\n\n/** Where the samples live. Framework-owned, like `rebase.jobs`. */\nexport const METRICS_HISTORY_TABLE = \"rebase.metric_samples\";\n\n/**\n * How long a sample is kept.\n *\n * Two weeks answers \"is this worse than last week\" and stops well short of\n * being an archive. Anything that needs to outlive it — billing, capacity\n * planning — is a rollup somebody else owns, not a longer retention here.\n *\n * Read it as \"14 days, or since this process started, whichever is longer\": the\n * sweep runs at boot and nowhere else, so a pod up for 90 days holds 90 days.\n * That is a deliberate trade rather than an oversight — a cron for it would be\n * machinery for a table that stays trivial either way, and reads are bounded by\n * the index regardless — but the table does not \"reach a steady size and stay\n * there\" on a long-lived pod, which an earlier version of this comment claimed.\n */\nexport const RETENTION_DAYS = 14;\n\n/** How often the process samples itself. Matched to the resolution it stores. */\nexport const SAMPLE_INTERVAL_MS = 60_000;\n\n/**\n * The series this records. A closed set on purpose — see the cardinality note.\n *\n * A list rather than only a union, because the route validates against it: an\n * unknown `?series=` must be a named 400 rather than an empty chart, which\n * reads exactly like a quiet period.\n *\n * **It lists what `sampleSelf` actually writes, and nothing else.** It used to\n * also name `requests_total`, `errors_total` and `event_loop_delay_ms`, none of\n * which anything ever recorded — so those three were *valid* parameters that\n * returned `points: []`, which is precisely the empty chart the 400 two\n * paragraphs up exists to prevent. A declared-but-unwritten series is worse\n * than an absent one: the 400 tells you the name is wrong, and the empty array\n * tells you the app was quiet.\n *\n * `event_loop_delay_ms` kept its place by gaining a sampler, because it is the\n * one signal here that says whether the process is *healthy* rather than how\n * much it is using, and it has no counter semantics to get wrong.\n *\n * `requests_total` and `errors_total` were dropped rather than wired up. The\n * registry does hold them, but they reset on restart, and a resetting counter\n * summed across replicas is not monotonic — a rolling deploy would draw a\n * cliff that looks like traffic collapsing. That needs a deliberate decision\n * about deltas and resets, not a line added at 2am. Adding either back means\n * adding its sampler in the same commit.\n */\nexport const METRIC_SERIES = [\n \"cpu_millicores\",\n \"memory_bytes\",\n \"event_loop_delay_ms\"\n] as const;\n\nexport type MetricSeries = (typeof METRIC_SERIES)[number];\n\nexport interface MetricSample {\n at: Date;\n series: MetricSeries;\n value: number;\n}\n\n/**\n * Create the table and sweep what has aged out.\n *\n * Called at boot, beside the job and cron stores, and for the same reason they\n * do it there: the only moment the schema is guaranteed to be reachable and\n * nobody is mid-request.\n */\nexport async function ensureMetricsHistory(exec: Exec): Promise<void> {\n await exec(`\n CREATE TABLE IF NOT EXISTS ${METRICS_HISTORY_TABLE} (\n at timestamptz NOT NULL,\n series text NOT NULL,\n instance text NOT NULL,\n value double precision NOT NULL,\n PRIMARY KEY (series, instance, at)\n )\n `, []);\n\n // (series, at DESC) rather than the primary key's order: every read is\n // \"this series across all instances, bounded by time\", and the PK leads with\n // instance, which is the wrong first column for that scan.\n await exec(`\n CREATE INDEX IF NOT EXISTS metric_samples_series_recent\n ON ${METRICS_HISTORY_TABLE} (series, at DESC)\n `, []);\n\n // Framework-internal, so the end-user role must not be able to address it.\n //\n // The driver grants `rebase_user` full DML across the schemas a project\n // uses, plus `ALTER DEFAULT PRIVILEGES` so tables created later inherit it —\n // and this one is created later, at boot. Without the revoke, an\n // authenticated request could read and write another deployment's process\n // metrics, and `pnpm rls:check` correctly called that `[critical]\n // rls-disabled` the first time CI saw the table.\n //\n // REVOKE rather than `ENABLE ROW LEVEL SECURITY`, matching every other table\n // in `REBASE_INTERNAL_TABLES`: RLS with no policy denies the same rows but\n // is the weaker statement, because the grant survives and a later policy\n // reopens it. There is no row here any end user should reach, so \"this role\n // has no privilege at all\" is the honest encoding. The owner connection the\n // recorder runs on is unaffected.\n await exec(revokeInternalTableSql(\"rebase\", \"metric_samples\"), []);\n\n // Swept here rather than by a cron, so a deployment that runs no scheduler\n // still stays bounded. A DELETE is the right tool at this size — a few\n // thousand rows a day — and partitioning would be machinery for a table\n // that never gets big.\n await exec(\n `DELETE FROM ${METRICS_HISTORY_TABLE} WHERE at < now() - make_interval(days => $1)`,\n [RETENTION_DAYS]\n );\n}\n\n/**\n * What this process is using right now.\n *\n * `process.cpuUsage()` is cumulative, so a rate needs two readings and the gap\n * between them — which is why the previous one is threaded through rather than\n * held in a module global: a module global is shared by every test in a file\n * and makes the first assertion depend on whatever ran before it.\n */\nexport function sampleSelf(\n previous: { cpu: NodeJS.CpuUsage; at: number } | null,\n now = Date.now(),\n /**\n * Mean event-loop delay over the last window, in milliseconds, or undefined\n * where it cannot be measured. Passed in rather than read here so this\n * function stays pure: the histogram is a stateful handle the recorder owns.\n */\n eventLoopDelayMs?: number\n): { samples: Omit<MetricSample, \"at\">[]; cursor: { cpu: NodeJS.CpuUsage; at: number } } {\n const cpu = process.cpuUsage();\n const memory = process.memoryUsage();\n const samples: Omit<MetricSample, \"at\">[] = [\n { series: \"memory_bytes\", value: memory.rss }\n ];\n\n // Only when it was actually measured. Zero is a real and common reading for\n // an idle process, so a `?? 0` here would be indistinguishable from a\n // healthy one — the same substitution this module rejects everywhere else.\n if (typeof eventLoopDelayMs === \"number\" && Number.isFinite(eventLoopDelayMs)) {\n samples.push({ series: \"event_loop_delay_ms\", value: eventLoopDelayMs });\n }\n\n if (previous) {\n const elapsedMs = now - previous.at;\n if (elapsedMs > 0) {\n // Microseconds of CPU over milliseconds of wall clock, as\n // millicores: 1000m is one core saturated for the whole window.\n const usedMicros = (cpu.user - previous.cpu.user) + (cpu.system - previous.cpu.system);\n samples.push({ series: \"cpu_millicores\", value: (usedMicros / 1000 / elapsedMs) * 1000 });\n }\n }\n\n return { samples, cursor: { cpu, at: now } };\n}\n\n/**\n * Write one tick's samples, for one instance.\n *\n * ## Why `instance` is part of the key\n *\n * A tenant's replicas share one database, and each records its OWN process. Key\n * a row by `(series, minute)` alone and the pods overwrite each other every\n * tick: one pod at 5m and another at 500m leave whichever wrote last, so a\n * scaled-out tenant charts one arbitrary replica and calls it the app. Adding\n * the instance makes each pod its own row, and lets the read decide whether the\n * question is \"the whole deployment\" or \"which pod is hot\".\n *\n * Cardinality stays bounded: rows are series × replicas × minutes, and replicas\n * are capped by the autoscaling ceiling. Six pods is ~43k rows a day and a\n * fortnight of them is well under a million.\n */\nexport async function recordSamples(\n exec: Exec,\n samples: Omit<MetricSample, \"at\">[],\n instance: string,\n at: Date = new Date()\n): Promise<void> {\n if (samples.length === 0) return;\n // Truncated to the minute, so a pod restarting mid-minute overwrites its own\n // earlier row rather than adding a second one for the same instant.\n const bucket = new Date(Math.floor(at.getTime() / 60_000) * 60_000);\n for (const s of samples) {\n if (!Number.isFinite(s.value)) continue;\n await exec(\n `INSERT INTO ${METRICS_HISTORY_TABLE} (at, series, instance, value)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (series, instance, at) DO UPDATE SET value = EXCLUDED.value`,\n [bucket, s.series, instance, s.value]\n );\n }\n}\n\n/**\n * How a series combines across the replicas that reported it.\n *\n * Not one answer for everything, because the right one differs by what the\n * number means. CPU and memory are consumption: the deployment's figure is the\n * sum, and a mean would make scaling out look like it reduced usage. A queue\n * depth or an event-loop delay is a condition each process is independently in,\n * and summing those produces a number no single pod ever experienced.\n */\nconst COMBINE: Record<MetricSeries, \"sum\" | \"avg\"> = {\n cpu_millicores: \"sum\",\n memory_bytes: \"sum\",\n event_loop_delay_ms: \"avg\"\n};\n\nexport interface SeriesPoint {\n at: string;\n /** The deployment's figure, combined per `COMBINE`. */\n value: number;\n /** How many instances reported in this bucket. */\n instances: number;\n}\n\n/**\n * Read one series over a window, oldest first — the order a chart draws in.\n *\n * Combined across instances rather than returned per-pod. A chart of \"this\n * app's CPU\" is the question people ask; \"which pod is hot\" is answered by the\n * live panel, which already lists instances individually and does not need\n * history to do it.\n *\n * `instances` rides along because a sum whose contributor count changed is not\n * comparable with itself: CPU doubling because the app got busy and CPU\n * doubling because it scaled from one replica to two are different events, and\n * a line chart alone cannot tell them apart.\n */\nexport async function readSeries(\n exec: Exec,\n series: MetricSeries,\n sinceMinutes: number\n): Promise<SeriesPoint[]> {\n const combine = COMBINE[series] === \"avg\" ? \"avg\" : \"sum\";\n // The current minute is EXCLUDED, and that is not tidiness.\n //\n // Each replica samples on its own phase — `setInterval` from whenever that\n // pod booted — so at 10:45:20 the bucket for 10:45 holds rows from whichever\n // pods have ticked so far, typically one of three. The chart takes the last\n // point as its headline figure, so a three-replica app displayed one\n // replica's CPU as the deployment's, the line ended in a cliff, and the\n // instance step dropped underneath it — which the chart's own caption\n // explains to the reader as a scale-down that never happened.\n //\n // Every live pod has written bucket M-1 before the clock enters M, so the\n // newest bucket returned is complete and its `instances` is the true\n // contributor count. `date_trunc` rather than arithmetic because it matches\n // the recorder's `floor(t / 60_000) * 60_000` exactly, and is\n // timezone-independent on a timestamptz.\n const rows = await exec(\n `SELECT at, ${combine}(value) AS value, count(*) AS instances\n FROM ${METRICS_HISTORY_TABLE}\n WHERE series = $1\n AND at >= now() - make_interval(mins => $2)\n AND at < date_trunc('minute', now())\n GROUP BY at\n ORDER BY at ASC`,\n [series, sinceMinutes]\n ) as unknown as { rows?: RawPoint[] } | RawPoint[];\n\n const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);\n return list.map(r => ({\n at: r.at instanceof Date ? r.at.toISOString() : String(r.at),\n value: Number(r.value),\n instances: Number(r.instances ?? 1)\n }));\n}\n\ninterface RawPoint { at: Date | string; value: number; instances?: number | string }\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,IAAa,wBAAwB;;AAmBrC,IAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BlC,IAAa,gBAAgB;CACzB;CACA;CACA;AACJ;;;;;;;;AAiBA,eAAsB,qBAAqB,MAA2B;CAClE,MAAM,KAAK;qCACsB,sBAAsB;;;;;;;OAOpD,CAAC,CAAC;CAKL,MAAM,KAAK;;iBAEE,sBAAsB;OAChC,CAAC,CAAC;CAiBL,MAAM,KAAK,uBAAuB,UAAU,gBAAgB,GAAG,CAAC,CAAC;CAMjE,MAAM,KACF,eAAe,sBAAsB,gDACrC,CAAA,EAAe,CACnB;AACJ;;;;;;;;;AAUA,SAAgB,WACZ,UACA,MAAM,KAAK,IAAI,GAMf,kBACqF;CACrF,MAAM,MAAM,QAAQ,SAAS;CAE7B,MAAM,UAAsC,CACxC;EAAE,QAAQ;EAAgB,OAFf,QAAQ,YAEc,CAAA,CAAO;CAAI,CAChD;CAKA,IAAI,OAAO,qBAAqB,YAAY,OAAO,SAAS,gBAAgB,GACxE,QAAQ,KAAK;EAAE,QAAQ;EAAuB,OAAO;CAAiB,CAAC;CAG3E,IAAI,UAAU;EACV,MAAM,YAAY,MAAM,SAAS;EACjC,IAAI,YAAY,GAAG;GAGf,MAAM,aAAc,IAAI,OAAO,SAAS,IAAI,QAAS,IAAI,SAAS,SAAS,IAAI;GAC/E,QAAQ,KAAK;IAAE,QAAQ;IAAkB,OAAQ,aAAa,MAAO,YAAa;GAAK,CAAC;EAC5F;CACJ;CAEA,OAAO;EAAE;EAAS,QAAQ;GAAE;GAAK,IAAI;EAAI;CAAE;AAC/C;;;;;;;;;;;;;;;;;AAkBA,eAAsB,cAClB,MACA,SACA,UACA,qBAAW,IAAI,KAAK,GACP;CACb,IAAI,QAAQ,WAAW,GAAG;CAG1B,MAAM,yBAAS,IAAI,KAAK,KAAK,MAAM,GAAG,QAAQ,IAAI,GAAM,IAAI,GAAM;CAClE,KAAK,MAAM,KAAK,SAAS;EACrB,IAAI,CAAC,OAAO,SAAS,EAAE,KAAK,GAAG;EAC/B,MAAM,KACF,eAAe,sBAAsB;;uFAGrC;GAAC;GAAQ,EAAE;GAAQ;GAAU,EAAE;EAAK,CACxC;CACJ;AACJ;;;;;;;;;;AAWA,IAAM,UAA+C;CACjD,gBAAgB;CAChB,cAAc;CACd,qBAAqB;AACzB;;;;;;;;;;;;;;AAuBA,eAAsB,WAClB,MACA,QACA,cACsB;CAiBtB,MAAM,OAAO,MAAM,KACf,cAjBY,QAAQ,YAAY,QAAQ,QAAQ,MAiB1B;kBACZ,sBAAsB;;;;;4BAMhC,CAAC,QAAQ,YAAY,CACzB;CAGA,QADa,MAAM,QAAQ,IAAI,IAAI,OAAQ,MAAM,QAAQ,CAAC,EAAA,CAC9C,KAAI,OAAM;EAClB,IAAI,EAAE,cAAc,OAAO,EAAE,GAAG,YAAY,IAAI,OAAO,EAAE,EAAE;EAC3D,OAAO,OAAO,EAAE,KAAK;EACrB,WAAW,OAAO,EAAE,aAAa,CAAC;CACtC,EAAE;AACN"}
package/dist/index.es.js CHANGED
@@ -8,8 +8,8 @@ import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_bl
8
8
  import { a as isDuplicateObjectRace, i as isConcurrentDdlRace, n as createDdlBootstrapper, o as isSQLAdmin, s as isSchemaEditingAdmin, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-5YZCZ8qk.js";
9
9
  import { A as findStorageSuffixCollision, C as canonicalStorageKey, M as storageEnvSuffix, O as sha256Hex, S as canonicalStorageId, b as InvalidStorageKeyError, c as getJwks, d as hasAsymmetricSigningKey, i as generateDownloadToken, j as normalizeStorageSources, k as DEFAULT_STORAGE_SOURCE_KEY, n as configureJwt, p as isJwtConfigured, v as normalizePemFromEnv, x as canonicalStorageBucket, y as InvalidStorageBucketError } from "./jwt-5VN4C6ln.js";
10
10
  import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-D8TvxPLo.js";
11
- import { $ as html, A as activeDevEmailSink, B as hashPassword, C as providerVerifiedEmail, Ct as PUBLIC_STORAGE_PREFIX, D as createDataRateLimiter, E as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, F as SMTPEmailService, G as getEmailVerificationTemplate, H as verifyPassword, I as createEmailService, J as getUserInvitationTemplate, K as getMagicLinkTemplate, L as assertEmailLinkBases, M as createDevEmailSink, N as extractLinks, O as defaultAuthLimiter, P as registerDevEmailSink, Q as escapeHtml, R as resolveEmailLinkBase, S as pkceTokenParams, St as isOperationAllowed, T as createBuiltinAuthAdapter, U as generateSecurePassword, V as validatePasswordStrength, W as getEmailOtpTemplate, X as resolveEmailBranding, Y as getWelcomeEmailTemplate, Z as RawHtml, _ as verifyOidcIdToken, _t as safeCompare, a as resolveRateLimitStoreKind, at as fileTokenAuth, b as createGoogleProvider, bt as hasAdministrativeRole, c as createSlackProvider, ct as queryTokenAuth, d as createDiscordProvider, dt as createApiKeyPreAuth, et as raw, f as createTwitterProvider, ft as createFunctionApiKeyGuard, g as tryVerifyOidcIdToken, gt as extractBearerToken, h as createMicrosoftProvider, ht as validateApiKey, i as createApiKeyStore, it as extractUserFromToken, j as clearActiveDevEmailSink, k as MemoryRateLimitStore, l as createBitbucketProvider, lt as requireAdmin, m as createAppleProvider, mt as isApiKeyToken, n as createCustomAuthAdapter, nt as createAuthMiddleware, o as createSqlRateLimitStore, ot as optionalAuth, p as createFacebookProvider, pt as createStorageApiKeyGuard, q as getPasswordResetTemplate, r as createApiKeyRoutes, rt as createRequireAuth, s as createSpotifyProvider, st as publicObjectAuth, tt as createAdapterAuthMiddleware, u as createGitLabProvider, ut as requireAuth, v as createGitHubProvider, vt as SERVICE_IDENTITY, w as createJwksRoutes, wt as isPublicStoragePath, x as oauthCodeFlowSchema, xt as httpMethodToOperation, y as createLinkedinProvider, yt as scopeDataDriver, z as resolveAuthHooks } from "./auth-BVPx33qB.js";
12
- import { t as revokeInternalTableSql } from "./internal-tables-DpxfPaEB.js";
11
+ import { $ as html, A as activeDevEmailSink, B as hashPassword, C as providerVerifiedEmail, Ct as PUBLIC_STORAGE_PREFIX, D as createDataRateLimiter, E as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, F as SMTPEmailService, G as getEmailVerificationTemplate, H as verifyPassword, I as createEmailService, J as getUserInvitationTemplate, K as getMagicLinkTemplate, L as assertEmailLinkBases, M as createDevEmailSink, N as extractLinks, O as defaultAuthLimiter, P as registerDevEmailSink, Q as escapeHtml, R as resolveEmailLinkBase, S as pkceTokenParams, St as isOperationAllowed, T as createBuiltinAuthAdapter, U as generateSecurePassword, V as validatePasswordStrength, W as getEmailOtpTemplate, X as resolveEmailBranding, Y as getWelcomeEmailTemplate, Z as RawHtml, _ as verifyOidcIdToken, _t as safeCompare, a as resolveRateLimitStoreKind, at as fileTokenAuth, b as createGoogleProvider, bt as hasAdministrativeRole, c as createSlackProvider, ct as queryTokenAuth, d as createDiscordProvider, dt as createApiKeyPreAuth, et as raw, f as createTwitterProvider, ft as createFunctionApiKeyGuard, g as tryVerifyOidcIdToken, gt as extractBearerToken, h as createMicrosoftProvider, ht as validateApiKey, i as createApiKeyStore, it as extractUserFromToken, j as clearActiveDevEmailSink, k as MemoryRateLimitStore, l as createBitbucketProvider, lt as requireAdmin, m as createAppleProvider, mt as isApiKeyToken, n as createCustomAuthAdapter, nt as createAuthMiddleware, o as createSqlRateLimitStore, ot as optionalAuth, p as createFacebookProvider, pt as createStorageApiKeyGuard, q as getPasswordResetTemplate, r as createApiKeyRoutes, rt as createRequireAuth, s as createSpotifyProvider, st as publicObjectAuth, tt as createAdapterAuthMiddleware, u as createGitLabProvider, ut as requireAuth, v as createGitHubProvider, vt as SERVICE_IDENTITY, w as createJwksRoutes, wt as isPublicStoragePath, x as oauthCodeFlowSchema, xt as httpMethodToOperation, y as createLinkedinProvider, yt as scopeDataDriver, z as resolveAuthHooks } from "./auth-Nwxjng9S.js";
12
+ import { t as revokeInternalTableSql } from "./internal-tables-DYVcFFSv.js";
13
13
  import { r as hostEnv, t as logger } from "./logger-DS03e908.js";
14
14
  import { n as errorHandler, t as ApiError } from "./errors-DBwpj9N8.js";
15
15
  import { a as resolveListLimitParam, i as parseQueryOptions, n as parseAggregateSelect, r as parseGroupBy, t as orderByEntriesToTuples } from "./query-parser--nstjRCh.js";
@@ -26,10 +26,10 @@ import { t as FunctionSelectionError } from "./selection-CRpqKUbt.js";
26
26
  import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-d9WMFENB.js";
27
27
  import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-BB82dWuU.js";
28
28
  import { t as createCronRoutes } from "./cron-routes-BsukhMGm.js";
29
- import { t as createCronStore } from "./cron-store-CbFfQhbg.js";
29
+ import { t as createCronStore } from "./cron-store-dof07MjG.js";
30
30
  import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-CRdZkA6c.js";
31
- import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-B2ELModo.js";
32
- import { t as METRIC_SERIES } from "./history-store-oiqhb3HU.js";
31
+ import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-BKG0mDlS.js";
32
+ import { t as METRIC_SERIES } from "./history-store-Dtsip8nH.js";
33
33
  import { createHash, createSign, randomBytes } from "node:crypto";
34
34
  import * as fs$3 from "fs";
35
35
  import fs, { existsSync } from "fs";
@@ -13038,7 +13038,7 @@ async function _initializeRebaseBackend(config) {
13038
13038
  ]) {
13039
13039
  const providerConfig = safeAuthConfig[key];
13040
13040
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
13041
- const createFn = (await import("./auth-BVPx33qB.js").then((n) => n.t))[factory];
13041
+ const createFn = (await import("./auth-Nwxjng9S.js").then((n) => n.t))[factory];
13042
13042
  oauthProviders.push(createFn(providerConfig));
13043
13043
  }
13044
13044
  }
@@ -13466,7 +13466,7 @@ async function _initializeRebaseBackend(config) {
13466
13466
  const { loadCronJobsWithDiagnostics } = await import("./cron-loader-d9WMFENB.js").then((n) => n.t);
13467
13467
  const { CronScheduler } = await import("./cron-scheduler-BB82dWuU.js").then((n) => n.n);
13468
13468
  const { createCronRoutes } = await import("./cron-routes-BsukhMGm.js").then((n) => n.n);
13469
- const { createCronStore } = await import("./cron-store-CbFfQhbg.js").then((n) => n.n);
13469
+ const { createCronStore } = await import("./cron-store-dof07MjG.js").then((n) => n.n);
13470
13470
  const { jobs: loadedCronJobs, problems: cronProblems } = await loadCronJobsWithDiagnostics(config.cronsDir);
13471
13471
  cronScheduler = new CronScheduler();
13472
13472
  cronScheduler.setClient(serverSingleton);
@@ -13516,7 +13516,7 @@ async function _initializeRebaseBackend(config) {
13516
13516
  }
13517
13517
  let metricsHistory;
13518
13518
  try {
13519
- const { createMetricsHistory } = await import("./history-recorder-B67maiTi.js");
13519
+ const { createMetricsHistory } = await import("./history-recorder-BMSFyySP.js");
13520
13520
  metricsHistory = createMetricsHistory(defaultDriver);
13521
13521
  if (metricsHistory) {
13522
13522
  await metricsHistory.ensure();
@@ -13528,7 +13528,7 @@ async function _initializeRebaseBackend(config) {
13528
13528
  }
13529
13529
  let jobQueue;
13530
13530
  if (config.jobs?.enabled) {
13531
- const { createJobStore, createJobQueue } = await import("./jobs-B2ELModo.js").then((n) => n.t);
13531
+ const { createJobStore, createJobQueue } = await import("./jobs-BKG0mDlS.js").then((n) => n.t);
13532
13532
  const store = createJobStore(defaultDriver);
13533
13533
  if (store) {
13534
13534
  await store.ensureTable();
@@ -16932,6 +16932,21 @@ function unpackedBundleSource(destination) {
16932
16932
  }
16933
16933
  }
16934
16934
  /**
16935
+ * A bundle directory worth falling back to, or undefined.
16936
+ *
16937
+ * "Worth" means it has a manifest — the same test the entrypoint and
16938
+ * `loadBundle` use for "there is a bundle here". A path that points at an empty
16939
+ * or half-unpacked directory is not a fallback, it is a second failure.
16940
+ *
16941
+ * Extracted so the decision can be tested: the code path that uses it only runs
16942
+ * when a download has already failed, which is not a state a boot test can
16943
+ * reach without a database.
16944
+ */
16945
+ function usableBundleFallback(dir) {
16946
+ if (!dir) return void 0;
16947
+ return fs$1.existsSync(path$1.join(dir, "manifest.json")) ? dir : void 0;
16948
+ }
16949
+ /**
16935
16950
  * Whether this process should fetch its bundle rather than read one from disk.
16936
16951
  *
16937
16952
  * An explicit `REBASE_BUNDLE` — a path — always wins. A platform that mounted a
@@ -16955,14 +16970,36 @@ function isGnuTar() {
16955
16970
  }
16956
16971
  /** Untar with the system `tar`, which every base image has. */
16957
16972
  async function extractWithTar(tarball, destination) {
16958
- const args = [
16959
- "-xzmf",
16960
- tarball,
16961
- "-C",
16962
- destination
16963
- ];
16964
- if (await isGnuTar()) args.push("--no-same-owner", "--no-same-permissions", "--no-overwrite-dir");
16965
- await run("tar", args);
16973
+ const staging = path$1.join(destination, ".rebase-unpack");
16974
+ fs$1.rmSync(staging, {
16975
+ recursive: true,
16976
+ force: true
16977
+ });
16978
+ fs$1.mkdirSync(staging, { recursive: true });
16979
+ try {
16980
+ const args = [
16981
+ "-xzmf",
16982
+ tarball,
16983
+ "-C",
16984
+ staging
16985
+ ];
16986
+ if (await isGnuTar()) args.push("--no-same-owner", "--no-same-permissions");
16987
+ await run("tar", args);
16988
+ for (const entry of fs$1.readdirSync(staging)) {
16989
+ const from = path$1.join(staging, entry);
16990
+ const to = path$1.join(destination, entry);
16991
+ fs$1.rmSync(to, {
16992
+ recursive: true,
16993
+ force: true
16994
+ });
16995
+ fs$1.renameSync(from, to);
16996
+ }
16997
+ } finally {
16998
+ fs$1.rmSync(staging, {
16999
+ recursive: true,
17000
+ force: true
17001
+ });
17002
+ }
16966
17003
  }
16967
17004
  /**
16968
17005
  * Download and unpack a bundle, returning the directory it landed in.
@@ -17228,11 +17265,22 @@ function bundleRootIn(directory) {
17228
17265
  * rebuilt, which is the precondition for patching a fleet.
17229
17266
  */
17230
17267
  async function bootFromBundle(options = {}) {
17231
- const fetchedDir = !options.bundleDir && !options.bundle && shouldFetchBundle() ? await fetchBundle({
17232
- url: process.env[BUNDLE_URL_ENV],
17233
- token: process.env[BUNDLE_TOKEN_ENV],
17234
- destination: process.env["REBASE_BUNDLE_FETCH_DIR"] || void 0
17235
- }) : void 0;
17268
+ let fetchedDir;
17269
+ if (!options.bundleDir && !options.bundle && shouldFetchBundle()) try {
17270
+ fetchedDir = await fetchBundle({
17271
+ url: process.env[BUNDLE_URL_ENV],
17272
+ token: process.env[BUNDLE_TOKEN_ENV],
17273
+ destination: process.env["REBASE_BUNDLE_FETCH_DIR"] || void 0
17274
+ });
17275
+ } catch (error) {
17276
+ const fallback = usableBundleFallback(options.bundleFallbackDir);
17277
+ if (!fallback) throw error;
17278
+ logger.error(`Could not fetch the bundle, so the copy already on disk is being served — it may be older than the deployment that started this pod. Fetch error: ${error instanceof Error ? error.message : String(error)}`, {
17279
+ url: process.env[BUNDLE_URL_ENV],
17280
+ fallback
17281
+ });
17282
+ fetchedDir = fallback;
17283
+ }
17236
17284
  const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
17237
17285
  const bundle = options.bundle ?? loadBundle(bundleDir);
17238
17286
  const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();