@rebasepro/server 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/api/rest/query-parser.d.ts +37 -1
  2. package/dist/api/rest/write-validation.d.ts +26 -0
  3. package/dist/auth/index.d.ts +3 -1
  4. package/dist/auth/interfaces.d.ts +14 -1
  5. package/dist/auth/jwks-routes.d.ts +17 -0
  6. package/dist/auth/jwt-keys.d.ts +108 -0
  7. package/dist/auth/jwt.d.ts +62 -2
  8. package/dist/{auth-CYoPVf-E.js → auth-BobZVd0j.js} +142 -167
  9. package/dist/auth-BobZVd0j.js.map +1 -0
  10. package/dist/boot/boot.d.ts +36 -50
  11. package/dist/boot/ddl-bootstrap.d.ts +15 -0
  12. package/dist/boot/env.d.ts +20 -0
  13. package/dist/boot/provision.d.ts +182 -0
  14. package/dist/boot/role.d.ts +88 -0
  15. package/dist/{cron-store-Dvr4Y1sZ.js → cron-store-CB1x-Ken.js} +3 -3
  16. package/dist/{cron-store-Dvr4Y1sZ.js.map → cron-store-CB1x-Ken.js.map} +1 -1
  17. package/dist/{ddl-bootstrap-BhXbTnBl.js → ddl-bootstrap-Cywoj8Ta.js} +40 -2
  18. package/dist/ddl-bootstrap-Cywoj8Ta.js.map +1 -0
  19. package/dist/env.d.ts +2 -0
  20. package/dist/functions/proxy.d.ts +41 -0
  21. package/dist/functions/selection.d.ts +45 -0
  22. package/dist/index.d.ts +8 -3
  23. package/dist/index.es.js +1113 -661
  24. package/dist/index.es.js.map +1 -1
  25. package/dist/init/shutdown.d.ts +4 -0
  26. package/dist/init/surfaces.d.ts +79 -0
  27. package/dist/init.d.ts +121 -1
  28. package/dist/jobs/index.d.ts +5 -0
  29. package/dist/jobs/job-queue.d.ts +14 -0
  30. package/dist/jobs/job-store.d.ts +22 -0
  31. package/dist/jobs/types.d.ts +125 -0
  32. package/dist/jobs-DR4SjGrD.js +326 -0
  33. package/dist/jobs-DR4SjGrD.js.map +1 -0
  34. package/dist/{jwt-_IFqfTOg.js → jwt-VJyXTdQQ.js} +447 -11
  35. package/dist/jwt-VJyXTdQQ.js.map +1 -0
  36. package/dist/{openapi-generator-DPKtUC9X.js → openapi-generator-DQeQ_q2f.js} +68 -3
  37. package/dist/openapi-generator-DQeQ_q2f.js.map +1 -0
  38. package/dist/proxy-Bj5DVllb.js +139 -0
  39. package/dist/proxy-Bj5DVllb.js.map +1 -0
  40. package/dist/{request-timeout-RivJsME0.js → request-timeout-BuFoEKwT.js} +6 -3
  41. package/dist/request-timeout-BuFoEKwT.js.map +1 -0
  42. package/dist/selection-_z6TM1DB.js +64 -0
  43. package/dist/selection-_z6TM1DB.js.map +1 -0
  44. package/dist/services/webhook-service.d.ts +43 -5
  45. package/dist/{src-C7rkDGxA.js → src-8XDWyDfR.js} +84 -13
  46. package/dist/src-8XDWyDfR.js.map +1 -0
  47. package/dist/src-Cz9nMgUR.js.map +1 -1
  48. package/dist/storage/keys.d.ts +17 -0
  49. package/dist/storage/routes.d.ts +1 -1
  50. package/dist/storage/storage-registry.d.ts +46 -4
  51. package/dist/storage/tus-handler.d.ts +1 -1
  52. package/package.json +5 -5
  53. package/dist/auth-CYoPVf-E.js.map +0 -1
  54. package/dist/ddl-bootstrap-BhXbTnBl.js.map +0 -1
  55. package/dist/jwt-_IFqfTOg.js.map +0 -1
  56. package/dist/openapi-generator-DPKtUC9X.js.map +0 -1
  57. package/dist/request-timeout-RivJsME0.js.map +0 -1
  58. package/dist/src-C7rkDGxA.js.map +0 -1
