@rebasepro/server 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/{admin_block-0Xu0r6eZ.js → admin_block-DxKLmdiv.js} +26 -8
  2. package/dist/{admin_block-0Xu0r6eZ.js.map → admin_block-DxKLmdiv.js.map} +1 -1
  3. package/dist/api/ast-schema-editor.d.ts +15 -0
  4. package/dist/api/rest/api-generator.d.ts +28 -0
  5. package/dist/{ast-schema-editor-C6mDz0XN.js → ast-schema-editor-CslO8Oje.js} +24 -4
  6. package/dist/ast-schema-editor-CslO8Oje.js.map +1 -0
  7. package/dist/auth/jwt.d.ts +67 -0
  8. package/dist/auth/rls-scope.d.ts +14 -0
  9. package/dist/{auth-DJsLXsCR.js → auth-gzoRjnuh.js} +24 -12
  10. package/dist/auth-gzoRjnuh.js.map +1 -0
  11. package/dist/boot/env.d.ts +8 -2
  12. package/dist/boot/fetch-bundle.d.ts +19 -0
  13. package/dist/boot/role.d.ts +2 -0
  14. package/dist/contract-routes-eLxV0le1.js.map +1 -1
  15. package/dist/{cron-routes-Bfwni8Zg.js → cron-routes-BamgO0Xr.js} +2 -2
  16. package/dist/{cron-routes-Bfwni8Zg.js.map → cron-routes-BamgO0Xr.js.map} +1 -1
  17. package/dist/{cron-store-Bsiw4Q6u.js → cron-store-yXOyIEmn.js} +2 -3
  18. package/dist/{cron-store-Bsiw4Q6u.js.map → cron-store-yXOyIEmn.js.map} +1 -1
  19. package/dist/db/ambient-transaction.d.ts +48 -0
  20. package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
  21. package/dist/deploy/pod-contract.d.ts +1 -1
  22. package/dist/errors-DMImyqyR.js.map +1 -1
  23. package/dist/functions/index.js.map +1 -1
  24. package/dist/{history-recorder-r5_IzSHK.js → history-recorder-hBGuXcZc.js} +2 -2
  25. package/dist/{history-recorder-r5_IzSHK.js.map → history-recorder-hBGuXcZc.js.map} +1 -1
  26. package/dist/{history-store-D4RVK-uZ.js → history-store-C9EtGtoL.js} +5 -6
  27. package/dist/history-store-C9EtGtoL.js.map +1 -0
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.es.js +2266 -93
  30. package/dist/index.es.js.map +1 -1
  31. package/dist/init/callback-client.d.ts +16 -0
  32. package/dist/init/surfaces.d.ts +37 -7
  33. package/dist/jobs/types.d.ts +5 -0
  34. package/dist/{jobs-CW5lm_Ix.js → jobs-DZ8DLLcU.js} +82 -13
  35. package/dist/jobs-DZ8DLLcU.js.map +1 -0
  36. package/dist/{jwt-DATvkKB_.js → jwt-C4OW-DNq.js} +136 -4
  37. package/dist/jwt-C4OW-DNq.js.map +1 -0
  38. package/dist/logger-DO2PZc4i.js.map +1 -1
  39. package/dist/mcp/consent-page.d.ts +75 -0
  40. package/dist/mcp/mcp-routes.d.ts +52 -0
  41. package/dist/mcp/mcp-tools.d.ts +51 -0
  42. package/dist/mcp/oauth-metadata.d.ts +123 -0
  43. package/dist/mcp/oauth-routes.d.ts +52 -0
  44. package/dist/mcp/oauth-store.d.ts +157 -0
  45. package/dist/metrics/index.d.ts +16 -1
  46. package/dist/{openapi-generator-DGyLbISS.js → openapi-generator-CAFKF36E.js} +2 -8
  47. package/dist/openapi-generator-CAFKF36E.js.map +1 -0
  48. package/dist/{query-parser-BQiPZrM-.js → query-parser-uqzkZ82i.js} +2 -2
  49. package/dist/{query-parser-BQiPZrM-.js.map → query-parser-uqzkZ82i.js.map} +1 -1
  50. package/dist/{schema-editor-routes-C3TLZqAC.js → schema-editor-routes-BKOmdf4M.js} +2 -2
  51. package/dist/{schema-editor-routes-C3TLZqAC.js.map → schema-editor-routes-BKOmdf4M.js.map} +1 -1
  52. package/dist/services/webhook-service.d.ts +11 -8
  53. package/dist/sql-rows-C6GEc2oE.js +45 -0
  54. package/dist/sql-rows-C6GEc2oE.js.map +1 -0
  55. package/dist/src-Br6ARbs6.js.map +1 -1
  56. package/dist/{src-DqZ9YiGA.js → src-DW1cQ_28.js} +261 -17
  57. package/dist/src-DW1cQ_28.js.map +1 -0
  58. package/dist/utils/portable-crypto.d.ts +8 -0
  59. package/package.json +5 -5
  60. package/dist/ast-schema-editor-C6mDz0XN.js.map +0 -1
  61. package/dist/auth-DJsLXsCR.js.map +0 -1
  62. package/dist/history-store-D4RVK-uZ.js.map +0 -1
  63. package/dist/internal-tables-DYVcFFSv.js +0 -117
  64. package/dist/internal-tables-DYVcFFSv.js.map +0 -1
  65. package/dist/jobs-CW5lm_Ix.js.map +0 -1
  66. package/dist/jwt-DATvkKB_.js.map +0 -1
  67. package/dist/openapi-generator-DGyLbISS.js.map +0 -1
  68. package/dist/src-DqZ9YiGA.js.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"file":"history-store-D4RVK-uZ.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,117 +0,0 @@
