@rebasepro/server 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/auth/api-keys/api-key-middleware.d.ts +1 -1
  2. package/dist/auth/jwt.d.ts +1 -1
  3. package/dist/auth/rls-scope.d.ts +1 -1
  4. package/dist/{auth-DIKS1rsI.js → auth-5Et5mnUA.js} +141 -32
  5. package/dist/auth-5Et5mnUA.js.map +1 -0
  6. package/dist/boot/boot.d.ts +17 -1
  7. package/dist/boot/env.d.ts +1 -0
  8. package/dist/boot/provision.d.ts +1 -1
  9. package/dist/boot/schema-stamp.d.ts +173 -0
  10. package/dist/boot/version-skew.d.ts +27 -3
  11. package/dist/{contract-routes-DDNj4_J1.js → contract-routes-DZ-LBpSL.js} +2 -2
  12. package/dist/contract-routes-DZ-LBpSL.js.map +1 -0
  13. package/dist/cron/define-cron.d.ts +1 -1
  14. package/dist/{cron-store-BBGvOA-9.js → cron-store-DfH_4Cd9.js} +2 -2
  15. package/dist/{cron-store-BBGvOA-9.js.map → cron-store-DfH_4Cd9.js.map} +1 -1
  16. package/dist/ddl-bootstrap-Cywoj8Ta.js.map +1 -1
  17. package/dist/email/index.d.ts +1 -0
  18. package/dist/email/templates.d.ts +21 -5
  19. package/dist/email/types.d.ts +14 -0
  20. package/dist/functions/define-function.d.ts +1 -1
  21. package/dist/index.es.js +386 -35
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/init/surfaces.d.ts +8 -1
  24. package/dist/{jobs-BOEOIGAm.js → jobs-CyOKXXlu.js} +2 -2
  25. package/dist/{jobs-BOEOIGAm.js.map → jobs-CyOKXXlu.js.map} +1 -1
  26. package/dist/jwt-DxH9fLPt.js.map +1 -1
  27. package/dist/{openapi-generator-DLiiGD9X.js → openapi-generator-BCKJRUS4.js} +2 -2
  28. package/dist/{openapi-generator-DLiiGD9X.js.map → openapi-generator-BCKJRUS4.js.map} +1 -1
  29. package/dist/singleton.d.ts +1 -1
  30. package/dist/{src-B-E7RjdN.js → src-BPfYOeN4.js} +3 -3
  31. package/dist/src-BPfYOeN4.js.map +1 -0
  32. package/dist/src-CrCxd8km.js.map +1 -1
  33. package/package.json +5 -5
  34. package/dist/auth-DIKS1rsI.js.map +0 -1
  35. package/dist/contract-routes-DDNj4_J1.js.map +0 -1
  36. package/dist/src-B-E7RjdN.js.map +0 -1
@@ -109,11 +109,27 @@ export declare function runFromBundle(options?: BootOptions): Promise<BootedRunt
109
109
  * driver. Nothing detected that before this: the halves simply disagreed in
110
110
  * silence until some capability turned out to be missing three layers down.
111
111
  *
112
- * A warning rather than a refusal. Old drivers are usually fine — the pairing is
112
+ * A warning rather than a refusal. Either pairing can be fine — both are
113
113
  * supported, and a boot that dies on version arithmetic would be a far worse
114
114
  * failure than the drift it is guarding against. This only has to make the skew
115
115
  * *visible*, so that the next person reading the log starts from the right
116
116
  * question.
117
+ *
118
+ * ## Both directions, because only one of them was ever reported
119
+ *
120
+ * A driver *behind* the runtime was the original case: the platform ships a fix,
121
+ * every tenant rolls onto the new image, and none of them run the fixed driver.
122
+ *
123
+ * A driver *ahead* by a minor is the mirror image and was silent until it cost
124
+ * another day. It is not the deliberate pin this check used to assume — that is
125
+ * a patch bump, and still says nothing. It is what a floating runtime range
126
+ * produces when the image lags: a bundle built against a framework the image
127
+ * predates, so a feature split across `@rebasepro/server` and the driver has
128
+ * only its driver half present. Nothing errors. The half that landed reads
129
+ * config the older harness never sets, defaults it, and a subsystem — realtime
130
+ * channels, in the case that prompted this — is inert in a process that passes
131
+ * every probe. The fix is the opposite of the stale case, which is exactly why
132
+ * the two need separate sentences: bump the *image*, not the package.json.
117
133
  */
118
134
  export declare function warnOnDriverSkew(dataSources: InitializedDataSource[], runtimeVersion: string | undefined): void;