@@ -1,6 +1,5 @@
1
1
  import { type Server } from "http";
2
2
  import { Hono } from "hono";
3
- import { type CollectionConfig } from "@rebasepro/types";
4
3
  import { type RebaseBackendInstance } from "../init";
5
4
  import type { HonoEnv } from "../api/types";
6
5
  import { type RebaseBootEnv } from "./env";
@@ -18,6 +17,18 @@ export interface BootedRuntime {
18
17
  dataSources: InitializedDataSource[];
19
18
  shutdown: () => Promise<void>;
20
19
  }
20
+ /**
21
+ * Whether this process is the one that provisions the database schema.
22
+ *
23
+ * Separate from `REBASE_MIGRATE_ON_BOOT` on purpose. That variable answers
24
+ * "does this deployment create its own schema at boot"; this answers "is this
25
+ * *process* the one that does it", which only becomes a question once a
26
+ * deployment boots the same bundle more than once.
27
+ */
28
+ export interface SchemaProvisioningOptions {
29
+ /** Default `true`. `false` leaves every DDL statement to another process. */
30
+ provision?: boolean;
31
+ }
21
32
  export interface BootOptions {
22
33
  /** Bundle directory. Defaults to `REBASE_BUNDLE` or `./dist-bundle`. */
23
34
  bundleDir?: string;
@@ -33,6 +44,20 @@ export interface BootOptions {
33
44
  listen?: boolean;
34
45
  /** Install SIGTERM/SIGINT handlers. Off for tests. */
35
46
  handleSignals?: boolean;
47
+ /**
48
+ * Whether this process provisions the collection schema and its RLS
49
+ * policies at boot. Default `true` — the behaviour every deployment has.
50
+ *
51
+ * Set `false` on a process that is one of several booting the same bundle
52
+ * against the same database. `CREATE … IF NOT EXISTS` reads the catalog and
53
+ * then writes to it non-atomically, so peers starting together do collide;
54
+ * exactly one owner is cheaper and more legible than N racing and retrying.
55
+ *
56
+ * Independent of `REBASE_MIGRATE_ON_BOOT`, which answers a different
57
+ * question — whether *this deployment* provisions its schema at boot at all,
58
+ * rather than which of its processes does.
59
+ */
60
+ provisionSchema?: boolean;
36
61
  }
37
62
  /**
38
63
  * Boot a Rebase runtime from a built bundle.
@@ -77,55 +102,16 @@ export declare function runFromBundle(options?: BootOptions): Promise<BootedRunt
77
102
  */
78
103
  export declare function warnOnDriverSkew(dataSources: InitializedDataSource[], runtimeVersion: string | undefined): void;
79
104
  /**
80
- * The collections a data source's engine is the store for.
105
+ * Warn about a bundle whose shape makes provisioning impossible.
81
106
  *
82
- * A bundle's collections directory holds *every* collection the project
83
- * declares, whatever engine serves it that is the point of `dataSource`
84
- * routing. What it is not is a list of tables to create: handing the whole
85
- * directory to the primary source's bootstrapper made a Firestore collection
86
- * declared alongside the Postgres ones arrive as an empty Postgres table, with
87
- * RLS policies, while the app went on reading its documents from Firestore.
88
- *
89
- * Excluding is deliberately conservative. A collection that names neither an
90
- * `engine` nor a `dataSource` belongs to whichever source is primary — the
91
- * "postgres" that `resolveDataSource` falls back to there is a default, not a
92
- * declaration, and must not exclude anything on its own. Only a collection that
93
- * explicitly routes to a *different* engine is dropped, so a project running two
94
- * sources on the same engine is unaffected.
95
- */
96
- export declare function collectionsStoredBy(collections: CollectionConfig[], primary: InitializedDataSource, dataSources: InitializedDataSource[]): CollectionConfig[];
97
- /**
98
- * Bring the database's collection tables up to date before serving.
99
- *
100
- * Delegates to whichever driver bootstrapped the default data source; a driver
101
- * without `ensureCollectionSchema` (a schemaless one, or an older build) skips
102
- * rather than failing, which is why this cannot break an existing deployment.
103
- *
104
- * Every path out of here says why, at info or louder. Guaranteeing the tables
105
- * exist is this function's entire job, so "it declined, and said nothing" is the
106
- * one outcome it must never produce: a deployment that skips comes up answering
107
- * sign-in and 500ing every `/api/data/*` route, and the operator's only evidence
108
- * is what these lines print. Silence here has already sent one investigation
109
- * chasing a stale runtime image that was not stale.
110
- *
111
- * Failure is fatal on purpose. Booting anyway would produce exactly the state
112
- * this exists to prevent — an app that answers sign-in and 500s every data
113
- * request — and a crash-looping pod with the DDL error in its logs is a far
114
- * better signal than a running one that silently cannot serve.
115
- */
116
- export declare function ensureCollectionSchema(bundle: LoadedBundle, dataSources: InitializedDataSource[], env: RebaseBootEnv): Promise<void>;
117
- /**
118
- * Apply the project's RLS policies before serving — the companion to
119
- * {@link ensureCollectionSchema}, which creates the tables this makes servable.
107
+ * `initializeRebaseBackend` provisions the collection schema for every boot
108
+ * path, and it decides from what actually resolved: a project with no
109
+ * collections reads them from the database and creates nothing. That rule is
110
+ * right, but from inside the runtime it cannot tell "this project declares no
111
+ * collections" from "this build lost them" and the second is a broken build
112
+ * that silently serves an empty API.
120
113
  *
121
- * Runs after `initializeRebaseBackend`, not alongside table creation: the
122
- * generated policies call the `auth.*` helper functions, and `CREATE POLICY`
123
- * validates those exist, so this cannot run before auth is initialized. The
124
- * gate conditions mirror `ensureCollectionSchema` (mode, bundle shape, driver
125
- * support) — and because that function already ran and explained any skip on
126
- * this same boot, the benign gates here return quietly rather than logging the
127
- * same reason twice. The one thing it does say out loud is a driver that
128
- * created tables but cannot apply policies: that is the difference between a
129
- * served collection and a 401, and it must not pass in silence.
114
+ * Only the bundle knows the difference, so this is the one piece of the old
115
+ * bundle-side provisioning worth keeping here. It warns; it never provisions.
130
116
  */
131
- export declare function ensureCollectionPolicies(bundle: LoadedBundle, dataSources: InitializedDataSource[], env: RebaseBootEnv): Promise<void>;
117
+ export declare function warnOnUnusableBundleShape(bundle: LoadedBundle): void;
@@ -46,6 +46,21 @@ export declare function hasInCauseChain(err: unknown, visit: (e: Record<string,
46
46
  * times and then reported as a race that never was.
47
47
  */
48
48
  export declare function isConcurrentDdlRace(err: unknown): boolean;
49
+ /**
50
+ * Did this statement fail *because a peer already created the same object*?
51
+ *
52
+ * The narrow companion to {@link isConcurrentDdlRace}, for the one caller that
53
+ * needs to tell "someone beat me to it" from "this genuinely failed": a loop
54
+ * applying a schema plan, where treating every `23505` as a harmless race would
55
+ * silently swallow the one that matters — a unique constraint that cannot be
56
+ * added because the customer's existing rows violate it.
57
+ *
58
+ * `23505` is therefore only accepted when it names a `pg_catalog` index. That is
59
+ * what a lost `CREATE TYPE`/`CREATE TABLE` race raises (`pg_type_typname_nsp_index`
60
+ * is the one seen in practice); a unique violation on user data names the user's
61
+ * own constraint and is left to the caller.
62
+ */
63
+ export declare function isDuplicateObjectRace(err: unknown): boolean;
49
64
  export interface DdlBootstrapper {
50
65
  /**
51
66
  * Run one idempotent statement — `CREATE … IF NOT EXISTS`, `ALTER TABLE …
@@ -95,6 +95,26 @@ declare const bootEnvExtension: z.ZodObject<{
95
95
  false: "false";
96
96
  }>>, z.ZodTransform<boolean, "" | "true" | "false">>;
97
97
  CORS_ORIGINS: z.ZodOptional<z.ZodString>;
98
+ REBASE_ROLE: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
99
+ "": "";
100
+ functions: "functions";
101
+ all: "all";
102
+ api: "api";
103
+ worker: "worker";
104
+ }>>, z.ZodTransform<"functions" | "all" | "api" | "worker", "" | "functions" | "all" | "api" | "worker">>;
105
+ REBASE_CRON_SCHEDULER: z.ZodPipe<z.ZodOptional<z.ZodEnum<{
106
+ "": "";
107
+ true: "true";
108
+ false: "false";
109
+ }>>, z.ZodTransform<boolean | undefined, "" | "true" | "false" | undefined>>;
110
+ REBASE_JOB_WORKERS: z.ZodPipe<z.ZodOptional<z.ZodEnum<{
111
+ "": "";
112
+ true: "true";
113
+ false: "false";
114
+ }>>, z.ZodTransform<boolean | undefined, "" | "true" | "false" | undefined>>;
115
+ REBASE_FUNCTIONS_ONLY: z.ZodOptional<z.ZodString>;
116
+ REBASE_FUNCTIONS_EXCLUDE: z.ZodOptional<z.ZodString>;
117
+ REBASE_FUNCTIONS_UPSTREAM: z.ZodOptional<z.ZodString>;
98
118
  }, z.core.$strip>;
99
119
  export type RebaseBootEnv = RebaseEnv & z.infer<typeof bootEnvExtension>;
100
120
  /**
@@ -0,0 +1,182 @@
1
+ import type { BackendBootstrapper, CollectionConfig, InitializedDriver } from "@rebasepro/types";
2
+ /**
3
+ * Boot-time provisioning of collection tables and their RLS policies.
4
+ *
5
+ * This lives outside both boot paths on purpose. It used to live inside
6
+ * `bootFromBundle`, which meant it ran for managed tenants and for nobody else:
7
+ * an app that ships its own image (`runtimeMode: custom`) boots by calling
8
+ * `initializeRebaseBackend` directly and never passes through the bundle path,
9
+ * so its collection tables were never created. That app came up serving sign-in
10
+ * — auth bootstraps its own tables — and 500ing every `/api/data/*` route, with
11
+ * a green deploy and no failing check anywhere. It stayed that way for weeks.
12
+ *
13
+ * The fix is structural rather than a second copy of the logic in the other
14
+ * path: `initializeRebaseBackend` is the one function BOTH paths go through, so
15
+ * provisioning belongs there, and this module is what it calls.
16
+ */
17
+ /** How the process-wide provisioning attempt turned out. */
18
+ export type ProvisionOutcome =
19
+ /** The hook ran. `applied` counts the statements it executed. */
20
+ {
21
+ status: "applied";
22
+ applied: number;
23
+ }
24
+ /** A gate declined, with the reason already logged. */
25
+ | {
26
+ status: "skipped";
27
+ reason: string;
28
+ };
29
+ /**
30
+ * What ran, in this process, before the driver looked at the database.
31
+ *
32
+ * Handed to `initializeDriver` so a driver's drift check can say something true
33
+ * about why tables are missing. Without it the Postgres driver could only guess,
34
+ * and it guessed wrong in the one case that mattered: its warning told operators
35
+ * to "redeploy with REBASE_MIGRATE_ON_BOOT unset" and to suspect a stale driver,
36
+ * when the real answer was that no provisioning step existed in that boot path
37
+ * at all. Both suggestions were unactionable, and the second sent an
38
+ * investigation after a driver that was current.
39
+ */
40
+ export interface SchemaProvisioningReport {
41
+ /** Whether the table-creation hook was invoked at all this boot. */
42
+ attempted: boolean;
43
+ /** Why it was not, when it was not — phrased to be printed verbatim. */
44
+ reason?: string;
45
+ }
46
+ /** The subset of a bootstrapper this module needs, from either boot path. */
47
+ export interface ProvisionTarget {
48
+ /**
49
+ * The driver's package name, when the caller resolved one.
50
+ *
51
+ * Undefined for an adapter an application constructed itself: there is no
52
+ * package to name, and inventing one misdirects — the reader would go
53
+ * looking for a dependency to bump when the object in question was built in
54
+ * their own source file.
55
+ */
56
+ driverPackage?: string;
57
+ engine: string;
58
+ driverVersion?: string;
59
+ runtimeVersion?: string;
60
+ bootstrapper: Pick<BackendBootstrapper, "ensureCollectionSchema" | "ensureCollectionPolicies">;
61
+ /**
62
+ * The handle the hooks read (`internals.db`), or `undefined` to let the
63
+ * adapter fall back to the connection it was constructed with.
64
+ *
65
+ * The bundle path has one because the coordinator opened the connection
66
+ * itself; an app calling `initializeRebaseBackend` directly passed its
67
+ * connection into the adapter and the framework never sees it.
68
+ */
69
+ driverResult?: InitializedDriver;
70
+ }
71
+ /** `REBASE_MIGRATE_ON_BOOT=none` opts a deployment out of both phases. */
72
+ /**
73
+ * How one call to a provisioning function is run.
74
+ *
75
+ * `provision` is a different question from `REBASE_MIGRATE_ON_BOOT`, and both
76
+ * are checked. That variable says whether this *deployment* provisions its own
77
+ * schema at boot; `provision` says whether this *process* is the one that does
78
+ * it — which only becomes a question once a deployment boots the same bundle
79
+ * more than once (see `REBASE_ROLE`).
80
+ */
81
+ export interface ProvisionRunOptions {
82
+ introspecting?: boolean;
83
+ env?: {
84
+ REBASE_MIGRATE_ON_BOOT?: string;
85
+ };
86
+ /** Default `true`. `false` leaves every DDL statement to another process. */
87
+ provision?: boolean;
88
+ }
89
+ export declare function provisioningDisabled(env?: {
90
+ REBASE_MIGRATE_ON_BOOT?: string;
91
+ }): boolean;
92
+ /**
93
+ * Create any collection tables the database is missing, before serving.
94
+ *
95
+ * Additive only: the driver may create missing tables, columns and enum types,
96
+ * and may never drop or rewrite. Destructive changes stay a deliberate
97
+ * migration, because this runs unattended with nobody reading a diff.
98
+ *
99
+ * Every path out of here says why, at info or louder. Guaranteeing the tables
100
+ * exist is this function's entire job, so "it declined, and said nothing" is the
101
+ * one outcome it must never produce: a deployment that skips comes up answering
102
+ * sign-in and 500ing every `/api/data/*` route, and the operator's only evidence
103
+ * is what these lines print.
104
+ *
105
+ * Note that silence is still possible one level up — if nothing CALLS this, no
106
+ * line is printed and the absence is the only signal. That is precisely the bug
107
+ * this module was extracted to make structurally impossible, and it is why the
108
+ * outcome is reported back to the driver rather than only logged.
109
+ *
110
+ * Failure is fatal on purpose. Booting anyway would produce exactly the state
111
+ * this exists to prevent, and a crash-looping pod with the DDL error in its logs
112
+ * is a far better signal than a running one that silently cannot serve.
113
+ */
114
+ export declare function provisionCollectionTables(collections: CollectionConfig[], target: ProvisionTarget, options?: ProvisionRunOptions): Promise<ProvisionOutcome>;
115
+ /**
116
+ * Apply the collections' RLS policies — the companion to
117
+ * {@link provisionCollectionTables}, which creates the tables this makes
118
+ * servable.
119
+ *
120
+ * Runs after auth is initialized, not alongside table creation: the generated
121
+ * policies call the `auth.*` helper functions and `CREATE POLICY` validates
122
+ * those exist. Tables without policies are not servable either — authenticated
123
+ * requests run as a restricted role, so a read with no policy returns nothing.
124
+ *
125
+ * The benign gates return quietly here rather than logging the same reason
126
+ * twice: table provisioning already ran on this same boot and explained itself.
127
+ * The one thing this does say out loud is a driver that created tables but
128
+ * cannot apply policies — that is the difference between a served collection and
129
+ * a 401, and it must not pass in silence.
130
+ */
131
+ export declare function provisionCollectionPolicies(collections: CollectionConfig[], target: ProvisionTarget, options?: ProvisionRunOptions): Promise<ProvisionOutcome>;
132
+ /**
133
+ * The collections a data source's engine is the store for.
134
+ *
135
+ * A project's collections directory holds *every* collection it declares,
136
+ * whatever engine serves it — that is the point of `dataSource` routing. What it
137
+ * is not is a list of tables to create: handing the whole directory to the
138
+ * primary source's bootstrapper made a Firestore collection declared alongside
139
+ * the Postgres ones arrive as an empty Postgres table, with RLS policies, while
140
+ * the app went on reading its documents from Firestore.
141
+ *
142
+ * Excluding is deliberately conservative. A collection that names neither an
143
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
144
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
145
+ * declaration, and must not exclude anything on its own. Only a collection that
146
+ * explicitly routes to a *different* engine is dropped, so a project running two
147
+ * sources on the same engine is unaffected.
148
+ *
149
+ * Takes the shapes it actually reads rather than an `InitializedDataSource`, so
150
+ * the path that has data sources and the path that has only an adapter can share
151
+ * one implementation instead of growing a second one that drifts.
152
+ */
153
+ export declare function collectionsStoredBy(collections: CollectionConfig[], primary: {
154
+ engine: string;
155
+ }, dataSources: Array<{
156
+ key: string;
157
+ engine: string;
158
+ }>): CollectionConfig[];
159
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
160
+ export declare function logForeignCollections(all: CollectionConfig[], stored: CollectionConfig[], primary: {
161
+ engine: string;
162
+ }): void;
163
+ /**
164
+ * Which bootstrapper provisions, and what to call it in a message.
165
+ *
166
+ * The default source is the one that stores this project's collections, so it
167
+ * is the one asked to create them; `dataSources[0]` is the bundle path's
168
+ * equivalent of the same choice.
169
+ *
170
+ * `driverResult` is deliberately left undefined here. The hooks run BEFORE
171
+ * `initializeDriver`, so there is no driver result to pass, and unlike the
172
+ * bundle path — where the coordinator opens the connection itself — an app that
173
+ * built its own adapter never handed the framework a connection handle. The
174
+ * adapter has one: it was constructed with it. So the contract is that an
175
+ * adapter falls back to its own connection when no result is supplied, which is
176
+ * the only shape that works for both paths.
177
+ */
178
+ export declare function provisionTargetFor(bootstrappers: BackendBootstrapper[], adapter?: {
179
+ type: string;
180
+ }, driverResult?: InitializedDriver): ProvisionTarget;
181
+ /** Load collections for provisioning when only a directory is known. */
182
+ export declare function collectionsForProvisioning(declared: CollectionConfig[], collectionsDir: string | undefined): Promise<CollectionConfig[]>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Turning `REBASE_ROLE` into a process shape.
3
+ *
4
+ * A deployment that boots one process is the default and stays the default.
5
+ * A deployment that boots the same image and the same bundle several times over
6
+ * says so here, once, per process — and everything downstream (which routes
7
+ * mount, which timers fire, who provisions the schema) is derived rather than
8
+ * configured separately. Four named shapes rather than a free-form list of
9
+ * surfaces, because the set of processes anyone actually runs is small and
10
+ * sixteen combinations is a space nobody tests.
11
+ *
12
+ * Every function in this module is pure. The decisions are the whole risk here:
13
+ * a role that quietly runs no scheduler, or two roles that both provision the
14
+ * schema, are failures nothing in a boot log makes obvious.
15
+ */
16
+ import type { RuntimeOwnershipOptions, RuntimeSurfaceOptions } from "../init/surfaces";
17
+ import type { FunctionSelection } from "../functions/selection";
18
+ /** The four shapes a runtime process can boot in. */
19
+ export type RebaseRuntimeRole = "all" | "api" | "functions" | "worker";
20
+ /** Everything a role decides, resolved. */
21
+ export interface ResolvedRole {
22
+ role: RebaseRuntimeRole;
23
+ surfaces: RuntimeSurfaceOptions;
24
+ ownership: RuntimeOwnershipOptions;
25
+ /** Whether this process runs the boot-time schema DDL. */
26
+ provisionSchema: boolean;
27
+ /** Which functions this process serves. Empty lists mean "all of them". */
28
+ functionsSelection: FunctionSelection;
29
+ /** Where `/api/functions/*` is forwarded, when this process forwards it. */
30
+ functionsUpstream?: string;
31
+ }
32
+ /** The environment this module reads. Narrowed so it can be called with a literal. */
33
+ export interface RoleEnv {
34
+ REBASE_ROLE?: RebaseRuntimeRole;
35
+ REBASE_CRON_SCHEDULER?: boolean;
36
+ REBASE_JOB_WORKERS?: boolean;
37
+ REBASE_MIGRATE_ON_BOOT?: "none" | "ensure" | "push" | "";
38
+ REBASE_FUNCTIONS_ONLY?: string;
39
+ REBASE_FUNCTIONS_EXCLUDE?: string;
40
+ REBASE_FUNCTIONS_UPSTREAM?: string;
41
+ }
42
+ /**
43
+ * A refusal to boot, phrased as the variable to change.
44
+ *
45
+ * Its own class so `bootFromBundle` can present it as a configuration problem
46
+ * rather than a crash. Every message names the variable and the fix, because the
47
+ * reader is looking at a container that will not start and has one line of log
48
+ * to work from.
49
+ */
50
+ export declare class RoleConfigurationError extends Error {
51
+ readonly hint?: string | undefined;
52
+ constructor(message: string, hint?: string | undefined);
53
+ }
54
+ /** Split a comma-separated list, dropping blanks. */
55
+ export declare function parseNameList(raw: string | undefined): string[];
56
+ /**
57
+ * Resolve the process shape, or refuse.
58
+ *
59
+ * @throws RoleConfigurationError for a combination that would boot into a state
60
+ * nobody meant — see the refusals inline. Each one is a refusal rather than a
61
+ * warning because its failure mode is silent: a second process racing schema
62
+ * DDL, or presence quietly not crossing instances, is not something an
63
+ * operator finds by reading logs.
64
+ */
65
+ export declare function resolveRole(env: RoleEnv): ResolvedRole;
66
+ /**
67
+ * ## Why there is no channel-bus refusal here
68
+ *
69
+ * Splitting `api` from `functions` was expected to need one: the in-memory
70
+ * channel bus is the default, and broadcast and presence do not cross processes
71
+ * on it. It turns out to be the wrong place to check, for two reasons.
72
+ *
73
+ * The first is that a role split does not create the problem. Only the roles
74
+ * that mount the API serve websockets at all — a `functions` process has no
75
+ * realtime clients to keep in sync, and a `worker` has no HTTP surface. What
76
+ * makes the bus matter is the *replica count* of the websocket-serving process,
77
+ * which is exactly as true for a single `all` deployment scaled to three as it
78
+ * is for a split one, and which no process can read from its own environment.
79
+ *
80
+ * The second is that the runtime already answers it better than a guess from
81
+ * configuration could: `RealtimeService.warnIfMemoryBusOnMultiplePods` fires on
82
+ * *evidence* — the first notification seen from another instance while the bus
83
+ * is still in-memory. That is a fact rather than an inference, and it catches
84
+ * the scaled-`all` case this would have missed.
85
+ *
86
+ * So: no refusal, and the operator guidance belongs in the deployment docs
87
+ * beside the replica count, not here.
88
+ */
@@ -2,9 +2,9 @@ import { createRequire as __createRequire } from "module";
2
2
  import "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
5
- import "./src-C7rkDGxA.js";
5
+ import "./src-8XDWyDfR.js";
6
6
  import "./src-Cz9nMgUR.js";
7
- import { i as isSQLAdmin, n as hasInCauseChain, r as revokeInternalTableSql, t as createDdlBootstrapper } from "./ddl-bootstrap-BhXbTnBl.js";
7
+ import { n as createDdlBootstrapper, o as revokeInternalTableSql, r as hasInCauseChain, s as isSQLAdmin } from "./ddl-bootstrap-Cywoj8Ta.js";
8
8
  import { t as logger } from "./logger-DfvF_8r-.js";
9
9
  //#region src/cron/cron-store.ts
10
10
  var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
@@ -180,4 +180,4 @@ function rowToLogEntry(row) {
180
180
  //#endregion
181
181
  export { cron_store_exports as n, createCronStore as t };
182
182
 
183
- //# sourceMappingURL=cron-store-Dvr4Y1sZ.js.map
183
+ //# sourceMappingURL=cron-store-CB1x-Ken.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cron-store-Dvr4Y1sZ.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-CB1x-Ken.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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"}
@@ -140,6 +140,44 @@ function isConcurrentDdlRace(err) {
140
140
  return hasInCauseChain(err, (e) => typeof e.code === "string" && CONCURRENT_DDL_SQLSTATES.has(e.code) || typeof e.message === "string" && /already exists/i.test(e.message));
141
141
  }
142
142
  /**
143
+ * SQLSTATEs that mean, unambiguously, *the object is already there*.
144
+ *
145
+ * A subset of {@link CONCURRENT_DDL_SQLSTATES} and a stricter question. The
146
+ * broad set answers "should this be retried"; this one answers "is it safe to
147
+ * carry on as though the statement had succeeded", which is a claim about the
148
+ * end state rather than about the attempt. Deadlock is not in it — a deadlocked
149
+ * statement did nothing and must be retried, not skipped.
150
+ */
151
+ var DUPLICATE_OBJECT_SQLSTATES = /* @__PURE__ */ new Set([
152
+ "42P06",
153
+ "42P07",
154
+ "42710"
155
+ ]);
156
+ /**
157
+ * Did this statement fail *because a peer already created the same object*?
158
+ *
159
+ * The narrow companion to {@link isConcurrentDdlRace}, for the one caller that
160
+ * needs to tell "someone beat me to it" from "this genuinely failed": a loop
161
+ * applying a schema plan, where treating every `23505` as a harmless race would
162
+ * silently swallow the one that matters — a unique constraint that cannot be
163
+ * added because the customer's existing rows violate it.
164
+ *
165
+ * `23505` is therefore only accepted when it names a `pg_catalog` index. That is
166
+ * what a lost `CREATE TYPE`/`CREATE TABLE` race raises (`pg_type_typname_nsp_index`
167
+ * is the one seen in practice); a unique violation on user data names the user's
168
+ * own constraint and is left to the caller.
169
+ */
170
+ function isDuplicateObjectRace(err) {
171
+ return hasInCauseChain(err, (e) => {
172
+ if (typeof e.code !== "string") return false;
173
+ if (DUPLICATE_OBJECT_SQLSTATES.has(e.code)) return true;
174
+ if (e.code !== "23505") return false;
175
+ const constraint = typeof e.constraint === "string" ? e.constraint : "";
176
+ const detail = typeof e.detail === "string" ? e.detail : "";
177
+ return constraint.startsWith("pg_") || /\bpg_[a-z_]+_index\b/.test(detail);
178
+ });
179
+ }
180
+ /**
143
181
  * @param exec the driver's SQL escape hatch
144
182
  * @param scope log prefix identifying the caller, e.g. `"cron-store"`
145
183
  */
@@ -178,6 +216,6 @@ function createDdlBootstrapper(exec, scope) {
178
216
  };
179
217
  }
180
218
  //#endregion
181
- export { isSQLAdmin as i, hasInCauseChain as n, revokeInternalTableSql as r, createDdlBootstrapper as t };
219
+ export { isDuplicateObjectRace as a, isConcurrentDdlRace as i, createDdlBootstrapper as n, revokeInternalTableSql as o, hasInCauseChain as r, isSQLAdmin as s, CONCURRENT_DDL_SQLSTATES as t };
182
220
 
183
- //# sourceMappingURL=ddl-bootstrap-BhXbTnBl.js.map
221
+ //# sourceMappingURL=ddl-bootstrap-Cywoj8Ta.js.map