@pramen/server 0.0.59 → 0.0.60
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/cli.js +99 -2
- package/dist/durable-object.d.ts +2 -0
- package/dist/durable-object.js +48 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/pramen.d.ts +7 -1
- package/dist/pramen.js +2 -0
- package/dist/runtime/data-migrations.d.ts +88 -0
- package/dist/runtime/data-migrations.js +256 -0
- package/dist/sdk/acl.d.ts +13 -5
- package/dist/sdk/acl.js +13 -5
- package/dist/sdk/handlers.d.ts +41 -0
- package/dist/sdk/iso-timestamps.d.ts +54 -0
- package/dist/sdk/iso-timestamps.js +119 -0
- package/dist/sdk/schema.d.ts +39 -3
- package/dist/sdk/schema.js +40 -4
- package/dist/worker.js +88 -1
- package/package.json +1 -1
- package/src/cli.ts +125 -4
- package/src/durable-object.ts +53 -0
- package/src/index.ts +5 -2
- package/src/pramen.ts +9 -1
- package/src/runtime/data-migrations.ts +343 -0
- package/src/sdk/acl.ts +13 -5
- package/src/sdk/handlers.ts +43 -0
- package/src/sdk/iso-timestamps.ts +146 -0
- package/src/sdk/schema.ts +41 -4
- package/src/worker.ts +89 -1
package/dist/worker.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity } from "./auth";
|
|
7
7
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
8
8
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
9
|
+
import { appliedMigrations, runDataMigrations } from "./runtime/data-migrations";
|
|
9
10
|
import { createMail } from "./runtime/mail";
|
|
10
11
|
import { createQueue } from "./runtime/queue";
|
|
11
12
|
import { dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
@@ -169,6 +170,42 @@ export function makeWorker(app) {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
};
|
|
173
|
+
// The mirror of the DO's runDataMigrations() for the D1 store, run once per isolate after
|
|
174
|
+
// migration. It diverges in one deliberate way: it runs EVERY declared migration whatever
|
|
175
|
+
// partition it names, because D1 is ONE shared database with no partition split — every
|
|
176
|
+
// entity's table lives in it, so a migration declared for "audit" has real rows to touch
|
|
177
|
+
// here and would otherwise be permanently unrunnable on this store. Each is still recorded
|
|
178
|
+
// under its OWN declared partition key, so a ledger read is comparable across stores.
|
|
179
|
+
//
|
|
180
|
+
// Errors are NOT swallowed (unlike runBootstrapD1): the .catch in ensureD1Migrated clears
|
|
181
|
+
// `d1Ready`, so a failed migration fails this request and is retried on the next one —
|
|
182
|
+
// the same fail-closed contract as the DO path.
|
|
183
|
+
//
|
|
184
|
+
// ATOMICITY CAVEAT: D1's `transaction(fn)` is `fn()` (no interactive transactions), so
|
|
185
|
+
// here the claim and the work do NOT commit together. The runner claims the ledger row
|
|
186
|
+
// before running (which is what keeps two cold isolates from both applying the same
|
|
187
|
+
// backfill — `d1Ready` is per-isolate and there is no single writer) and releases it on a
|
|
188
|
+
// throw, so a failed migration leaves partial writes and re-runs. Write SQL that tolerates
|
|
189
|
+
// that (`WHERE col IS NULL`) when the D1 store is in play.
|
|
190
|
+
const runDataMigrationsD1 = async (driver) => {
|
|
191
|
+
const migrations = app.migrations;
|
|
192
|
+
if (!migrations?.length)
|
|
193
|
+
return;
|
|
194
|
+
const db = new Db(driver, { acl: d1Acl, identity: { roles: ["admin"] }, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
195
|
+
await runDataMigrations(driver, migrations, {
|
|
196
|
+
// No `partition` — see the "runs every declared migration" note above.
|
|
197
|
+
makeContext: (partition) => ({ db, driver, schema: app.schema, partition }),
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
/** Partitions an admin route may address: the schema's, the default (always addressable),
|
|
201
|
+
* and any a data migration declares. Computed once — the app is static. */
|
|
202
|
+
let knownPartitionsCache;
|
|
203
|
+
const knownPartitions = () => {
|
|
204
|
+
if (!knownPartitionsCache) {
|
|
205
|
+
knownPartitionsCache = new Set([DEFAULT_PARTITION, ...partitionsOf(app.schema), ...(app.migrations ?? []).map((m) => m.partition ?? DEFAULT_PARTITION)]);
|
|
206
|
+
}
|
|
207
|
+
return knownPartitionsCache;
|
|
208
|
+
};
|
|
172
209
|
let d1Ready;
|
|
173
210
|
/** Run one handler against the D1 store, in the Worker. The request path and the
|
|
174
211
|
* PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
|
|
@@ -214,6 +251,7 @@ export function makeWorker(app) {
|
|
|
214
251
|
const ensureD1Migrated = (driver, allowDestructive) => {
|
|
215
252
|
if (!d1Ready) {
|
|
216
253
|
d1Ready = migrate(driver, app.schema, { allowDestructive })
|
|
254
|
+
.then(() => runDataMigrationsD1(driver)) // imperative, recorded backfills (fail closed)
|
|
217
255
|
.then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
|
|
218
256
|
.then(() => runBootstrapD1(driver)) // converge code-defined reference data
|
|
219
257
|
.then(() => undefined)
|
|
@@ -271,6 +309,26 @@ export function makeWorker(app) {
|
|
|
271
309
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
272
310
|
return listTasks(driver, { status, limit });
|
|
273
311
|
};
|
|
312
|
+
/** Read the data-migration ledger straight from D1 in the Worker (there is no DO on this
|
|
313
|
+
* path), shaped exactly like the DO's /__migrations answer so the CLI can't tell them
|
|
314
|
+
* apart. The D1 ledger holds every partition's rows; filter to the one asked for.
|
|
315
|
+
*
|
|
316
|
+
* Strictly READ-ONLY: deliberately NOT ensureD1Migrated / ensureMigrationsTable. Booting
|
|
317
|
+
* the store here would apply every pending backfill as a side effect of asking about them,
|
|
318
|
+
* so `migrations status` could never report PENDING — which is the only question the
|
|
319
|
+
* command exists to answer. An absent ledger table is simply "none applied". */
|
|
320
|
+
const listD1Migrations = async (env, tenant, partition) => {
|
|
321
|
+
if (!env.DB)
|
|
322
|
+
throw new BadRequest("D1 store is not configured");
|
|
323
|
+
// The same COMMINGLING GUARD as every other D1 entry point: one shared database with no
|
|
324
|
+
// tenant column, so reporting it as some specific tenant's ledger requires the opt-in.
|
|
325
|
+
if (tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
|
|
326
|
+
throw new Forbidden(`D1 store for tenant '${tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
|
|
327
|
+
}
|
|
328
|
+
const driver = new D1Driver(env.DB, { start: "first-primary" });
|
|
329
|
+
const rows = await appliedMigrations(driver, partition).catch(() => []);
|
|
330
|
+
return { partition, applied: rows.map((r) => ({ id: r.id, appliedAt: r.appliedAt })) };
|
|
331
|
+
};
|
|
274
332
|
return {
|
|
275
333
|
async fetch(request, env, ctx) {
|
|
276
334
|
const url = new URL(request.url);
|
|
@@ -397,6 +455,35 @@ export function makeWorker(app) {
|
|
|
397
455
|
const res = await stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition } }));
|
|
398
456
|
return withCors(res, cors);
|
|
399
457
|
}
|
|
458
|
+
// --- admin: which data migrations a tenant has applied. The fleet-wide "is it safe to
|
|
459
|
+
// prune this id?" signal — a cold tenant is unmigrated until touched, so nothing else
|
|
460
|
+
// can answer it. `x-pramen-store: d1` reads the Worker's shared ledger instead. ---
|
|
461
|
+
if (url.pathname === "/admin/migrations") {
|
|
462
|
+
if (!isAdmin(identity))
|
|
463
|
+
return withCors(forbidden("migrations"), cors);
|
|
464
|
+
const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
|
|
465
|
+
const tenant = url.searchParams.get("tenant") ?? "main";
|
|
466
|
+
// `partition` is caller-supplied and reaches partitionStubFor, which INSTANTIATES a
|
|
467
|
+
// DO — the same reason /live validates it: without this an admin typo mints a junk DO
|
|
468
|
+
// and a permanent registry key for a partition nothing lives in. A migration may
|
|
469
|
+
// declare a partition of its own, so accept those too.
|
|
470
|
+
if (!knownPartitions().has(partition))
|
|
471
|
+
return withCors(badRequest(`unknown partition '${partition}'`), cors);
|
|
472
|
+
try {
|
|
473
|
+
if (request.headers.get("x-pramen-store") === "d1") {
|
|
474
|
+
return withCors(json({ ok: true, result: await listD1Migrations(env, tenant, partition) }), cors);
|
|
475
|
+
}
|
|
476
|
+
const stub = partitionStubFor(env, tenant, partition);
|
|
477
|
+
const res = await stub.fetch(new Request("https://do/__migrations", { headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition } }));
|
|
478
|
+
return withCors(res, cors);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
// The DO branch throws a plain Error on a D1-ONLY deployment (no PRAMEN binding).
|
|
482
|
+
// Uncaught that is an opaque 500; surfaced, it's the actionable "pin the D1 store".
|
|
483
|
+
const { status, body } = toResponse(err);
|
|
484
|
+
return withCors(json(body, status), cors);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
400
487
|
// --- admin: generic data ops over a tenant's tables (browse/edit any row).
|
|
401
488
|
// Body: { tenant, table, op: list|get|create|update|delete|count, ... }. Runs
|
|
402
489
|
// in the DO under SYSTEM scope (ACL bypassed) — gated to admins here. ---
|
|
@@ -475,7 +562,7 @@ export function makeWorker(app) {
|
|
|
475
562
|
"Header X-Pramen-Tenant selects the store (default: main). " +
|
|
476
563
|
"Admin (optional partition selects the partition DO, default: " + DEFAULT_PARTITION + "): " +
|
|
477
564
|
"GET /tenants, POST /admin/recover {tenant,timestamp,partition?}, GET /admin/schema?tenant=&partition=, " +
|
|
478
|
-
"POST /admin/data {tenant,table,op,partition?}.\n", { headers: { "content-type": "text/plain" } });
|
|
565
|
+
"GET /admin/migrations?tenant=&partition=, POST /admin/data {tenant,table,op,partition?}.\n", { headers: { "content-type": "text/plain" } });
|
|
479
566
|
}
|
|
480
567
|
// Authorize the tenant against the identity before reaching the DO, so a
|
|
481
568
|
// caller can't address (or register) tenants they have no claim to.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.60",
|
|
4
4
|
"description": "pramen server runtime \u2014 schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// pramen schema snapshot save the current schema to .pramen/schema.json
|
|
11
11
|
// pramen schema diff compare the schema to the snapshot (safe vs unsafe changes)
|
|
12
12
|
// pramen schema status [--tenant t] [--url u] [--token jwt] compare a deployed tenant to the schema
|
|
13
|
+
// pramen migrations list declared data-migration ids, in order
|
|
14
|
+
// pramen migrations status applied vs pending, per tenant + partition
|
|
13
15
|
// pramen token <sub> [roles...] [--tenant a,b] mint a dev JWT
|
|
14
16
|
//
|
|
15
17
|
// The bin uses a `bun` shebang: the `schema *` commands import your app module (a .ts
|
|
@@ -22,7 +24,9 @@ import { dirname, resolve } from "node:path";
|
|
|
22
24
|
import { createTableSql } from "./runtime/ddl";
|
|
23
25
|
import { schemaHash } from "./runtime/migrate";
|
|
24
26
|
import { diffSchemaFingerprint, schemaFingerprint, type SchemaFingerprint } from "./runtime/schema-diff";
|
|
25
|
-
import { entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
|
|
27
|
+
import { DEFAULT_PARTITION, entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
|
|
28
|
+
import { migrationsForPartition } from "./runtime/data-migrations";
|
|
29
|
+
import type { DataMigration } from "./sdk/handlers";
|
|
26
30
|
import { signDevToken } from "./runtime/dev-token";
|
|
27
31
|
|
|
28
32
|
/** The dev JWT claims `pramen token` mints. See `runtime/dev-token.ts` for the signer. */
|
|
@@ -57,14 +61,21 @@ function fail(msg: string): never {
|
|
|
57
61
|
process.exit(1);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
|
|
64
|
+
/** The slice of an app module the CLI needs: the schema, plus the declared data
|
|
65
|
+
* migrations (`migrations list`/`status` read them; every other command ignores them). */
|
|
66
|
+
interface LoadedApp {
|
|
67
|
+
schema: SchemaDef;
|
|
68
|
+
migrations?: readonly DataMigration[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function loadApp(): Promise<LoadedApp> {
|
|
61
72
|
const explicit = flag("app");
|
|
62
73
|
const candidates = explicit ? [explicit] : ["./app.ts", "./example/app.ts"];
|
|
63
74
|
for (const c of candidates) {
|
|
64
75
|
const p = resolve(process.cwd(), c);
|
|
65
76
|
if (existsSync(p)) {
|
|
66
|
-
const mod = (await import(p)) as { app?:
|
|
67
|
-
if (mod.app?.schema) return mod.app
|
|
77
|
+
const mod = (await import(p)) as { app?: Partial<LoadedApp> };
|
|
78
|
+
if (mod.app?.schema) return { schema: mod.app.schema, migrations: mod.app.migrations };
|
|
68
79
|
fail(`${c} does not export { app }`);
|
|
69
80
|
}
|
|
70
81
|
}
|
|
@@ -83,6 +94,9 @@ Usage: pramen <command>
|
|
|
83
94
|
schema diff compare the schema to the snapshot (safe vs unsafe)
|
|
84
95
|
schema status compare a deployed tenant's schema to the local schema
|
|
85
96
|
[--tenant t] [--url u] [--token jwt]
|
|
97
|
+
migrations list declared data migration ids, in order (id + partition)
|
|
98
|
+
migrations status applied vs pending data migrations for a deployed tenant
|
|
99
|
+
[--tenant t] [--all-tenants] [--store d1] [--url u] [--token jwt]
|
|
86
100
|
token <sub> [roles...] mint a dev JWT [--tenant a,b]
|
|
87
101
|
|
|
88
102
|
Flags: --app <path> to point at your app module (default ./app.ts or ./example/app.ts).`;
|
|
@@ -200,6 +214,111 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
|
|
|
200
214
|
console.log(HELP);
|
|
201
215
|
}
|
|
202
216
|
|
|
217
|
+
/** One (tenant, partition) the ledger is read for. `--all-tenants` fans out over the
|
|
218
|
+
* registry's real pairs; otherwise the schema's partitions for one tenant. */
|
|
219
|
+
interface MigrationTarget {
|
|
220
|
+
tenant: string;
|
|
221
|
+
partition: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** GET the applied-migration ledger for one (tenant, partition). Exits non-zero rather
|
|
225
|
+
* than reporting a partial fleet — "not answered" must never read as "not applied". */
|
|
226
|
+
async function fetchApplied(
|
|
227
|
+
url: string,
|
|
228
|
+
token: string,
|
|
229
|
+
target: MigrationTarget,
|
|
230
|
+
store: string | undefined,
|
|
231
|
+
): Promise<{ id: string; appliedAt: string }[]> {
|
|
232
|
+
const qs = target.partition === DEFAULT_PARTITION ? "" : `&partition=${encodeURIComponent(target.partition)}`;
|
|
233
|
+
const headers = new Headers({ authorization: `Bearer ${token}` });
|
|
234
|
+
// The ledger lives in the Worker's shared D1 on that store — there is no DO to ask, and
|
|
235
|
+
// without this header the request routes to a DO binding a D1-only deploy doesn't have.
|
|
236
|
+
if (store === "d1") headers.set("x-pramen-store", "d1");
|
|
237
|
+
const res = await fetch(`${url}/admin/migrations?tenant=${encodeURIComponent(target.tenant)}${qs}`, {
|
|
238
|
+
headers,
|
|
239
|
+
}).catch((e: Error) => fail(`migrations status: cannot reach ${url} (${e.message})`));
|
|
240
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
241
|
+
ok?: boolean;
|
|
242
|
+
result?: { partition: string; applied: { id: string; appliedAt: string }[] };
|
|
243
|
+
error?: string;
|
|
244
|
+
};
|
|
245
|
+
if (!res.ok || !body.ok || !body.result) {
|
|
246
|
+
fail(`migrations status failed (tenant ${target.tenant}, partition ${target.partition}): ${body.error ?? res.status}`);
|
|
247
|
+
}
|
|
248
|
+
return body.result!.applied;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function migrationsCmd(sub: string | undefined): Promise<void> {
|
|
252
|
+
if (sub === "list") {
|
|
253
|
+
const { migrations } = await loadApp();
|
|
254
|
+
if (!migrations?.length) {
|
|
255
|
+
console.log("no migrations declared.");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
for (const m of migrations) console.log(`${m.id} (partition: ${m.partition ?? DEFAULT_PARTITION})`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (sub === "status") {
|
|
262
|
+
const { schema, migrations } = await loadApp();
|
|
263
|
+
const declared = migrations ?? [];
|
|
264
|
+
const url = flag("url") ?? "http://localhost:8787";
|
|
265
|
+
const token = flag("token") ?? (await sign({ sub: "cli", roles: ["admin"] }));
|
|
266
|
+
const store = flag("store");
|
|
267
|
+
if (store !== undefined && store !== "d1" && store !== "do") fail(`migrations status: --store must be "do" or "d1"`);
|
|
268
|
+
|
|
269
|
+
// --all-tenants asks the registry which (tenant, partition) DOs actually exist — the
|
|
270
|
+
// only way to answer "is it safe to prune this id?", since a tenant nobody has touched
|
|
271
|
+
// since the migration shipped is still unmigrated and no local artifact knows that.
|
|
272
|
+
let targets: MigrationTarget[];
|
|
273
|
+
if (argv.includes("--all-tenants")) {
|
|
274
|
+
const res = await fetch(`${url}/tenants`, { headers: { authorization: `Bearer ${token}` } }).catch(
|
|
275
|
+
(e: Error) => fail(`migrations status: cannot reach ${url} (${e.message})`),
|
|
276
|
+
);
|
|
277
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: MigrationTarget[]; error?: string };
|
|
278
|
+
if (!res.ok || !body.ok || !body.result) fail(`migrations status: /tenants failed: ${body.error ?? res.status}`);
|
|
279
|
+
targets = body.result!;
|
|
280
|
+
} else {
|
|
281
|
+
const tenant = flag("tenant") ?? "main";
|
|
282
|
+
// Union the schema's partitions with the ones the migrations themselves declare.
|
|
283
|
+
// partitionsOf() only returns partitions an ENTITY lives in, so an app whose every
|
|
284
|
+
// entity is partitioned would drop the default partition from the report — and a
|
|
285
|
+
// default-partition migration would silently never be listed, in the one command
|
|
286
|
+
// whose answer gates deleting it.
|
|
287
|
+
const declaredIn = declared.map((m) => m.partition ?? DEFAULT_PARTITION);
|
|
288
|
+
targets = [...new Set([...partitionsOf(schema), ...declaredIn])].map((partition) => ({ tenant, partition }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const target of targets) {
|
|
292
|
+
console.log(`\ntenant: ${target.tenant} partition: ${target.partition}`);
|
|
293
|
+
const applied = await fetchApplied(url, token, target, store);
|
|
294
|
+
const appliedAt = new Map(applied.map((a) => [a.id, a.appliedAt]));
|
|
295
|
+
const forHere = migrationsForPartition(declared, target.partition);
|
|
296
|
+
let pending = 0;
|
|
297
|
+
for (const m of forHere) {
|
|
298
|
+
const at = appliedAt.get(m.id);
|
|
299
|
+
if (at) console.log(` ✓ ${m.id} (applied ${at})`);
|
|
300
|
+
else {
|
|
301
|
+
console.log(` • ${m.id} PENDING`);
|
|
302
|
+
pending++;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// A ledger row with no declaration left in the codebase — the pruning question in
|
|
306
|
+
// reverse. Harmless (it stays applied), but it means this deploy no longer describes
|
|
307
|
+
// what ran, so a fresh tenant and this one are NOT converging on the same history.
|
|
308
|
+
const declaredIds = new Set(forHere.map((m) => m.id));
|
|
309
|
+
for (const a of applied) if (!declaredIds.has(a.id)) console.log(` ? ${a.id} applied but NOT DECLARED`);
|
|
310
|
+
console.log(` ${forHere.length - pending} applied, ${pending} pending`);
|
|
311
|
+
}
|
|
312
|
+
console.log(
|
|
313
|
+
"\nA migration is only safe to DELETE once EVERY live tenant reports it applied — the same trap as\n" +
|
|
314
|
+
"`renamedFrom`: migration is lazy and per-DO, so a tenant nobody has touched is still unmigrated\n" +
|
|
315
|
+
"and would silently skip the id if it were gone. Check with --all-tenants.",
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
console.log(HELP);
|
|
320
|
+
}
|
|
321
|
+
|
|
203
322
|
async function tokenCmd(args: string[]): Promise<void> {
|
|
204
323
|
const pos = positionals(args);
|
|
205
324
|
const sub = pos[0];
|
|
@@ -310,6 +429,8 @@ async function main(): Promise<void> {
|
|
|
310
429
|
return initCmd(argv.slice(1));
|
|
311
430
|
case "schema":
|
|
312
431
|
return schemaCmd(argv[1]);
|
|
432
|
+
case "migrations":
|
|
433
|
+
return migrationsCmd(argv[1]);
|
|
313
434
|
case "token":
|
|
314
435
|
return tokenCmd(argv.slice(1));
|
|
315
436
|
default:
|
package/src/durable-object.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
|
21
21
|
import { createMail } from "./runtime/mail";
|
|
22
22
|
import { createQueue } from "./runtime/queue";
|
|
23
23
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
24
|
+
import { appliedMigrations, runDataMigrations as runPendingDataMigrations } from "./runtime/data-migrations";
|
|
24
25
|
import { Db } from "./runtime/db";
|
|
25
26
|
import { digest } from "./runtime/digest";
|
|
26
27
|
import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
|
|
@@ -148,12 +149,38 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
148
149
|
await this.driver.transaction(() =>
|
|
149
150
|
migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => {}),
|
|
150
151
|
);
|
|
152
|
+
await this.runDataMigrations(); // imperative, recorded backfills for THIS partition
|
|
151
153
|
await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
|
|
152
154
|
await this.runBootstrap(); // converge code-defined reference data (default partition only)
|
|
153
155
|
this.migrated = true;
|
|
154
156
|
});
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// Run this partition's pending app.migrations once, right after migrate() (so a backfill
|
|
160
|
+
// sees the column ADD COLUMN just created) and before bootstrap. Unlike runBootstrap this
|
|
161
|
+
// is NOT default-partition-only: a partition's rows live in ITS DO, so each partition-DO
|
|
162
|
+
// runs its own partition's migrations against its own tables.
|
|
163
|
+
//
|
|
164
|
+
// It deliberately does NOT swallow errors, which is the one place it diverges from
|
|
165
|
+
// runBootstrap. A bootstrap reconciler is idempotent and retried every boot, so logging a
|
|
166
|
+
// failure and carrying on is safe; a data migration is a ONE-SHOT transformation of user
|
|
167
|
+
// rows, and swallowing a half-finished backfill would leave the store silently corrupt
|
|
168
|
+
// AND (via the ledger row it never wrote) look unmigrated forever. Failing closed brings
|
|
169
|
+
// the tenant's first fetch down instead, and the migration retries on the next one.
|
|
170
|
+
private async runDataMigrations(): Promise<void> {
|
|
171
|
+
const migrations = this.app.migrations;
|
|
172
|
+
if (!migrations?.length) return;
|
|
173
|
+
const db = new Db(
|
|
174
|
+
this.driver,
|
|
175
|
+
{ acl: this.acl, identity: { roles: ["admin"] }, system: true, partition: this.partition, schema: this.app.schema, suppressTriggers: true },
|
|
176
|
+
this.app.schema,
|
|
177
|
+
);
|
|
178
|
+
await runPendingDataMigrations(this.driver, migrations, {
|
|
179
|
+
partition: this.partition,
|
|
180
|
+
makeContext: (partition) => ({ db, driver: this.driver, schema: this.app.schema, partition }),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
// Run app.bootstrap() once per DO lifetime, right after migration and inside the same
|
|
158
185
|
// blockConcurrencyWhile block, so the first request sees a converged store and two
|
|
159
186
|
// concurrent first fetches can't double-run it. Default partition ONLY: reference data
|
|
@@ -187,6 +214,14 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
187
214
|
const partitionHeader = request.headers.get("x-pramen-partition");
|
|
188
215
|
if (partitionHeader) this.partition = partitionHeader;
|
|
189
216
|
|
|
217
|
+
// READ-ONLY PROBE, answered before the boot. Everything below this line migrates:
|
|
218
|
+
// ensureMigrated() runs migrate(), the data migrations and bootstrap. Answering the
|
|
219
|
+
// ledger after that would make `pramen migrations status` apply every pending backfill
|
|
220
|
+
// as a side effect of ASKING — it could never report PENDING, and `--all-tenants`
|
|
221
|
+
// (advertised as the read-only "is it safe to prune?" check) would silently migrate the
|
|
222
|
+
// whole fleet. Reads no table it might have to create; see handleMigrations.
|
|
223
|
+
if (new URL(request.url).pathname === "/__migrations") return this.handleMigrations();
|
|
224
|
+
|
|
190
225
|
await this.ensureMigrated();
|
|
191
226
|
await this.ensureRegistered(request);
|
|
192
227
|
// Persist (tenant, partition) once per instance so a cold alarm can rebuild the
|
|
@@ -583,6 +618,24 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
583
618
|
return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
|
|
584
619
|
}
|
|
585
620
|
|
|
621
|
+
// Introspection: which data migrations this partition has applied (admin-gated at the
|
|
622
|
+
// Worker). Powers the CLI's `migrations status` — the fleet-wide "is it safe to prune this
|
|
623
|
+
// id yet?" answer, which nothing else can give: a tenant nobody has touched since the
|
|
624
|
+
// migration shipped is still unmigrated, and the schema hash says nothing about backfills.
|
|
625
|
+
// Ensure the ledger exists first so a DO that has never applied one answers with [] rather
|
|
626
|
+
// than a missing-table error.
|
|
627
|
+
private async handleMigrations(): Promise<Response> {
|
|
628
|
+
// Strictly read-only — NOT ensureMigrationsTable(). A DO that has never applied a
|
|
629
|
+
// migration has no ledger table, and CREATE-ing one here would make the probe a write
|
|
630
|
+
// (and would boot an otherwise-untouched tenant's store just to answer a question about
|
|
631
|
+
// it). An absent table is simply "none applied".
|
|
632
|
+
const rows = await appliedMigrations(this.driver, this.partition).catch(() => []);
|
|
633
|
+
return Response.json({
|
|
634
|
+
ok: true,
|
|
635
|
+
result: { partition: this.partition, applied: rows.map((r) => ({ id: r.id, appliedAt: r.appliedAt })) },
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
586
639
|
// Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
|
|
587
640
|
// Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
|
|
588
641
|
// json/fileRef codec, transactions, and live-query broadcast still apply.
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// and codegen load an app.ts for its schema without dragging in the DO runtime.
|
|
9
9
|
|
|
10
10
|
// --- schema authoring ---
|
|
11
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
11
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, ISO_NOW_SQL, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
12
|
+
// The one-off rewrite that goes with `expr.now()` changing shape — see ./sdk/iso-timestamps.
|
|
13
|
+
export { isoTimestampBackfill, timestampColumns, LEGACY_NOW_SQL } from "./sdk/iso-timestamps";
|
|
14
|
+
export type { ExtraTimestampColumns, IsoTimestampBackfillOpts } from "./sdk/iso-timestamps";
|
|
12
15
|
export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
13
16
|
export { isValidUuid } from "./sdk/uuid";
|
|
14
17
|
export type {
|
|
@@ -31,7 +34,7 @@ export type {
|
|
|
31
34
|
// --- app + handlers ---
|
|
32
35
|
export { createApp } from "./sdk/app";
|
|
33
36
|
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
34
|
-
export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
|
|
37
|
+
export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn, MigrationContext, DataMigration } from "./sdk/handlers";
|
|
35
38
|
|
|
36
39
|
// --- ACL ---
|
|
37
40
|
export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
|
package/src/pramen.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
import { makeWorker, type Env } from "./worker";
|
|
16
16
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
17
17
|
import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
|
|
18
|
-
import
|
|
18
|
+
import { validateMigrations } from "./runtime/data-migrations";
|
|
19
|
+
import type { AppTaskMap, HandlerMap, BootstrapFn, DataMigration } from "./sdk/handlers";
|
|
19
20
|
import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
|
|
20
21
|
import type { Role } from "./sdk/acl";
|
|
21
22
|
import type { EnvBag } from "./sdk/handlers";
|
|
@@ -59,6 +60,12 @@ export interface PramenApp {
|
|
|
59
60
|
* code-defined reference data into the store (see `BootstrapFn`). Each runs with a
|
|
60
61
|
* privileged system Db; failures are logged, never fatal. */
|
|
61
62
|
bootstrap?: readonly BootstrapFn[];
|
|
63
|
+
/** Imperative DATA migrations — backfills, splits, normalizations — run in declaration
|
|
64
|
+
* order after `migrate()` and before `bootstrap`, each recorded ONCE per (id, partition)
|
|
65
|
+
* in `_pramen_migrations` (see `DataMigration`). The declarative migrator diffs shapes
|
|
66
|
+
* and so can only enact structure; this is the transformation half. Unlike `bootstrap`,
|
|
67
|
+
* a failure is NOT swallowed — it fails the boot closed and retries next fetch. */
|
|
68
|
+
migrations?: readonly DataMigration[];
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export type { Env, DoEnv };
|
|
@@ -73,6 +80,7 @@ export function createPramen(app: PramenApp): {
|
|
|
73
80
|
PramenDO: ReturnType<typeof pramenDO>;
|
|
74
81
|
} {
|
|
75
82
|
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
83
|
+
validateMigrations(app.schema, app.migrations); // fail fast on a duplicate/empty id or an unknown partition
|
|
76
84
|
const worker = makeWorker(app);
|
|
77
85
|
return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
|
|
78
86
|
}
|