119
135
  /**
@@ -22,6 +22,7 @@ declare const bootEnvExtension: z.ZodObject<{
22
22
  SMTP_FROM: z.ZodOptional<z.ZodString>;
23
23
  SMTP_NAME: z.ZodOptional<z.ZodString>;
24
24
  APP_NAME: z.ZodDefault<z.ZodString>;
25
+ EMAIL_LOGO_URL: z.ZodOptional<z.ZodString>;
25
26
  REBASE_SERVE_STATIC: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
26
27
  "": "";
27
28
  true: "true";
@@ -57,7 +57,7 @@ export interface ProvisionTarget {
57
57
  engine: string;
58
58
  driverVersion?: string;
59
59
  runtimeVersion?: string;
60
- bootstrapper: Pick<BackendBootstrapper, "ensureCollectionSchema" | "ensureCollectionPolicies">;
60
+ bootstrapper: Pick<BackendBootstrapper, "ensureCollectionSchema" | "ensureCollectionPolicies" | "readCollectionsSchemaVersion" | "stampCollectionsSchemaVersion">;
61
61
  /**
62
62
  * The handle the hooks read (`internals.db`), or `undefined` to let the
63
63
  * adapter fall back to the connection it was constructed with.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Does the schema in the database match the collections this process was built
3
+ * from?
4
+ *
5
+ * A split deployment can run several processes from one bundle, and — once a
6
+ * deployment can pin them to *different* bundles — from more than one. They all
7
+ * share one database, and only one of them provisions it. So a process can come
8
+ * up perfectly, pass every probe, and query a column the schema owner has not
9
+ * created yet, or rely on a policy it has not applied. Neither fails in a way
10
+ * anybody sees: the first is a SQL error on one route, the second is a 200 with
11
+ * zero rows.
12
+ *
13
+ * The check is a stamp. The process that provisions writes the version of the
14
+ * collections it just applied into the database; every process compares its own
15
+ * against what it finds. Same table, same shape and the same "unstamped is not
16
+ * an error" rule as the auth schema version, which exists for exactly this class
17
+ * of failure one subsystem over.
18
+ *
19
+ * ## Why the database and not the api
20
+ *
21
+ * The obvious design is an HTTP call to the api's `/api/meta/schema-version`.
22
+ * It is worse in three ways. It needs the api's address configured on every
23
+ * other process, so it adds a variable whose absence disables the check
24
+ * silently. It makes booting depend on another process already being up, which
25
+ * turns a rollout ordering problem into a boot failure. And it asks the wrong
26
+ * question: what breaks is the *database* not matching, and two processes can
27
+ * agree with each other while both disagree with it.
28
+ *
29
+ * The database is also the only answer that works for a `worker`, which has no
30
+ * reason to know any URL, and for a single `all` deployment scaled to three,
31
+ * where there is no api to ask.
32
+ *
33
+ * ## Why both sides compute rather than read
34
+ *
35
+ * Both the stamp and the comparison are computed from the collections the
36
+ * process actually loaded, never from the `schemaVersion` a bundle manifest
37
+ * declares. A declared value is not evidence: `/api/meta/schema-version` returns
38
+ * the manifest's own number verbatim when the bundle has one, which is why
39
+ * comparing that endpoint to that manifest is a check that passes on a bundle
40
+ * whose declared version is nonsense — verified, once, on a fixture corrupted to
41
+ * `v1:deadbeefdeadbeef`. Computing on both sides means the comparison is between
42
+ * two things that were derived, so agreeing means something.
43
+ *
44
+ * ## A driver that predates this
45
+ *
46
+ * The image supplies `@rebasepro/server`; the *driver* comes from the bundle,
47
+ * pinned to whatever the project's package.json asked npm for. So a current
48
+ * runtime routinely runs against a driver older than itself, and that driver has
49
+ * neither hook. Absence is treated as "this driver does not record a version" —
50
+ * the same answer as a driver that never will — and the check silently does
51
+ * nothing rather than failing a boot over a capability the deployment cannot
52
+ * supply. It starts working when the project's driver is next updated. See
53
+ * `version-skew.ts` for the general shape of this problem.
54
+ */
55
+ import { type CollectionConfig } from "@rebasepro/types";
56
+ /** What the comparison found. */
57
+ export type SchemaStampVerdict =
58
+ /** The database was stamped by a build with these same collections. */
59
+ {
60
+ status: "match";
61
+ version: string;
62
+ }
63
+ /**
64
+ * Nothing has ever stamped this database.
65
+ *
66
+ * Not an error, and it must never become one: every database provisioned
67
+ * before this check existed is unstamped, and so is every fresh one until
68
+ * its first provisioning boot finishes.
69
+ */
70
+ | {
71
+ status: "unstamped";
72
+ }
73
+ /** The database describes a different set of collections than this process. */
74
+ | {
75
+ status: "mismatch";
76
+ database: string;
77
+ process: string;
78
+ }
79
+ /** No comparison was possible, with the reason. */
80
+ | {
81
+ status: "skipped";
82
+ reason: string;
83
+ };
84
+ /** Everything the decision needs. Pure, so the decision can be tested without a database. */
85
+ export interface SchemaStampInput {
86
+ /** The version computed from the collections this process loaded. */
87
+ processVersion: string;
88
+ /** What the database carries, or `null` when it has never been stamped. */
89
+ databaseVersion: string | null;
90
+ /**
91
+ * Whether this process derived its collections from the database itself.
92
+ *
93
+ * In that mode the collections *are* the database, so the two can never
94
+ * meaningfully disagree — and a mismatch would only ever mean two
95
+ * introspecting processes read the schema at different moments.
96
+ */
97
+ introspecting?: boolean;
98
+ /** Whether the driver can read a stamp at all. */
99
+ supported?: boolean;
100
+ }
101
+ /**
102
+ * Compare, without acting.
103
+ *
104
+ * Separated from {@link enforceSchemaStamp} because the decision is the part
105
+ * worth testing and the part that is wrong in a way nobody sees. Every branch
106
+ * here has a test.
107
+ */
108
+ export declare function decideSchemaStamp(input: SchemaStampInput): SchemaStampVerdict;
109
+ /**
110
+ * A refusal to serve against a schema this process does not match.
111
+ *
112
+ * Its own class so the boot paths can present it as a configuration problem
113
+ * rather than a crash, matching `RoleConfigurationError`.
114
+ */
115
+ export declare class SchemaVersionMismatchError extends Error {
116
+ readonly databaseVersion: string;
117
+ readonly processVersion: string;
118
+ constructor(databaseVersion: string, processVersion: string, message: string);
119
+ }
120
+ /** How loudly a mismatch is treated. */
121
+ export type SchemaMismatchPolicy = "warn" | "refuse";
122
+ /**
123
+ * Read the policy from the environment.
124
+ *
125
+ * Warning is the default because refusing would fail a rollout *mid-flight*: for
126
+ * the whole window between the schema owner rolling and the last unit following,
127
+ * the units that have not rolled yet legitimately disagree. That window is
128
+ * normal, and a deployment that turns it into a crash loop has traded a silent
129
+ * problem for a loud outage.
130
+ *
131
+ * `refuse` is for the deployment that would rather not serve at all than serve
132
+ * wrong — and for CI, where the window does not exist.
133
+ */
134
+ export declare function resolveSchemaMismatchPolicy(env?: {
135
+ REBASE_REQUIRE_SCHEMA_MATCH?: string;
136
+ }): SchemaMismatchPolicy;
137
+ /**
138
+ * The message a mismatch prints, or throws.
139
+ *
140
+ * One function so the warning and the refusal say exactly the same thing — an
141
+ * operator who raises the policy from `warn` to `refuse` should not have to
142
+ * learn a second description of the same fact.
143
+ */
144
+ export declare function describeMismatch(databaseVersion: string, processVersion: string): string;
145
+ /** What {@link enforceSchemaStamp} needs to do its work. */
146
+ export interface EnforceSchemaStampOptions {
147
+ /** The collections this process will serve. */
148
+ collections: CollectionConfig[];
149
+ /** Reads the stamp. Absent when the driver cannot. */
150
+ read?: () => Promise<string | null>;
151
+ /** Writes the stamp. Only called when this process provisioned the schema. */
152
+ stamp?: (version: string) => Promise<void>;
153
+ /** Whether this process is the one that just applied the schema. */
154
+ provisioned: boolean;
155
+ /** Whether the collections came from the database. */
156
+ introspecting?: boolean;
157
+ /** What a mismatch does. */
158
+ policy?: SchemaMismatchPolicy;
159
+ }
160
+ /**
161
+ * Stamp, or compare and report.
162
+ *
163
+ * The provisioning process writes; everyone else reads. A process that both
164
+ * provisions and would compare has nothing to compare against — it is the
165
+ * authority — so it stamps and returns.
166
+ *
167
+ * Every failure to read or write is swallowed with a warning rather than
168
+ * propagated. This check exists to make a silent problem visible; letting it
169
+ * take down a boot it was only supposed to describe would be a strictly worse
170
+ * trade, and a database that refuses one small write is already telling the
171
+ * operator something through other channels.
172
+ */
173
+ export declare function enforceSchemaStamp(options: EnforceSchemaStampOptions): Promise<SchemaStampVerdict>;
@@ -17,6 +17,23 @@ export declare function compareVersions(a: string, b: string): number | undefine
17
17
  export interface DriverSkew {
18
18
  /** True only when both versions parsed AND the driver is genuinely older. */
19
19
  stale: boolean;
20
+ /**
21
+ * True when the driver leads the runtime by a **minor or more**.
22
+ *
23
+ * Deliberately not "any version ahead". A driver pinned one patch forward is
24
+ * someone taking a fix early, and warning about it would train people to
25
+ * ignore the line — the reason this direction was silent to begin with. A
26
+ * *minor* ahead is a different animal: pre-1.0 the minor is where breaking
27
+ * change lives, and it is the axis a feature spanning `@rebasepro/server`
28
+ * and a driver moves on. When the bundle's half of such a feature is present
29
+ * and the image's half is not, the half that landed runs against a harness
30
+ * that never learned to drive it, and the result is a subsystem that is
31
+ * silently inert in a process reporting itself perfectly healthy.
32
+ *
33
+ * Nobody chooses this pairing. It is what a floating runtime range produces
34
+ * when the image lags the packages a project builds against.
35
+ */
36
+ ahead: boolean;
20
37
  /**
21
38
  * A clause naming both versions, ready to append to a sentence. Present
22
39
  * whenever both versions are known — including when they agree, because
@@ -28,9 +45,16 @@ export interface DriverSkew {
28
45
  /**
29
46
  * Describe how a driver's version relates to the runtime asking it for work.
30
47
  *
31
- * Returns `stale: false` whenever it cannot tell an unknown version, an
32
- * unparseable one, a driver that is newer. Silence beats a confident wrong
33
- * diagnosis, and the caller still prints its own local fact either way.
48
+ * Both directions are reportable, for opposite reasons. A driver **behind** the
49
+ * runtime lacks capabilities the runtime will ask for, and reports their absence
50
+ * in language indistinguishable from a driver that never had them. A driver
51
+ * **ahead** by a minor carries half of a feature whose other half lives in
52
+ * `@rebasepro/server` — and the image supplies that half, so the bundle's code
53
+ * runs against a harness that does not know about it.
54
+ *
55
+ * Returns both flags false whenever it cannot tell: an unknown version, an
56
+ * unparseable one, or a gap too small to mean anything. Silence beats a
57
+ * confident wrong diagnosis, and the caller still prints its own local fact.
34
58
  */
35
59
  export declare function describeDriverSkew(driverVersion: string | undefined, runtimeVersion: string | undefined): DriverSkew;
36
60
  /**
@@ -259,6 +259,6 @@ function createContractRoutes(config) {
259
259
  return router;
260
260
  }
261
261
  //#endregion
262
- export { createContractRoutes as n, contract_routes_exports as t };
262
+ export { createContractRoutes as n, computeSchemaVersion as r, contract_routes_exports as t };
263
263
 
264
- //# sourceMappingURL=contract-routes-DDNj4_J1.js.map
264
+ //# sourceMappingURL=contract-routes-DZ-LBpSL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract-routes-DZ-LBpSL.js","names":[],"sources":["../../types/src/types/project_manifest.ts","../../types/src/types/collection_contract.ts","../../types/src/types/schema_version.ts","../src/api/contract-routes.ts"],"sourcesContent":["/**\n * The project manifest (`rebase.json`) and the build artifacts derived from it.\n *\n * Three separate documents live in this file, and keeping them distinct matters:\n *\n * 1. {@link RebaseProjectManifest} — `rebase.json`. **Authored** by the developer,\n * committed to the repository. Declares topology only: which runtime major the\n * project targets, and which apps *this repository* contributes to the project.\n * Schema, security rules, hooks and functions stay in TypeScript under the\n * config package — nothing that needs a type system belongs here.\n *\n * 2. {@link RebaseProjectLink} — the per-checkout link (`.rebase/cloud.json`).\n * **Not committed**, because it is per-developer like a git remote. Says which\n * deployed project this working copy points at, whether that is a Rebase Cloud\n * project or the base URL of a self-hosted backend.\n *\n * 3. {@link RebaseBundleManifest} — `manifest.json` inside a built bundle.\n * **Generated**, never hand-edited. It is the lockfile analogue: the exact\n * contract a built artifact claims to satisfy, which the runtime validates\n * before it boots and a control plane validates before it deploys.\n *\n * A repository declares only the apps it contains. The set of apps belonging to a\n * project is held by the project itself, which is what makes multi-repo projects\n * work: two repositories never need to know about each other, only about the\n * project.\n */\n\nimport type { StorageSourceDefinition } from \"./storage_source\";\n\n/**\n * Which kind of thing an app is.\n *\n * - `backend` — the collections/hooks/functions that define the project's API.\n * Exactly one per *project* (not per repository); the registry enforces it.\n * - `static` — a pre-built client bundle (SPA, static site), served from the\n * backend process at its declared `path` or from a CDN. The admin panel is\n * one of these: it is an app in the user's repository like any other.\n *\n * That is the whole list. Ownership of the server process is a property of the\n * backend app ({@link RebaseBackendAppConfig.runtime}), not an app type.\n */\nexport type RebaseAppType = \"backend\" | \"static\";\n\n/**\n * The backend app: the project's API surface.\n *\n * Paths are relative to the directory holding `rebase.json`. The defaults match\n * the layout `rebase init` scaffolds, so a stock project may declare simply\n * `{ \"type\": \"backend\", \"runtime\": \"managed\" }`.\n */\nexport interface RebaseBackendAppConfig {\n type: \"backend\";\n /**\n * Who owns the process this backend runs in.\n *\n * - `managed` — the platform's runtime image boots this project's bundle.\n * You supply collections, functions, crons and schema; Rebase supplies the\n * server.\n * - `custom` — this repository builds its own image and entrypoint. The\n * escape hatch: full control, no managed-runtime guarantees.\n *\n * Independent of *where* it runs. Both run on Rebase Cloud and both\n * self-host — the destination lives in `.rebase/cloud.json`, not here. See\n * `docker/docker-compose.selfhost.yml`, which boots a managed bundle on a\n * developer's own Docker host.\n *\n * This is authored rather than inferred on purpose. It is the single most\n * consequential fact about a deployment, and inferring it is what used to\n * land projects on the custom runtime without anyone choosing it.\n */\n runtime: \"managed\" | \"custom\";\n /** Directory of the config package (collections + index). Default `config`. */\n config?: string;\n /** Directory of server functions. Default `backend/functions`. */\n functions?: string;\n /** Directory of cron job definitions. Default `backend/crons` when present. */\n crons?: string;\n /**\n * Path to the generated Drizzle schema module (tables/enums/relations).\n * Default `backend/src/schema.generated.ts`.\n */\n schema?: string;\n /**\n * Module path (relative to `config`) exporting the auth users collection as\n * its default export. Default `collections/users`.\n */\n usersCollection?: string;\n\n /**\n * `runtime: \"custom\"` only. Dockerfile path relative to the repository root.\n * Default `Dockerfile`.\n */\n dockerfile?: string;\n /** `runtime: \"custom\"` only. Build context relative to the root. Default `.`. */\n context?: string;\n /** `runtime: \"custom\"` only. Port the container listens on. Default 8080. */\n port?: number;\n}\n\n/**\n * A static client bundle — SPA or static site — built here and served at `path`.\n */\nexport interface RebaseStaticAppConfig {\n type: \"static\";\n /** Package directory containing the client sources. */\n root: string;\n /** Command that produces `output`. Run from the repository root. */\n build?: string;\n /** Directory of built assets, relative to the repository root. */\n output: string;\n /**\n * Public base path this app is served under. Default `/`.\n *\n * Several static apps run in one process, each at its own path — the API at\n * `/api`, a site at `/`, the admin at `/admin` — which is what keeps a\n * self-hosted deployment a single container.\n *\n * **This is a build-time input, not only a serving concern.** An app mounted\n * at `/admin` must be *built* for `/admin` (Vite's `base`), or `index.html`\n * loads and every asset 404s: a blank page with no server error. `rebase\n * build` passes it as `REBASE_APP_BASE` and asserts the emitted HTML honours\n * it. Changing this value requires rebuilding the app.\n */\n path?: string;\n /**\n * Serve `index.html` for unmatched paths under `path` (client-side routing).\n * Default `true` — the overwhelmingly common case for a client app, and a\n * static *site* generator emits real files for its routes anyway.\n */\n spa?: boolean;\n}\n\nexport type RebaseAppConfig = RebaseBackendAppConfig | RebaseStaticAppConfig;\n\n/**\n * One declared storage source, as authored in `rebase.json`.\n *\n * The key comes from the enclosing record, so this is\n * {@link StorageSourceDefinition} minus its `key` — the same document the\n * runtime registry and the frontend router consume, expressed the way a JSON\n * object naturally expresses \"a set of named things\".\n */\nexport interface RebaseStorageSourceConfig {\n /** Engine backing this source: `local`, `s3`, `gcs`, or a custom id. */\n engine: string;\n /**\n * How the frontend reaches it. Default `server` (proxied through\n * `/api/storage`). `direct` means a provider SDK talks to the bucket and the\n * backend is not in the upload path.\n */\n transport?: \"server\" | \"direct\";\n /** Human-readable label for the console and the admin UI. */\n label?: string;\n}\n\n/**\n * `rebase.json` — the authored project manifest.\n */\nexport interface RebaseProjectManifest {\n /** JSON Schema URL, for editor completion. Ignored by the tooling. */\n $schema?: string;\n /**\n * The runtime contract **major** this project targets, as a semver range\n * (e.g. `^1`, `~1.4`, or an exact `1.4.2` to pin).\n *\n * The platform upgrades patches and minors underneath a project without\n * asking; it never crosses a major. See {@link RUNTIME_CONTRACT_VERSION}.\n *\n * Named `rebase` rather than `runtime` so that `runtime` means exactly one\n * thing — {@link RebaseBackendAppConfig.runtime}, who owns the process. It\n * reads like `engines` in a `package.json`, which is what it is.\n */\n rebase: string;\n /**\n * Apps this repository contributes, keyed by app name. The key is the app's\n * identity within the project: it is what `rebase deploy <app>` names, what\n * client credentials are issued against, and what a second repository must\n * not collide with.\n */\n apps: Record<string, RebaseAppConfig>;\n /**\n * Storage sources this project uses, keyed by source key.\n *\n * **Topology only — never credentials.** Which buckets exist is a property of\n * the project and belongs in the repository; how to reach each one is a\n * property of the deployment and lives in the environment, read per source\n * from `<BASE>__<KEY>` (`S3_BUCKET__MEDIA` for a source keyed `media`). The\n * default source takes no suffix, so a single-bucket project configured with\n * plain `S3_BUCKET` keeps working having declared nothing at all.\n *\n * Declared here rather than only in the config package because this file is\n * the one artifact a host can read *before* running a build. That is what\n * lets a console show \"this project wants a `media` bucket, and it has none\"\n * on a project's first deploy, and it is why the managed and custom runtimes\n * can present the same list — a custom build emits no bundle manifest, so a\n * declaration that lived only in compiled config would leave every custom\n * project invisible.\n *\n * Omitted entirely means one default source, which is the overwhelmingly\n * common project and must not be required to say so.\n */\n storage?: Record<string, RebaseStorageSourceConfig>;\n /**\n * Repository-wide opt-out from anonymous CLI usage sharing.\n *\n * **Only `false` does anything.** It suppresses sharing for everyone who\n * clones this repository, overriding each developer's own opt-in — an\n * organisation setting policy for work done on its behalf, the same shape\n * as a committed `.npmrc`.\n *\n * `true` is deliberately ignored, and the CLI says so rather than obeying\n * quietly. This file is committed, so a `true` here would be one developer\n * answering a privacy question for every colleague who later clones the\n * repo — consent by proxy, which is the exact thing opt-in exists to\n * prevent. Individuals opt in with `rebase telemetry enable`.\n */\n telemetry?: boolean;\n}\n\n/**\n * The per-checkout project link.\n *\n * Deliberately separate from `rebase.json`: the manifest is committed and shared,\n * while the link is per-developer. Keeping them in one file would mean either\n * committing someone's project id or gitignoring the topology.\n */\nexport interface RebaseProjectLink {\n /**\n * A Rebase Cloud project id, or the base URL of any running Rebase backend\n * (`https://api.example.com`). Both are first-class: every command that\n * accepts a project reference accepts either, so a self-hosted project has\n * the same tooling as a cloud one.\n */\n project: string;\n /** Organization slug. Cloud projects only. */\n org?: string;\n /** Explicit API base URL, when it differs from the project's default. */\n apiUrl?: string;\n}\n\n/**\n * Whether a project can run on the managed runtime, and if not, precisely why.\n *\n * The reasons are returned rather than summarised so tooling can print something\n * a developer can act on. \"Not eligible\" is never a dead end — it selects the\n * custom-runtime path, which still deploys.\n */\nexport interface ManagedCompatibility {\n eligible: boolean;\n reasons: string[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Bundle\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Version of the bundle *format* itself.\n *\n * Bumped only when the on-disk layout changes in a way an older runtime could\n * not read. A runtime accepts any bundle whose `bundleFormat` is less than or\n * equal to its own — old bundles keep booting on new runtimes, which is the\n * whole point of separating the artifact from the engine.\n *\n * - **1** — `mode: \"cms\" | \"baas\" | \"static\"`, `entry.static` a single directory\n * string, `entry.admin` for a bundled admin panel.\n * - **2** — `kind: \"backend\" | \"static\"`, `entry.static` a list of\n * {@link RebaseBundleStatic}, `entry.admin` removed. A format-1 runtime reading\n * one of these would find no `mode` and an array where it expects a string, so\n * the bump is what turns that into a refusal to boot instead of a bundle that\n * starts and serves nothing.\n */\nexport const BUNDLE_FORMAT_VERSION = 2;\n\n/**\n * The runtime contract major.\n *\n * Distinct from the `@rebasepro/server` package version: the package may release\n * any number of minors and patches while this stays put. It changes only when\n * the bundle/runtime contract breaks compatibility, and a project's\n * `manifest.runtime` range is matched against *this*.\n */\nexport const RUNTIME_CONTRACT_VERSION = 1;\n\n/** Where the runtime finds each part of the bundle. Paths are bundle-relative. */\nexport interface RebaseBundleEntrypoints {\n /** Compiled config package directory (collections live under it). */\n config?: string;\n /** Compiled collections directory, when it differs from `<config>/collections`. */\n collections?: string;\n /** Compiled functions directory. */\n functions?: string;\n /** Compiled crons directory. */\n crons?: string;\n /** Compiled Drizzle schema module. */\n schema?: string;\n /** Module exporting the auth users collection (default export). */\n usersCollection?: string;\n /**\n * Built static apps to serve from this process, in declaration order.\n *\n * A list rather than a single directory because one process serves several\n * apps at different paths — a site at `/` and the admin at `/admin`. The\n * runtime mounts them longest-path-first so the `/`-rooted app's catch-all\n * does not claim its siblings' URLs.\n */\n static?: RebaseBundleStatic[];\n}\n\n/** One built static app inside a bundle. */\nexport interface RebaseBundleStatic {\n /** Public base path, e.g. `/` or `/admin`. */\n path: string;\n /** Bundle-relative directory holding the built assets. */\n dir: string;\n /** Serve `index.html` for unmatched paths under `path`. */\n spa: boolean;\n}\n\n/**\n * A native module found in the dependency closure.\n *\n * Recorded rather than merely counted so a rejection can name the offending\n * package instead of saying \"something here is native\".\n */\nexport interface NativeDependency {\n name: string;\n /** Why it was flagged — a `.node` binary, a gyp build, or an install script. */\n reason: string;\n}\n\n/**\n * `manifest.json` — generated, and the document the runtime and control plane\n * both validate against.\n */\nexport interface RebaseBundleManifest {\n /** @see BUNDLE_FORMAT_VERSION */\n bundleFormat: number;\n runtime: {\n /** The `runtime` range copied from `rebase.json`. */\n range: string;\n /** Exact `@rebasepro/server` version this bundle was built against. */\n builtAgainst: string;\n /** Runtime contract major this bundle requires. */\n contract: number;\n };\n /**\n * Hash of the compiled collection definitions.\n *\n * This is the contract stamp. A generated SDK records the value it was built\n * from, a client sends it back, and a mismatch is what lets the platform say\n * \"this app was built against an older schema\" instead of failing mysteriously\n * at the first request. It covers collections only — a hook edit does not\n * change a client's contract, so it must not invalidate every SDK.\n */\n schemaVersion: string;\n /** Which app in `rebase.json` this bundle was built from. */\n app: string;\n /**\n * What the runtime does with this bundle.\n *\n * - `backend` — boot the full server: database, auth and the data API, plus\n * any static apps in `entry.static`.\n * - `static` — no backend at all: serve `entry.static` and nothing else. No\n * database, no auth, no data sources. This is how a static app runs on the\n * same image as the backend.\n *\n * Replaces an earlier `mode: \"cms\" | \"baas\" | \"static\"`. The cms/baas\n * distinction was never a third kind of thing — it is simply whether\n * `entry.config` is present, so it is derived rather than declared.\n */\n kind: \"backend\" | \"static\";\n entry: RebaseBundleEntrypoints;\n /** Collection slugs contained in the bundle, for quick inspection. */\n collections?: string[];\n hooks: {\n /**\n * Whether the dependency closure contains native code.\n *\n * The managed runtime refuses these: a prebuilt binary cannot be run on\n * an image the platform did not build it for, and the honest failure is\n * at deploy time rather than at 3am in a crash loop.\n */\n native: boolean;\n nativeModules?: NativeDependency[];\n };\n /**\n * What the bundle's config says about storage access control.\n *\n * Storage is not under RLS and its keys share one flat namespace, so a\n * deployment with file storage enabled and no access model serves every\n * user's files to every signed-in user. The runtime refuses to boot in that\n * state — which, on a hosted platform that enables storage from the *console*\n * rather than from the bundle, surfaces as a crash loop the developer cannot\n * read.\n *\n * Recording it here lets a host reject the deploy with the reason instead.\n * Absent on bundles built before this field existed.\n */\n storage?: {\n /** Whether the config package exports a `storageAuthorize` hook. */\n authorize: boolean;\n /**\n * Every storage source this bundle expects, resolved at build time from\n * `rebase.json`'s `storage` block merged with any `storageSources` the\n * config package exports.\n *\n * Recorded so the runtime does not have to import user code to learn its\n * own topology, and so a host can tell — from the artifact alone, before\n * starting anything — which buckets need configuring. Absent on bundles\n * built before this field existed, which means one default source.\n */\n sources?: StorageSourceDefinition[];\n };\n deps: {\n /** Runtime dependencies of user code, as declared. */\n declared: Record<string, string>;\n /**\n * The dependency tree ships *inside* the bundle, already installed.\n *\n * Absent or false means the tree is declared but not present, and\n * whoever boots the bundle has to install it. On the managed runtime that\n * install runs in an init container on **every** pod start — the bundle\n * lives on a volume that is wiped each time — and it is the single\n * largest cost in a managed pod's life: 35–55 seconds of a 40–60 second\n * cold start. Since a pod restarts on every eviction, node failure, OOM\n * and runtime rollout, that number is not a startup detail. It is what an\n * outage costs.\n *\n * Vendoring moves the install to build time, where it happens once. It is\n * skipped when the closure contains native code, because a prebuilt\n * binary is only valid for the platform it was built for — see\n * {@link vendorTarget} for what \"the platform\" means here.\n */\n vendored?: boolean;\n /**\n * What {@link vendored} was resolved for, recorded so a mismatch can be\n * refused rather than discovered at import time.\n *\n * Cross-platform vendoring is safe for pure JavaScript and unsafe for\n * anything compiled, and the boundary between them is not always visible\n * in a dependency list: `esbuild` is pure-JS with a *platform-specific\n * optional dependency* holding the actual binary, so an install run on a\n * developer's Mac silently produces a tree that cannot run on the Linux\n * image. The install therefore resolves optional dependencies for the\n * target explicitly rather than for the machine it runs on, and records\n * the answer here.\n */\n vendorTarget?: {\n /** npm `--os`, e.g. `linux`. */\n os: string;\n /** npm `--cpu`, e.g. `x64`. */\n cpu: string;\n /** Node major the tree was resolved for. */\n node: string;\n };\n };\n build: {\n /** `@rebasepro/cli` version that produced this bundle. */\n cli: string;\n /** Node major the bundle was compiled on. */\n node: string;\n /** ISO-8601. */\n createdAt: string;\n };\n}\n\n/** The contract a running backend serves at `GET /api/meta/contract`. */\nexport interface RebaseProjectContract {\n /** Matches {@link RebaseBundleManifest.schemaVersion}. */\n schemaVersion: string;\n runtime: {\n /** `@rebasepro/server` version currently running. */\n version: string;\n contract: number;\n };\n /** Full collection definitions, serialized — the input to SDK generation. */\n collections: unknown[];\n /** Collection slugs, for cheap inspection without parsing the definitions. */\n collectionSlugs: string[];\n generatedAt: string;\n}\n\n/** Header carrying the schema version an SDK was generated from. */\nexport const SCHEMA_VERSION_HEADER = \"x-rebase-schema\";\n","import type { CollectionConfig } from \"./collections\";\n\n/**\n * Serializing collections so they survive a network hop.\n *\n * A collection definition is not plain data. Relations point at their target\n * with a *function* (`target: () => usersCollection`) so two collections can\n * reference each other without an import cycle, and collections also carry\n * callbacks, custom views and component references. `JSON.stringify` silently\n * drops every one of those, which matters because the SDK generator *calls*\n * `relation.target()` to decide whether a foreign key is a string or a number.\n * Serialize naively and remote SDK generation produces subtly wrong types\n * instead of failing — the worst possible outcome.\n *\n * So relation targets are resolved to a slug reference on the way out and\n * rebuilt into functions on the way in. Everything else that cannot cross a wire\n * is dropped deliberately: an SDK is generated from the *shape* of the data, and\n * server-side behaviour is neither useful to a client nor safe to publish.\n */\n\n/** Marker replacing a relation's `target` function in serialized form. */\nexport interface SerializedCollectionRef {\n __collectionRef: string;\n}\n\nexport function isSerializedCollectionRef(value: unknown): value is SerializedCollectionRef {\n return typeof value === \"object\"\n && value !== null\n && typeof (value as SerializedCollectionRef).__collectionRef === \"string\";\n}\n\n/** Depth limit for the walk — deep enough for real configs, finite for cyclic ones. */\nconst MAX_DEPTH = 64;\n\n/**\n * Resolve whatever a `target` thunk returns down to a collection.\n *\n * A target may be the collection, a module namespace (when the authoring file\n * used `import * as`), or a default-export wrapper. All three appear in real\n * projects, and the SDK generator already unwraps them the same way.\n */\nfunction unwrapTarget(value: unknown): CollectionConfig | undefined {\n if (!value || typeof value !== \"object\") return undefined;\n const candidate = value as { default?: unknown; __esModule?: boolean; properties?: unknown };\n if (candidate.default || candidate.__esModule) {\n const inner = candidate.default;\n if (inner && typeof inner === \"object\") return inner as CollectionConfig;\n }\n if (candidate.properties) return value as CollectionConfig;\n return undefined;\n}\n\n/** The identity a serialized reference uses. Slug first — it is the routing key. */\nfunction refFor(collection: CollectionConfig | undefined): string | undefined {\n if (!collection) return undefined;\n const withPath = collection as CollectionConfig & { path?: string };\n return collection.slug || withPath.path || collection.name;\n}\n\n/**\n * Deep-copy a value into something JSON can carry.\n *\n * `target` keys are special-cased into refs. Other functions vanish, cycles are\n * cut, and everything else is copied structurally.\n */\n/** Shared walk state: the memo, plus a count of depth-cap hits. */\ninterface WalkState {\n memo: WeakMap<object, unknown>;\n /**\n * How many times the walk has truncated a subtree — by hitting the depth\n * cap, or by cutting a cycle.\n *\n * Either kind of truncation makes a result valid only at the *position* it\n * was produced at, so caching it and serving it elsewhere silently drops\n * content that would have been included. Comparing this counter before and\n * after a node's children tells us whether its result is position-\n * independent and therefore safe to memoize.\n *\n * The cycle case is the subtle one: with `a.b = b` and `b.a = a`, serializing\n * `{ first: b, second: a }` visits `a` beneath `b` — where the cycle back to\n * `b` is cut — and would then reuse that truncated `a` for `second`, where\n * nothing needed cutting.\n */\n truncations: number;\n}\n\nfunction toSerializable(\n value: unknown,\n seen: WeakSet<object>,\n depth: number,\n state: WalkState,\n key?: string\n): unknown {\n if (depth > MAX_DEPTH) {\n state.truncations++;\n return undefined;\n }\n\n if (typeof value === \"function\") {\n // Only a relation target carries information a client needs. Calling it\n // is safe here — this runs on the server, where the target module is\n // already loaded — and a throwing target simply yields no reference,\n // which degrades the generated FK type rather than failing the request.\n if (key === \"target\") {\n try {\n const resolved = unwrapTarget((value as () => unknown)());\n const ref = refFor(resolved);\n return ref ? { __collectionRef: ref } : undefined;\n } catch {\n return undefined;\n }\n }\n return undefined;\n }\n\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n\n if (value instanceof Date) return value.toISOString();\n if (value instanceof RegExp) return value.source;\n\n if (seen.has(value as object)) {\n state.truncations++;\n return undefined;\n }\n\n // A shared (non-cyclic) subgraph is reachable by many paths, and `seen` is a\n // *path* set — released in the `finally` below so a node referenced twice in\n // different branches is emitted twice rather than dropped as a false cycle.\n // Without memoization that makes the walk exponential in depth: a diamond\n // graph 20 levels deep took ~400ms, and each further level doubled it. The\n // result is a plain data tree, so handing back the same converted object for\n // a repeat visit is indistinguishable after JSON.stringify.\n const cached = state.memo.get(value as object);\n if (cached !== undefined) return cached;\n\n seen.add(value as object);\n const truncationsBefore = state.truncations;\n const memoize = (result: unknown): unknown => {\n // Only cache a result that nothing was cut from.\n if (result !== undefined && state.truncations === truncationsBefore) {\n state.memo.set(value as object, result);\n }\n return result;\n };\n\n try {\n if (Array.isArray(value)) {\n const items = value\n .map(item => toSerializable(item, seen, depth + 1, state))\n .filter(item => item !== undefined);\n // A container that had content, none of which can be represented, is\n // itself unrepresentable — see the note below.\n return memoize(value.length > 0 && items.length === 0 ? undefined : items);\n }\n\n // A React element or component reference has no meaning to a client and\n // will not survive JSON anyway.\n if (\"$$typeof\" in (value as Record<string, unknown>)) return undefined;\n\n const entries = Object.entries(value as Record<string, unknown>);\n const out: Record<string, unknown> = {};\n for (const [k, v] of entries) {\n const converted = toSerializable(v, seen, depth + 1, state, k);\n if (converted !== undefined) out[k] = converted;\n }\n\n // Drop a container whose entire content was dropped.\n //\n // `callbacks: { beforeSave() {…} }` would otherwise serialize to\n // `callbacks: {}` — an empty husk that carries no information but is not\n // *nothing*, so it lands in the payload and, worse, in the schema hash.\n // Editing a hook would then change every client's schema version and\n // report perfectly current SDKs as stale.\n //\n // A container that started empty stays empty: `properties: {}` is a\n // deliberate statement, not a casualty.\n if (entries.length > 0 && Object.keys(out).length === 0) return undefined;\n\n return memoize(out);\n } finally {\n // Released so a collection referenced twice in different branches is\n // emitted twice rather than being dropped as a false cycle.\n seen.delete(value as object);\n }\n}\n\n/**\n * Serialize collections for transport over the contract endpoint.\n *\n * Sorted by slug so the output — and therefore the schema hash computed from it\n * — does not depend on filesystem ordering.\n */\nexport function serializeCollections(collections: CollectionConfig[]): unknown[] {\n return [...collections]\n .sort((a, b) => String(a.slug ?? \"\").localeCompare(String(b.slug ?? \"\")))\n .map(collection => toSerializable(withoutAdminBlock(collection), new WeakSet(), 0, {\n memo: new WeakMap(),\n truncations: 0\n }))\n .filter((c): c is Record<string, unknown> => c !== undefined);\n}\n\n/**\n * Drop the admin block before the walk.\n *\n * Nothing downstream of serialization is an admin panel. The contract endpoint\n * feeds remote SDK generation, and `rebase build` writes the result into a bundle\n * manifest that only the backend runtime reads. The block would survive the walk\n * as a husk anyway — its React elements and component functions are dropped\n * individually — and that husk has two costs worth avoiding: it puts every custom\n * component's *file path* on an endpoint whose job is to describe data shapes, and\n * it grows a payload that is fetched and cached per project.\n *\n * Removing it here rather than at each call site means one chokepoint, so a future\n * consumer of `serializeCollections` cannot forget.\n *\n * Child collections carry their own block, so this recurses — stripping only the\n * top level was the mistake `stripNonClientFields` in the contract routes already\n * had to fix once for security rules.\n */\nfunction withoutAdminBlock(collection: CollectionConfig): CollectionConfig {\n const { admin: _admin, ...rest } = collection as CollectionConfig & Record<string, unknown>;\n const nested = rest as Record<string, unknown>;\n if (Array.isArray(nested.subcollections)) {\n nested.subcollections = nested.subcollections.map(\n (child) => withoutAdminBlock(child as CollectionConfig)\n );\n }\n return rest as CollectionConfig;\n}\n\n/**\n * Rebuild collections received from a contract endpoint.\n *\n * Relation refs become real thunks resolving through the returned set, so\n * downstream consumers — the SDK generator above all — see exactly the shape\n * they would have seen had the collections been imported from source.\n *\n * A ref naming a collection that is not in the payload resolves to `undefined`\n * rather than throwing: the generator already tolerates an unresolvable target\n * by falling back to a permissive key type, and a partial contract should still\n * produce a usable SDK.\n */\nexport function deserializeCollections(payload: unknown[]): CollectionConfig[] {\n const collections = payload\n .filter((c): c is Record<string, unknown> => typeof c === \"object\" && c !== null)\n .map(c => ({ ...c })) as unknown as CollectionConfig[];\n\n const bySlug = new Map<string, CollectionConfig>();\n for (const collection of collections) {\n const ref = refFor(collection);\n if (ref) bySlug.set(ref, collection);\n }\n\n const rehydrate = (value: unknown, depth: number): void => {\n if (depth > MAX_DEPTH || !value || typeof value !== \"object\") return;\n\n if (Array.isArray(value)) {\n for (const item of value) rehydrate(item, depth + 1);\n return;\n }\n\n const record = value as Record<string, unknown>;\n for (const [key, child] of Object.entries(record)) {\n if (key === \"target\" && isSerializedCollectionRef(child)) {\n const slug = child.__collectionRef;\n record.target = () => bySlug.get(slug);\n continue;\n }\n rehydrate(child, depth + 1);\n }\n };\n\n for (const collection of collections) rehydrate(collection, 0);\n return collections;\n}\n","import type { CollectionConfig } from \"./collections\";\nimport { serializeCollections } from \"./collection_contract\";\n\n/**\n * The schema version stamp.\n *\n * One function, used in three places that must agree or the whole drift-detection\n * story is noise: `rebase build` writes it into a bundle manifest, the runtime\n * serves it from the contract endpoint, and a generated SDK records the value it\n * was built from. If any two of those computed it differently, every client would\n * look permanently out of date.\n *\n * It covers **collections only** — the client's contract is the shape of the\n * data, so editing a hook or a server function must not invalidate every SDK in\n * every repository. That is a deliberate narrowing, not an oversight.\n */\n\n/** Stable stringify: object keys sorted at every level, so key order cannot alter the hash. */\nfunction canonicalize(value: unknown): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value) ?? \"null\";\n }\n if (Array.isArray(value)) {\n return `[${value.map(canonicalize).join(\",\")}]`;\n }\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(\",\")}}`;\n}\n\n/**\n * Reduce a collection to the parts a generated client is actually built from.\n *\n * The version answers one question — \"is this SDK stale?\" — so it must change\n * exactly when the generated types could change, and never otherwise. Hashing a\n * whole collection fails both halves of that:\n *\n * - Security rules, callbacks, icons, groups and UI settings do not appear in a\n * generated client, so including them reports perfectly current SDKs as stale.\n * - Worse, they are not stable *inputs*. The runtime applies default security\n * rules when it loads collections, so the same source hashed before and after\n * loading produced two different answers — a build-time stamp that could never\n * match the server that served it.\n *\n * Codegen reads the slug (for the `Database` key and type names), the properties,\n * and the relations. That is the projection.\n */\nfunction projectForCodegen(collection: CollectionConfig): Record<string, unknown> {\n const source = collection as CollectionConfig & {\n relations?: unknown;\n subcollections?: CollectionConfig[];\n path?: string;\n engine?: unknown;\n dataSource?: unknown;\n };\n\n return {\n slug: collection.slug ?? source.path,\n properties: collection.properties,\n relations: source.relations,\n // The engine decides whether relations are resolved at all: codegen asks\n // `getDataSourceCapabilities(collection.engine).supportsRelations`, and an\n // engine that answers no drops every foreign-key column from the\n // generated Row/Insert/Update types. Moving a collection to such an\n // engine is a real change to the generated types, so it has to move the\n // version. `dataSource` is what resolves to `engine`, so it counts too.\n engine: source.engine,\n dataSource: source.dataSource,\n subcollections: source.subcollections?.map(projectForCodegen)\n };\n}\n\n/**\n * Compute the canonical string a schema version hashes.\n *\n * Exposed separately so the hashing itself can differ by environment: Node has\n * `crypto`, and callers without it can still compare canonical forms directly.\n */\nexport function canonicalSchemaPayload(collections: CollectionConfig[]): string {\n const projected = serializeCollections(collections)\n .map(collection => projectForCodegen(collection as CollectionConfig));\n return canonicalize(projected);\n}\n\n/**\n * A short, non-cryptographic digest of the canonical payload.\n *\n * FNV-1a style, 64 bits, as two 32-bit halves. This is an identity, not a\n * security boundary: nothing trusts a schema version to prove anything, it only\n * answers \"is this the same schema as before\". A hand-rolled hash keeps this\n * module free of `node:crypto`, so the identical function runs in the browser,\n * in the CLI, and in the runtime — which is the property that actually matters.\n */\nexport function computeSchemaVersion(collections: CollectionConfig[]): string {\n const payload = canonicalSchemaPayload(collections);\n\n let h1 = 0x811c9dc5;\n let h2 = 0x01000193;\n\n for (let i = 0; i < payload.length; i++) {\n const code = payload.charCodeAt(i);\n h1 ^= code;\n // Multiply by the FNV prime using shifts to stay in 32-bit integer math.\n h1 = (h1 + ((h1 << 1) + (h1 << 4) + (h1 << 7) + (h1 << 8) + (h1 << 24))) >>> 0;\n h2 ^= code + i;\n h2 = (h2 + ((h2 << 1) + (h2 << 5) + (h2 << 9) + (h2 << 15) + (h2 << 24))) >>> 0;\n }\n\n const hex = (n: number): string => n.toString(16).padStart(8, \"0\");\n return `v1:${hex(h1)}${hex(h2)}`;\n}\n","import { Hono } from \"hono\";\nimport {\n RUNTIME_CONTRACT_VERSION,\n SCHEMA_VERSION_HEADER,\n computeSchemaVersion,\n serializeCollections,\n type CollectionConfig,\n type RebaseProjectContract\n} from \"@rebasepro/types\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The project contract endpoint.\n *\n * This is what makes a repository able to build against a project it does not\n * contain. Without it, a typed client can only be generated from local\n * collection *source*, which means every frontend must live in the same\n * repository as the backend. Serving the contract turns that around: an app\n * asks the project what its shape is, so a web app, a second web app and a\n * mobile app can each live wherever they like and none of them needs to know\n * about the others.\n *\n * Admin-gated. Collection definitions describe every table, column and relation\n * in the project, including ones no security rule would ever expose — that is a\n * map of the database, not public API documentation.\n */\n\nexport interface ContractRoutesConfig {\n collectionRegistry: { getRawCollections(): CollectionConfig[] };\n /**\n * The schema version recorded at build time.\n *\n * Preferred over recomputing, so that what a client is told matches exactly\n * what the bundle claims. It is recomputed only when a bundle did not record\n * one — a `baas`-mode project derives its collections from the live database\n * at boot, so there was nothing to hash when it was built.\n */\n schemaVersion?: string;\n /** Runtime package version, surfaced so a client can report what it built against. */\n runtimeVersion?: string;\n}\n\n/**\n * Strip everything a client does not need from a serialized collection.\n *\n * The generator reads the slug, the properties and the relations. It never reads\n * a security rule — but `securityRules` carries the raw SQL of every RLS\n * predicate guarding the project, which is a description of the authorization\n * model rather than of the data shape. Publishing it to anyone who can generate\n * an SDK gives away more than the endpoint is for, so it is removed here rather\n * than trusted not to matter.\n */\nfunction stripNonClientFields(collection: unknown): unknown {\n if (!collection || typeof collection !== \"object\") return collection;\n const {\n securityRules: _securityRules,\n callbacks: _callbacks,\n ...rest\n } = collection as Record<string, unknown>;\n\n // Subcollections are collections, and carry their own rules. Stripping only\n // the top level published every nested policy — the leak this exists to\n // prevent, just one level down.\n if (Array.isArray(rest.subcollections)) {\n rest.subcollections = rest.subcollections.map(stripNonClientFields);\n }\n\n return rest;\n}\n\nexport function createContractRoutes(config: ContractRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n // Computing a version walks and canonicalizes every collection, and\n // `/schema-version` is deliberately unauthenticated and meant to be polled.\n // Recomputing per request would make a CI convenience into a CPU\n // amplification anyone could aim at the server. Collections do not change\n // after boot, so once is enough.\n let cachedVersion: string | undefined;\n const schemaVersionOf = (collections: CollectionConfig[]): string => {\n if (config.schemaVersion) return config.schemaVersion;\n if (cachedVersion === undefined) cachedVersion = computeSchemaVersion(collections);\n return cachedVersion;\n };\n\n router.get(\"/contract\", (c) => {\n const collections = config.collectionRegistry.getRawCollections();\n const serialized = serializeCollections(collections).map(stripNonClientFields);\n\n // In `baas` mode the collections are whatever introspection found at\n // boot, so the version has to be computed from them rather than taken\n // from a build that never saw them.\n const schemaVersion = schemaVersionOf(collections);\n\n const contract: RebaseProjectContract = {\n schemaVersion,\n runtime: {\n version: config.runtimeVersion ?? \"unknown\",\n contract: RUNTIME_CONTRACT_VERSION\n },\n collections: serialized,\n collectionSlugs: collections\n .map(collection => collection.slug)\n .filter((slug): slug is string => Boolean(slug))\n .sort(),\n generatedAt: new Date().toISOString()\n };\n\n c.header(SCHEMA_VERSION_HEADER, schemaVersion);\n return c.json(contract);\n });\n\n /**\n * Cheap drift check.\n *\n * Deliberately unauthenticated and deliberately tiny: it returns a version\n * string and nothing else. A CI job that only wants to know whether its\n * generated SDK is stale should not need admin credentials, and a version\n * stamp reveals nothing about the schema it stands for.\n */\n router.get(\"/schema-version\", (c) => {\n const schemaVersion = schemaVersionOf(config.collectionRegistry.getRawCollections());\n c.header(SCHEMA_VERSION_HEADER, schemaVersion);\n return c.json({ schemaVersion, });\n });\n\n logger.debug(\"Contract routes mounted\");\n return router;\n}\n"],"mappings":"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"}
@@ -13,7 +13,7 @@ import type { CronJobDefinition } from "@rebasepro/types";
13
13
  * against that identity, not skipped. See that constant's docblock. It clears
14
14
  * the default policies through their `rolesOverlap(['admin'])` arm, which is why
15
15
  * the distinction rarely shows; it shows against hand-written rules, where
16
- * `policy.serverContext()` (`auth.uid() IS NULL`) is **false** for it. Use
16
+ * `policy.serverContext()` (`rebase.uid() IS NULL`) is **false** for it. Use
17
17
  * `rebase.sql()` when you need the genuine bypass — owner connection, no
18
18
  * policies.
19
19
  *
@@ -2,7 +2,7 @@ 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-B-E7RjdN.js";
5
+ import "./src-BPfYOeN4.js";
6
6
  import "./src-CrCxd8km.js";
7
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";
@@ -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-BBGvOA-9.js.map
183
+ //# sourceMappingURL=cron-store-DfH_4Cd9.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cron-store-BBGvOA-9.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-DfH_4Cd9.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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"}