@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.
- package/dist/api/rest/query-parser.d.ts +37 -1
- package/dist/api/rest/write-validation.d.ts +26 -0
- package/dist/auth/index.d.ts +3 -1
- package/dist/auth/interfaces.d.ts +14 -1
- package/dist/auth/jwks-routes.d.ts +17 -0
- package/dist/auth/jwt-keys.d.ts +108 -0
- package/dist/auth/jwt.d.ts +62 -2
- package/dist/{auth-CYoPVf-E.js → auth-BobZVd0j.js} +142 -167
- package/dist/auth-BobZVd0j.js.map +1 -0
- package/dist/boot/boot.d.ts +36 -50
- package/dist/boot/ddl-bootstrap.d.ts +15 -0
- package/dist/boot/env.d.ts +20 -0
- package/dist/boot/provision.d.ts +182 -0
- package/dist/boot/role.d.ts +88 -0
- package/dist/{cron-store-Dvr4Y1sZ.js → cron-store-CB1x-Ken.js} +3 -3
- package/dist/{cron-store-Dvr4Y1sZ.js.map → cron-store-CB1x-Ken.js.map} +1 -1
- package/dist/{ddl-bootstrap-BhXbTnBl.js → ddl-bootstrap-Cywoj8Ta.js} +40 -2
- package/dist/ddl-bootstrap-Cywoj8Ta.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/functions/proxy.d.ts +41 -0
- package/dist/functions/selection.d.ts +45 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.es.js +1113 -661
- package/dist/index.es.js.map +1 -1
- package/dist/init/shutdown.d.ts +4 -0
- package/dist/init/surfaces.d.ts +79 -0
- package/dist/init.d.ts +121 -1
- package/dist/jobs/index.d.ts +5 -0
- package/dist/jobs/job-queue.d.ts +14 -0
- package/dist/jobs/job-store.d.ts +22 -0
- package/dist/jobs/types.d.ts +125 -0
- package/dist/jobs-DR4SjGrD.js +326 -0
- package/dist/jobs-DR4SjGrD.js.map +1 -0
- package/dist/{jwt-_IFqfTOg.js → jwt-VJyXTdQQ.js} +447 -11
- package/dist/jwt-VJyXTdQQ.js.map +1 -0
- package/dist/{openapi-generator-DPKtUC9X.js → openapi-generator-DQeQ_q2f.js} +68 -3
- package/dist/openapi-generator-DQeQ_q2f.js.map +1 -0
- package/dist/proxy-Bj5DVllb.js +139 -0
- package/dist/proxy-Bj5DVllb.js.map +1 -0
- package/dist/{request-timeout-RivJsME0.js → request-timeout-BuFoEKwT.js} +6 -3
- package/dist/request-timeout-BuFoEKwT.js.map +1 -0
- package/dist/selection-_z6TM1DB.js +64 -0
- package/dist/selection-_z6TM1DB.js.map +1 -0
- package/dist/services/webhook-service.d.ts +43 -5
- package/dist/{src-C7rkDGxA.js → src-8XDWyDfR.js} +84 -13
- package/dist/src-8XDWyDfR.js.map +1 -0
- package/dist/src-Cz9nMgUR.js.map +1 -1
- package/dist/storage/keys.d.ts +17 -0
- package/dist/storage/routes.d.ts +1 -1
- package/dist/storage/storage-registry.d.ts +46 -4
- package/dist/storage/tus-handler.d.ts +1 -1
- package/package.json +5 -5
- package/dist/auth-CYoPVf-E.js.map +0 -1
- package/dist/ddl-bootstrap-BhXbTnBl.js.map +0 -1
- package/dist/jwt-_IFqfTOg.js.map +0 -1
- package/dist/openapi-generator-DPKtUC9X.js.map +0 -1
- package/dist/request-timeout-RivJsME0.js.map +0 -1
- package/dist/src-C7rkDGxA.js.map +0 -1
package/dist/boot/boot.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
105
|
+
* Warn about a bundle whose shape makes provisioning impossible.
|
|
81
106
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
*
|
|
122
|
-
*
|
|
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
|
|
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 …
|
package/dist/boot/env.d.ts
CHANGED
|
@@ -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-
|
|
5
|
+
import "./src-8XDWyDfR.js";
|
|
6
6
|
import "./src-Cz9nMgUR.js";
|
|
7
|
-
import {
|
|
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-
|
|
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 {
|
|
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-
|
|
221
|
+
//# sourceMappingURL=ddl-bootstrap-Cywoj8Ta.js.map
|