1
- import { createRequire as __rebaseCreateRequire } from "module";
2
- import __rebaseProcess from "process";
3
- globalThis.process ??= __rebaseProcess;
4
- __rebaseCreateRequire(import.meta.url);
5
- //#region ../common/src/util/internal-tables.ts
6
- /**
7
- * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
8
- * end-user role away from them.
9
- *
10
- * ## Why this exists
11
- *
12
- * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
13
- * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
14
- * in the schemas a project uses — including `rebase`, because a project's own
15
- * collections are allowed to live there (the scaffold puts `users` there). It
16
- * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
17
- * migrating role inherits the same grant.
18
- *
19
- * Every framework-internal table is created later: auth's tables come up during
20
- * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
21
- * job registers, `idempotency_keys` on the first request that carries a key. So
22
- * they all inherited full DML for the end-user role — and none of them enables
23
- * row-level security, because none of them is a collection with
24
- * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
25
- * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
26
- * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
27
- * `rebase.api_keys` (including its `admin` flag), and insert into
28
- * `rebase.app_config`.
29
- *
30
- * Nothing routes a user-context query at those tables today, so this was not
31
- * reachable over the API. That is the wrong thing to depend on: the documented
32
- * model is that RLS is the authorization boundary, and these tables sat outside
33
- * it. The boundary is now a privilege boundary instead — the role simply cannot
34
- * address them.
35
- *
36
- * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
37
- *
38
- * RLS with no policy denies every row, which is the same outcome, but it is the
39
- * *weaker* statement: it leaves the grant in place, so a later policy — or a
40
- * `FORCE` flag cleared by some future migration — reopens the table. There is no
41
- * row of `refresh_tokens` any end user should ever reach, so the honest encoding
42
- * is "this role has no privilege here at all". It also keeps the owner
43
- * connection (which auth actually runs on) completely unaffected.
44
- *
45
- * ## Keeping it true
46
- *
47
- * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
48
- * "platform" schema — and its `rls-disabled` check fires on exactly the
49
- * condition this module removes: RLS off *and* a DML grant to a reachable role.
50
- * So a table added here without a revoke is caught by `pnpm rls:check`, not by
51
- * someone re-reading this file.
52
- */
53
- /**
54
- * The Postgres role authenticated requests run as.
55
- *
56
- * Defined here rather than in the Postgres driver because both the driver (which
57
- * provisions the role) and this module (which revokes on its behalf) need it,
58
- * and a second spelling of a role name is a silent no-op waiting to happen.
59
- */
60
- var REBASE_USER_ROLE = "rebase_user";
61
- /** Postgres identifiers this module is willing to interpolate. */
62
- var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
63
- /**
64
- * A single statement that takes every privilege on `schema.table` away from the
65
- * end-user role.
66
- *
67
- * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
68
- * happen in practice:
69
- *
70
- * - the role does not exist when the connection is unprivileged (Rebase then
71
- * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
72
- * missing role is an error, not a no-op;
73
- * - the table may not exist yet — `cron_logs` never appears in a project with
74
- * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
75
- *
76
- * The third guard is the one that decides whether the *right* table is being
77
- * revoked. The names in {@link REBASE_INTERNAL_TABLES} are unqualified, and the
78
- * boot-time sweep in `ensureAppRole` applies all of them to every schema a
79
- * project uses — including the schema its own collections live in. A project is
80
- * free to call a collection `jobs`, `branches` or `api_keys`, and when it does,
81
- * the sweep was revoking `rebase_user`'s DML on the project's table on every
82
- * single boot. That is not a subtle degradation: the collection's whole API
83
- * answers 500 `permission denied for table …` from then on, which is what
84
- * happened to a public job board whose vacancies live in `public.jobs`.
85
- *
86
- * `relrowsecurity` separates the two cleanly, and it is the same fact this
87
- * module already relies on. Framework-internal tables carry no RLS — that is the
88
- * premise stated at the top of this file, and the reason a revoke is needed at
89
- * all. Every collection table has it enabled, because that is how Rebase
90
- * enforces `securityRules`. So "RLS is off" is exactly "this is not somebody's
91
- * collection", and the guard also subsumes the hand-carved `users` exception:
92
- * the auth user table is a collection, has RLS, and would now be skipped on its
93
- * own merits rather than by being kept off a list.
94
- *
95
- * One command, so it is safe on handles that speak the extended query protocol
96
- * and reject multi-statement strings.
97
- */
98
- function revokeInternalTableSql(schema, table) {
99
- if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
100
- if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
101
- const qualified = `"${schema}"."${table}"`;
102
- return `
103
- DO $rebase_revoke$
104
- BEGIN
105
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
106
- AND to_regclass('${qualified}') IS NOT NULL
107
- AND NOT (SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass('${qualified}')) THEN
108
- EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
109
- END IF;
110
- END
111
- $rebase_revoke$;
112
- `.trim();
113
- }
114
- //#endregion
115
- export { revokeInternalTableSql as t };
116
-
117
- //# sourceMappingURL=internal-tables-DYVcFFSv.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"internal-tables-DYVcFFSv.js","names":[],"sources":["../../common/src/util/internal-tables.ts"],"sourcesContent":["/**\n * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the\n * end-user role away from them.\n *\n * ## Why this exists\n *\n * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role\n * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table\n * in the schemas a project uses — including `rebase`, because a project's own\n * collections are allowed to live there (the scaffold puts `users` there). It\n * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the\n * migrating role inherits the same grant.\n *\n * Every framework-internal table is created later: auth's tables come up during\n * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first\n * job registers, `idempotency_keys` on the first request that carries a key. So\n * they all inherited full DML for the end-user role — and none of them enables\n * row-level security, because none of them is a collection with\n * `securityRules`. Measured on a freshly provisioned database, `SET ROLE\n * rebase_user` could read `rebase.refresh_tokens` (session token hashes),\n * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and\n * `rebase.api_keys` (including its `admin` flag), and insert into\n * `rebase.app_config`.\n *\n * Nothing routes a user-context query at those tables today, so this was not\n * reachable over the API. That is the wrong thing to depend on: the documented\n * model is that RLS is the authorization boundary, and these tables sat outside\n * it. The boundary is now a privilege boundary instead — the role simply cannot\n * address them.\n *\n * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY\n *\n * RLS with no policy denies every row, which is the same outcome, but it is the\n * *weaker* statement: it leaves the grant in place, so a later policy — or a\n * `FORCE` flag cleared by some future migration — reopens the table. There is no\n * row of `refresh_tokens` any end user should ever reach, so the honest encoding\n * is \"this role has no privilege here at all\". It also keeps the owner\n * connection (which auth actually runs on) completely unaffected.\n *\n * ## Keeping it true\n *\n * `packages/rls-check` scans the `rebase` schema — it used to skip it as a\n * \"platform\" schema — and its `rls-disabled` check fires on exactly the\n * condition this module removes: RLS off *and* a DML grant to a reachable role.\n * So a table added here without a revoke is caught by `pnpm rls:check`, not by\n * someone re-reading this file.\n */\n\n/**\n * The Postgres role authenticated requests run as.\n *\n * Defined here rather than in the Postgres driver because both the driver (which\n * provisions the role) and this module (which revokes on its behalf) need it,\n * and a second spelling of a role name is a silent no-op waiting to happen.\n */\nexport const REBASE_USER_ROLE = \"rebase_user\";\n\n/**\n * Framework-internal table names, unqualified.\n *\n * Deliberately NOT including `users`: the auth user table is also a collection,\n * with `securityRules`, RLS enabled and policies applied. Users read their own\n * row through it — revoking there would break sign-in. `revokeInternalTableSql`\n * now skips any table with RLS enabled, so that exception is enforced rather\n * than merely remembered — and so is the same hazard for every other name here,\n * any of which a project may legitimately use for a collection of its own.\n *\n * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`\n * because `db migrate apply` passes `--revisions-schema rebase`.\n *\n * Every entry here must also be revoked by whatever creates it, and vice versa:\n * the creation-time revoke fires once, on the boot that first makes the table,\n * so it cannot help a database provisioned before that revoke existed. This\n * list is what the boot-time sweep in `ensureAppRole` iterates, and the sweep\n * is the only thing that can repair an already-granted table. A table revoked\n * at creation but missing here is therefore permanently stranded on any\n * database that predates its revoke.\n *\n * These names are unqualified, and the boot-time sweep applies them to every\n * schema a project uses — so an entry here is a claim on that name in `public`\n * as much as in `rebase`. `jobs` is Rebase's queue at `rebase.jobs` AND a\n * perfectly ordinary collection name, and revoking `public.jobs` from a project\n * that owns it leaves every read failing 42501 with correct policies applied\n * and nothing in the RLS logs to explain it. The `relrowsecurity` guard in\n * `revokeInternalTableSql` is what makes a common noun safe here — it is not a\n * licence to claim more of them.\n */\nexport const REBASE_INTERNAL_TABLES: readonly string[] = [\n // auth\n \"user_identities\",\n \"refresh_tokens\",\n \"password_reset_tokens\",\n \"magic_link_tokens\",\n \"mfa_factors\",\n \"mfa_challenges\",\n \"recovery_codes\",\n \"app_config\",\n \"schema_meta\",\n // platform services\n \"api_keys\",\n \"cron_logs\",\n \"cron_claims\",\n \"jobs\",\n \"rate_limit_hits\",\n \"idempotency_keys\",\n \"entity_history\",\n \"branches\",\n \"metric_samples\",\n // realtime channels — authorization for these lives in the channel rules the\n // server evaluates before it reads or writes, never in a row policy\n \"channel_messages\",\n \"channel_cursors\",\n \"channel_presence\",\n // migration bookkeeping\n \"atlas_schema_revisions\"\n];\n\n/** Postgres identifiers this module is willing to interpolate. */\nconst SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;\n\n/**\n * A single statement that takes every privilege on `schema.table` away from the\n * end-user role.\n *\n * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which\n * happen in practice:\n *\n * - the role does not exist when the connection is unprivileged (Rebase then\n * relies on native RLS rather than a role switch), and a bare `REVOKE` on a\n * missing role is an error, not a no-op;\n * - the table may not exist yet — `cron_logs` never appears in a project with\n * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.\n *\n * The third guard is the one that decides whether the *right* table is being\n * revoked. The names in {@link REBASE_INTERNAL_TABLES} are unqualified, and the\n * boot-time sweep in `ensureAppRole` applies all of them to every schema a\n * project uses — including the schema its own collections live in. A project is\n * free to call a collection `jobs`, `branches` or `api_keys`, and when it does,\n * the sweep was revoking `rebase_user`'s DML on the project's table on every\n * single boot. That is not a subtle degradation: the collection's whole API\n * answers 500 `permission denied for table …` from then on, which is what\n * happened to a public job board whose vacancies live in `public.jobs`.\n *\n * `relrowsecurity` separates the two cleanly, and it is the same fact this\n * module already relies on. Framework-internal tables carry no RLS — that is the\n * premise stated at the top of this file, and the reason a revoke is needed at\n * all. Every collection table has it enabled, because that is how Rebase\n * enforces `securityRules`. So \"RLS is off\" is exactly \"this is not somebody's\n * collection\", and the guard also subsumes the hand-carved `users` exception:\n * the auth user table is a collection, has RLS, and would now be skipped on its\n * own merits rather than by being kept off a list.\n *\n * One command, so it is safe on handles that speak the extended query protocol\n * and reject multi-statement strings.\n */\nexport function revokeInternalTableSql(schema: string, table: string): string {\n if (!SAFE_IDENTIFIER.test(schema)) {\n throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);\n }\n if (!SAFE_IDENTIFIER.test(table)) {\n throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);\n }\n const qualified = `\"${schema}\".\"${table}\"`;\n return `\n DO $rebase_revoke$\n BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')\n AND to_regclass('${qualified}') IS NOT NULL\n AND NOT (SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass('${qualified}')) THEN\n EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';\n END IF;\n END\n $rebase_revoke$;\n `.trim();\n}\n\n/**\n * Revoke on every internal table in `schema`, one statement at a time.\n *\n * Best-effort per table: a connection that does not own one of them (a\n * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and\n * that must not take down a boot. The caller decides how loud to be — `onError`\n * exists so the driver can warn without this module importing a logger.\n */\nexport async function revokeInternalTableAccess(\n execute: (sql: string) => Promise<unknown>,\n schema: string,\n options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }\n): Promise<void> {\n for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {\n try {\n await execute(revokeInternalTableSql(schema, table));\n } catch (error) {\n options?.onError?.(table, error);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,IAAa,mBAAmB;;AA+DhC,IAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCxB,SAAgB,uBAAuB,QAAgB,OAAuB;CAC1E,IAAI,CAAC,gBAAgB,KAAK,MAAM,GAC5B,MAAM,IAAI,MAAM,qDAAqD,KAAK,UAAU,MAAM,GAAG;CAEjG,IAAI,CAAC,gBAAgB,KAAK,KAAK,GAC3B,MAAM,IAAI,MAAM,oDAAoD,KAAK,UAAU,KAAK,GAAG;CAE/F,MAAM,YAAY,IAAI,OAAO,KAAK,MAAM;CACxC,OAAO;;;iEAGsD,iBAAiB;kCAChD,UAAU;uFAC2C,UAAU;yCACxD,UAAU,QAAQ,iBAAiB;;;;MAItE,KAAK;AACX"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"jobs-CW5lm_Ix.js","names":[],"sources":["../src/jobs/job-store.ts","../src/jobs/job-queue.ts","../src/jobs/index.ts"],"sourcesContent":["import type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, type SqlExec } from \"../boot/ddl-bootstrap.js\";\nimport type { JobRecord } from \"./types.js\";\n\n/**\n * The queue's storage, as SQL against `rebase.jobs`.\n *\n * Split from the worker for the same reason `cron-store` is split from\n * `cron-scheduler`: the interesting parts here are three statements that have\n * to be exactly right under concurrency, and they are much easier to reason\n * about — and to test — away from a polling loop.\n */\n\nconst TABLE = \"rebase.jobs\";\n\n/** How long finished jobs are kept before the boot sweep removes them. */\nconst SUCCEEDED_RETENTION_DAYS = 3;\n/**\n * Failures outlive successes by a lot. A dead-lettered job is evidence, and the\n * person who needs it is usually looking on Monday for something that happened\n * on Friday night.\n */\nconst FAILED_RETENTION_DAYS = 30;\n\n/** A row as Postgres returns it. */\ninterface JobRow {\n id: string;\n task: string;\n payload: unknown;\n status: string;\n run_at: string;\n attempts: number;\n max_attempts: number;\n last_error: string | null;\n created_at: string;\n updated_at: string;\n}\n\nfunction toRecord(row: JobRow): JobRecord {\n return {\n id: row.id,\n task: row.task,\n payload: row.payload,\n status: row.status as JobRecord[\"status\"],\n runAt: new Date(row.run_at).toISOString(),\n attempts: Number(row.attempts),\n maxAttempts: Number(row.max_attempts),\n lastError: row.last_error,\n createdAt: new Date(row.created_at).toISOString(),\n updatedAt: new Date(row.updated_at).toISOString()\n };\n}\n\n/** Same rule as `cron-store`: match the SQLSTATE, never the message. */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) => e.code === \"23505\");\n}\n\nexport interface JobStore {\n ensureTable(): Promise<void>;\n /** Returns the new job's id, or `null` if an idempotency key matched unfinished work. */\n insert(job: {\n task: string;\n payload: unknown;\n runAt: Date;\n maxAttempts: number;\n idempotencyKey?: string;\n }): Promise<string | null>;\n /** Atomically take up to `limit` runnable jobs for this worker. */\n claim(limit: number, workerId: string): Promise<JobRecord[]>;\n complete(id: string): Promise<void>;\n /** Back to `pending` with a later `runAt`, or `failed` when out of attempts. */\n fail(id: string, error: string, retryAt: Date | null): Promise<void>;\n /** Return jobs stranded by a worker that died holding them. Resolves with how many. */\n reapExpired(visibilityTimeoutMs: number): Promise<number>;\n fetch(id: string): Promise<JobRecord | null>;\n}\n\nexport function createJobStore(driver: DataDriver): JobStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\n \"⚠️ [jobs] DataDriver does not support SQL admin — the durable job queue is unavailable. \" +\n \"Work that would have been queued runs inline instead.\"\n );\n return undefined;\n }\n\n // Two shapes of the same call: the bootstrapper's `SqlExec` takes an\n // options object, while everything below reads better with a positional\n // parameter array.\n const execRaw: SqlExec = (sqlText, options) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n const exec = (sqlText: string, params?: unknown[]) =>\n execRaw(sqlText, params ? { params } : undefined);\n\n const ddl = createDdlBootstrapper(execRaw, \"jobs\");\n\n return {\n async ensureTable(): Promise<void> {\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 task TEXT NOT NULL,\n payload JSONB,\n status TEXT NOT NULL DEFAULT 'pending',\n run_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n locked_at TIMESTAMPTZ,\n locked_by TEXT,\n idempotency_key TEXT,\n last_error TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n )\n `);\n\n // The claim query's index. Partial, because the rows it has to find\n // fast are a shrinking minority of a table that also holds every\n // success and every dead letter.\n await ddl.ensureObject(\"Creating idx_jobs_runnable\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_runnable\n ON ${TABLE}(run_at, created_at)\n WHERE status = 'pending'\n `);\n\n // Finds jobs stranded by a dead worker.\n await ddl.ensureObject(\"Creating idx_jobs_running\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_running\n ON ${TABLE}(locked_at)\n WHERE status = 'running'\n `);\n\n // What makes `idempotencyKey` a guarantee rather than a\n // check-then-act race: two instances reacting to one event both\n // reach the INSERT, and the index decides. Partial on unfinished\n // work — see `EnqueueOptions.idempotencyKey` for why a key must be\n // reusable once its job is done.\n await ddl.ensureObject(\"Creating idx_jobs_idempotency\", `\n CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_idempotency\n ON ${TABLE}(idempotency_key)\n WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')\n `);\n\n const ready = await ddl.isReadable(TABLE);\n\n if (ready) {\n await ddl.step(\"Job retention sweep\", async () => {\n await exec(\n `DELETE FROM ${TABLE}\n WHERE (status = 'succeeded' AND updated_at < now() - make_interval(days => $1))\n OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))`,\n [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS]\n );\n });\n\n // Payloads are arbitrary application data — a webhook body, a\n // user id, whatever was passed — and a writable queue lets any\n // signed-in user schedule work of their choosing under the\n // server's own authority. Same reasoning, and the same\n // unconditional re-application, as `cron_claims`.\n await ddl.step(\"Revoking end-user access to jobs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"jobs\")));\n\n logger.info(\"✅ Job queue table ready\");\n } else {\n logger.error(\n `❌ [jobs] ${TABLE} is unavailable — nothing can be queued and nothing queued earlier ` +\n \"will run. Callers fall back to running the work inline.\"\n );\n }\n },\n\n async insert(job): Promise<string | null> {\n try {\n const rows = await exec(\n `INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES ($1, $2::jsonb, $3, $4, $5)\n RETURNING id`,\n [\n job.task,\n JSON.stringify(job.payload ?? null),\n job.runAt.toISOString(),\n job.maxAttempts,\n job.idempotencyKey ?? null\n ]\n );\n return (rows?.[0]?.id as string) ?? null;\n } catch (error) {\n // The losing side of an idempotency race. Not an error: the\n // work the caller wanted is already queued, which is the\n // outcome they asked for.\n if (isUniqueViolation(error)) return null;\n throw error;\n }\n },\n\n async claim(limit: number, workerId: string): Promise<JobRecord[]> {\n // `FOR UPDATE SKIP LOCKED` is the whole design. The inner select\n // takes row locks on the jobs it picks and *skips* any a concurrent\n // worker already holds, so N workers polling the same table divide\n // the work instead of contending for the head of it — and no job is\n // ever handed to two of them.\n //\n // `attempts` is incremented here, on claim, rather than on failure.\n // A worker that is killed mid-job never reports anything, so\n // counting on failure would let a job that crashes the process be\n // retried forever, once per restart, taking the process down each\n // time.\n const rows = await exec(\n `UPDATE ${TABLE} SET\n status = 'running',\n attempts = attempts + 1,\n locked_at = now(),\n locked_by = $2,\n updated_at = now()\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE status = 'pending' AND run_at <= now()\n ORDER BY run_at, created_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING *`,\n [limit, workerId]\n );\n return (rows as unknown as JobRow[]).map(toRecord);\n },\n\n async complete(id: string): Promise<void> {\n await exec(\n `UPDATE ${TABLE} SET status = 'succeeded', locked_at = NULL, locked_by = NULL,\n last_error = NULL, updated_at = now()\n WHERE id = $1`,\n [id]\n );\n },\n\n async fail(id: string, error: string, retryAt: Date | null): Promise<void> {\n if (retryAt) {\n await exec(\n `UPDATE ${TABLE} SET status = 'pending', run_at = $2, locked_at = NULL,\n locked_by = NULL, last_error = $3, updated_at = now()\n WHERE id = $1`,\n [id, retryAt.toISOString(), error]\n );\n return;\n }\n await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = $2, updated_at = now()\n WHERE id = $1`,\n [id, error]\n );\n },\n\n async reapExpired(visibilityTimeoutMs: number): Promise<number> {\n // A worker killed while holding a job cannot release it, so nothing\n // but a timeout will ever free the row. Jobs that still have\n // attempts left go back to `pending`; the rest are dead-lettered\n // with an error that says what happened, because \"attempts: 3,\n // lastError: null\" is otherwise a genuinely baffling row to find.\n const seconds = Math.max(1, Math.round(visibilityTimeoutMs / 1000));\n\n const revived = await exec(\n `UPDATE ${TABLE} SET status = 'pending', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding; the job was reclaimed', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts < max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const buried = await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding on the final attempt', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts >= max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const count = (revived?.length ?? 0) + (buried?.length ?? 0);\n if (count > 0) {\n logger.warn(\n `[jobs] Reclaimed ${count} job(s) from a worker that stopped responding ` +\n `(${revived?.length ?? 0} retryable, ${buried?.length ?? 0} dead-lettered)`\n );\n }\n return count;\n },\n\n async fetch(id: string): Promise<JobRecord | null> {\n const rows = await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]);\n const row = (rows as unknown as JobRow[])[0];\n return row ? toRecord(row) : null;\n }\n };\n}\n","import { randomUUID } from \"crypto\";\nimport { logger } from \"../utils/logger.js\";\nimport type { JobStore } from \"./job-store.js\";\nimport type { EnqueueOptions, JobHandler, JobQueueClient, JobQueueOptions, JobRecord } from \"./types.js\";\n\n/**\n * The worker: claim, run, record, repeat.\n *\n * Everything difficult about running jobs concurrently is in `job-store.ts`,\n * where one `UPDATE … FOR UPDATE SKIP LOCKED` does the arbitration. What is\n * left here is a loop and the decisions around a handler that throws.\n */\n\nconst DEFAULT_CONCURRENCY = 5;\nconst DEFAULT_POLL_INTERVAL_MS = 2_000;\nconst DEFAULT_VISIBILITY_TIMEOUT_MS = 5 * 60_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\n\n/** 1s, 5s, 25s, 125s … capped at an hour. */\nexport function defaultBackoff(attempt: number): number {\n return Math.min(1_000 * Math.pow(5, Math.max(0, attempt - 1)), 60 * 60_000);\n}\n\n/**\n * How often the reaper runs, relative to the visibility timeout.\n *\n * A quarter of it, so a stranded job waits at most 1.25× the timeout rather\n * than 2× — and so the sweep is not itself a per-poll query against a table\n * whose interesting rows are, almost always, none.\n */\nconst REAP_INTERVAL_FACTOR = 0.25;\n\nexport interface JobQueue extends JobQueueClient {\n start(): void;\n stop(): Promise<void>;\n /** Run one poll's worth of work and return how many jobs ran. For tests and for `/jobs/drain`. */\n runOnce(): Promise<number>;\n /** Registered after construction — how `tasks` from config and internal producers meet. */\n register<P = unknown>(task: string, handler: JobHandler<P>): void;\n isRunning(): boolean;\n}\n\nexport function createJobQueue(store: JobStore, options: JobQueueOptions = {}): JobQueue {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;\n const defaultMaxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const backoff = options.backoff ?? defaultBackoff;\n\n const handlers = new Map<string, JobHandler<never>>();\n for (const [task, handler] of Object.entries(options.tasks ?? {})) {\n handlers.set(task, handler);\n }\n\n // Identifies this process in `locked_by`. Purely diagnostic — the claim is\n // enforced by the row lock, not by this — but \"which pod had it when it\n // stopped\" is the first question anyone asks of a stuck job.\n const workerId = `${process.pid}-${randomUUID().slice(0, 8)}`;\n\n let timer: NodeJS.Timeout | null = null;\n let running = false;\n let draining = false;\n /** Resolves when the in-flight poll finishes, so `stop()` can wait for it. */\n let inFlight: Promise<unknown> = Promise.resolve();\n let lastReapAt = 0;\n\n async function runJob(job: JobRecord): Promise<void> {\n const handler = handlers.get(job.task);\n\n if (!handler) {\n // Not a failure. A rolling deploy runs old and new code at once, and\n // an instance that has not been updated yet must not burn the\n // attempts of a job belonging to one that has. Give the row back\n // and let a peer — or this process after its next deploy — take it.\n //\n // The attempt increment from the claim is deliberately not undone:\n // a task nobody in the fleet implements would otherwise cycle\n // forever, and this way it dead-letters after `maxAttempts` with an\n // error naming the task.\n logger.warn(`[jobs] No handler registered for task \"${job.task}\" — returning the job to the queue`);\n await store.fail(\n job.id,\n `No handler registered for task \"${job.task}\"`,\n job.attempts < job.maxAttempts ? new Date(Date.now() + backoff(job.attempts)) : null\n );\n return;\n }\n\n try {\n await handler({\n id: job.id,\n task: job.task,\n payload: job.payload as never,\n attempt: job.attempts,\n maxAttempts: job.maxAttempts\n } as never);\n await store.complete(job.id);\n } catch (error) {\n const message = error instanceof Error ? (error.stack ?? error.message) : String(error);\n const willRetry = job.attempts < job.maxAttempts;\n\n // Truncated, because `last_error` holds a stack and a queue that\n // accumulates megabytes of them is its own outage.\n await store.fail(job.id, message.slice(0, 4_000), willRetry ? new Date(Date.now() + backoff(job.attempts)) : null);\n\n if (willRetry) {\n logger.warn(`[jobs] \"${job.task}\" failed on attempt ${job.attempts}/${job.maxAttempts}; retrying`, { jobId: job.id });\n } else {\n // The last attempt is an error, not a warning: nothing else will\n // touch this job, and if nobody looks at the table it is simply\n // lost work.\n logger.error(`[jobs] \"${job.task}\" failed permanently after ${job.attempts} attempts`, { jobId: job.id, error: message });\n }\n }\n }\n\n async function poll(): Promise<number> {\n // The reaper, on its own cadence.\n const now = Date.now();\n if (now - lastReapAt > visibilityTimeoutMs * REAP_INTERVAL_FACTOR) {\n lastReapAt = now;\n try {\n await store.reapExpired(visibilityTimeoutMs);\n } catch (error) {\n logger.error(\"[jobs] Failed to reclaim expired jobs\", { error });\n }\n }\n\n const jobs = await store.claim(concurrency, workerId);\n if (jobs.length === 0) return 0;\n\n // Settled, not `all`: `runJob` handles its own errors, but a store\n // write failing inside it must not abandon this batch's siblings.\n await Promise.allSettled(jobs.map(runJob));\n return jobs.length;\n }\n\n function schedule(delayMs: number): void {\n if (!running) return;\n timer = setTimeout(() => {\n void tick();\n }, delayMs);\n // Never hold the process open. A queue with nothing to do should not be\n // the reason `rebase dev` will not exit.\n timer.unref?.();\n }\n\n async function tick(): Promise<void> {\n if (!running) return;\n const work = (async () => {\n try {\n return await poll();\n } catch (error) {\n logger.error(\"[jobs] Poll failed\", { error });\n return 0;\n }\n })();\n inFlight = work;\n const count = await work;\n\n // A full batch means there is probably more waiting, so go straight\n // back rather than sleeping through a backlog.\n schedule(count >= concurrency ? 0 : pollIntervalMs);\n }\n\n return {\n start(): void {\n if (running) return;\n running = true;\n logger.info(`[jobs] Worker started (concurrency ${concurrency}, poll ${pollIntervalMs}ms)`);\n schedule(0);\n },\n\n async stop(): Promise<void> {\n running = false;\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (draining) return;\n draining = true;\n // Jobs in flight keep their claim until they finish or the\n // visibility timeout expires, so waiting here is what turns a\n // graceful shutdown into \"no job runs twice\".\n await inFlight.catch(() => undefined);\n draining = false;\n },\n\n runOnce(): Promise<number> {\n return poll();\n },\n\n register<P = unknown>(task: string, handler: JobHandler<P>): void {\n if (handlers.has(task)) {\n logger.warn(`[jobs] Task \"${task}\" was already registered; the later handler wins`);\n }\n handlers.set(task, handler as JobHandler<never>);\n },\n\n isRunning(): boolean {\n return running;\n },\n\n async enqueue<P = unknown>(task: string, payload?: P, enqueueOptions: EnqueueOptions = {}): Promise<string | null> {\n return store.insert({\n task,\n payload: payload ?? null,\n runAt: new Date(Date.now() + (enqueueOptions.delayMs ?? 0)),\n maxAttempts: enqueueOptions.maxAttempts ?? defaultMaxAttempts,\n idempotencyKey: enqueueOptions.idempotencyKey\n });\n }\n };\n}\n","export { createJobStore } from \"./job-store\";\nexport type { JobStore } from \"./job-store\";\nexport { createJobQueue, defaultBackoff } from \"./job-queue\";\nexport type { JobQueue } from \"./job-queue\";\nexport type {\n EnqueueOptions,\n JobContext,\n JobHandler,\n JobQueueClient,\n JobQueueOptions,\n JobRecord,\n JobStatus\n} from \"./types\";\n"],"mappings":"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"}