@pramen/server 0.0.59 → 0.0.61
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/cli.js
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
|
|
@@ -21,7 +23,8 @@ import { dirname, resolve } from "node:path";
|
|
|
21
23
|
import { createTableSql } from "./runtime/ddl";
|
|
22
24
|
import { schemaHash } from "./runtime/migrate";
|
|
23
25
|
import { diffSchemaFingerprint, schemaFingerprint } from "./runtime/schema-diff";
|
|
24
|
-
import { entitiesInPartition, partitionsOf } from "./sdk/schema";
|
|
26
|
+
import { DEFAULT_PARTITION, entitiesInPartition, partitionsOf } from "./sdk/schema";
|
|
27
|
+
import { migrationsForPartition } from "./runtime/data-migrations";
|
|
25
28
|
import { signDevToken } from "./runtime/dev-token";
|
|
26
29
|
const sign = (payload) => signDevToken(payload);
|
|
27
30
|
/** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
|
|
@@ -57,7 +60,7 @@ async function loadApp() {
|
|
|
57
60
|
if (existsSync(p)) {
|
|
58
61
|
const mod = (await import(p));
|
|
59
62
|
if (mod.app?.schema)
|
|
60
|
-
return mod.app;
|
|
63
|
+
return { schema: mod.app.schema, migrations: mod.app.migrations };
|
|
61
64
|
fail(`${c} does not export { app }`);
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -75,6 +78,9 @@ Usage: pramen <command>
|
|
|
75
78
|
schema diff compare the schema to the snapshot (safe vs unsafe)
|
|
76
79
|
schema status compare a deployed tenant's schema to the local schema
|
|
77
80
|
[--tenant t] [--url u] [--token jwt]
|
|
81
|
+
migrations list declared data migration ids, in order (id + partition)
|
|
82
|
+
migrations status applied vs pending data migrations for a deployed tenant
|
|
83
|
+
[--tenant t] [--all-tenants] [--store d1] [--url u] [--token jwt]
|
|
78
84
|
token <sub> [roles...] mint a dev JWT [--tenant a,b]
|
|
79
85
|
|
|
80
86
|
Flags: --app <path> to point at your app module (default ./app.ts or ./example/app.ts).`;
|
|
@@ -179,6 +185,95 @@ async function schemaCmd(sub) {
|
|
|
179
185
|
}
|
|
180
186
|
console.log(HELP);
|
|
181
187
|
}
|
|
188
|
+
/** GET the applied-migration ledger for one (tenant, partition). Exits non-zero rather
|
|
189
|
+
* than reporting a partial fleet — "not answered" must never read as "not applied". */
|
|
190
|
+
async function fetchApplied(url, token, target, store) {
|
|
191
|
+
const qs = target.partition === DEFAULT_PARTITION ? "" : `&partition=${encodeURIComponent(target.partition)}`;
|
|
192
|
+
const headers = new Headers({ authorization: `Bearer ${token}` });
|
|
193
|
+
// The ledger lives in the Worker's shared D1 on that store — there is no DO to ask, and
|
|
194
|
+
// without this header the request routes to a DO binding a D1-only deploy doesn't have.
|
|
195
|
+
if (store === "d1")
|
|
196
|
+
headers.set("x-pramen-store", "d1");
|
|
197
|
+
const res = await fetch(`${url}/admin/migrations?tenant=${encodeURIComponent(target.tenant)}${qs}`, {
|
|
198
|
+
headers,
|
|
199
|
+
}).catch((e) => fail(`migrations status: cannot reach ${url} (${e.message})`));
|
|
200
|
+
const body = (await res.json().catch(() => ({})));
|
|
201
|
+
if (!res.ok || !body.ok || !body.result) {
|
|
202
|
+
fail(`migrations status failed (tenant ${target.tenant}, partition ${target.partition}): ${body.error ?? res.status}`);
|
|
203
|
+
}
|
|
204
|
+
return body.result.applied;
|
|
205
|
+
}
|
|
206
|
+
async function migrationsCmd(sub) {
|
|
207
|
+
if (sub === "list") {
|
|
208
|
+
const { migrations } = await loadApp();
|
|
209
|
+
if (!migrations?.length) {
|
|
210
|
+
console.log("no migrations declared.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const m of migrations)
|
|
214
|
+
console.log(`${m.id} (partition: ${m.partition ?? DEFAULT_PARTITION})`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (sub === "status") {
|
|
218
|
+
const { schema, migrations } = await loadApp();
|
|
219
|
+
const declared = migrations ?? [];
|
|
220
|
+
const url = flag("url") ?? "http://localhost:8787";
|
|
221
|
+
const token = flag("token") ?? (await sign({ sub: "cli", roles: ["admin"] }));
|
|
222
|
+
const store = flag("store");
|
|
223
|
+
if (store !== undefined && store !== "d1" && store !== "do")
|
|
224
|
+
fail(`migrations status: --store must be "do" or "d1"`);
|
|
225
|
+
// --all-tenants asks the registry which (tenant, partition) DOs actually exist — the
|
|
226
|
+
// only way to answer "is it safe to prune this id?", since a tenant nobody has touched
|
|
227
|
+
// since the migration shipped is still unmigrated and no local artifact knows that.
|
|
228
|
+
let targets;
|
|
229
|
+
if (argv.includes("--all-tenants")) {
|
|
230
|
+
const res = await fetch(`${url}/tenants`, { headers: { authorization: `Bearer ${token}` } }).catch((e) => fail(`migrations status: cannot reach ${url} (${e.message})`));
|
|
231
|
+
const body = (await res.json().catch(() => ({})));
|
|
232
|
+
if (!res.ok || !body.ok || !body.result)
|
|
233
|
+
fail(`migrations status: /tenants failed: ${body.error ?? res.status}`);
|
|
234
|
+
targets = body.result;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const tenant = flag("tenant") ?? "main";
|
|
238
|
+
// Union the schema's partitions with the ones the migrations themselves declare.
|
|
239
|
+
// partitionsOf() only returns partitions an ENTITY lives in, so an app whose every
|
|
240
|
+
// entity is partitioned would drop the default partition from the report — and a
|
|
241
|
+
// default-partition migration would silently never be listed, in the one command
|
|
242
|
+
// whose answer gates deleting it.
|
|
243
|
+
const declaredIn = declared.map((m) => m.partition ?? DEFAULT_PARTITION);
|
|
244
|
+
targets = [...new Set([...partitionsOf(schema), ...declaredIn])].map((partition) => ({ tenant, partition }));
|
|
245
|
+
}
|
|
246
|
+
for (const target of targets) {
|
|
247
|
+
console.log(`\ntenant: ${target.tenant} partition: ${target.partition}`);
|
|
248
|
+
const applied = await fetchApplied(url, token, target, store);
|
|
249
|
+
const appliedAt = new Map(applied.map((a) => [a.id, a.appliedAt]));
|
|
250
|
+
const forHere = migrationsForPartition(declared, target.partition);
|
|
251
|
+
let pending = 0;
|
|
252
|
+
for (const m of forHere) {
|
|
253
|
+
const at = appliedAt.get(m.id);
|
|
254
|
+
if (at)
|
|
255
|
+
console.log(` ✓ ${m.id} (applied ${at})`);
|
|
256
|
+
else {
|
|
257
|
+
console.log(` • ${m.id} PENDING`);
|
|
258
|
+
pending++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// A ledger row with no declaration left in the codebase — the pruning question in
|
|
262
|
+
// reverse. Harmless (it stays applied), but it means this deploy no longer describes
|
|
263
|
+
// what ran, so a fresh tenant and this one are NOT converging on the same history.
|
|
264
|
+
const declaredIds = new Set(forHere.map((m) => m.id));
|
|
265
|
+
for (const a of applied)
|
|
266
|
+
if (!declaredIds.has(a.id))
|
|
267
|
+
console.log(` ? ${a.id} applied but NOT DECLARED`);
|
|
268
|
+
console.log(` ${forHere.length - pending} applied, ${pending} pending`);
|
|
269
|
+
}
|
|
270
|
+
console.log("\nA migration is only safe to DELETE once EVERY live tenant reports it applied — the same trap as\n" +
|
|
271
|
+
"`renamedFrom`: migration is lazy and per-DO, so a tenant nobody has touched is still unmigrated\n" +
|
|
272
|
+
"and would silently skip the id if it were gone. Check with --all-tenants.");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
console.log(HELP);
|
|
276
|
+
}
|
|
182
277
|
async function tokenCmd(args) {
|
|
183
278
|
const pos = positionals(args);
|
|
184
279
|
const sub = pos[0];
|
|
@@ -287,6 +382,8 @@ async function main() {
|
|
|
287
382
|
return initCmd(argv.slice(1));
|
|
288
383
|
case "schema":
|
|
289
384
|
return schemaCmd(argv[1]);
|
|
385
|
+
case "migrations":
|
|
386
|
+
return migrationsCmd(argv[1]);
|
|
290
387
|
case "token":
|
|
291
388
|
return tokenCmd(argv.slice(1));
|
|
292
389
|
default:
|
package/dist/durable-object.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
46
46
|
private readonly subsBySocket;
|
|
47
47
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
|
|
48
48
|
private ensureMigrated;
|
|
49
|
+
private runDataMigrations;
|
|
49
50
|
private runBootstrap;
|
|
50
51
|
fetch(request: Request): Promise<Response>;
|
|
51
52
|
/** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
|
|
@@ -75,6 +76,7 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
75
76
|
private ensureRegistered;
|
|
76
77
|
private handleRecover;
|
|
77
78
|
private handleSchema;
|
|
79
|
+
private handleMigrations;
|
|
78
80
|
private handleAdminData;
|
|
79
81
|
private ctxFor;
|
|
80
82
|
private widenedEnv;
|
package/dist/durable-object.js
CHANGED
|
@@ -20,6 +20,7 @@ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
|
20
20
|
import { createMail } from "./runtime/mail";
|
|
21
21
|
import { createQueue } from "./runtime/queue";
|
|
22
22
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
23
|
+
import { appliedMigrations, runDataMigrations as runPendingDataMigrations } from "./runtime/data-migrations";
|
|
23
24
|
import { Db } from "./runtime/db";
|
|
24
25
|
import { digest } from "./runtime/digest";
|
|
25
26
|
import { compileAcl } from "./runtime/acl";
|
|
@@ -101,11 +102,33 @@ export class PramenDOBase extends DurableObject {
|
|
|
101
102
|
if (this.migrated)
|
|
102
103
|
return; // a concurrent first request already migrated
|
|
103
104
|
await this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => { }));
|
|
105
|
+
await this.runDataMigrations(); // imperative, recorded backfills for THIS partition
|
|
104
106
|
await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
|
|
105
107
|
await this.runBootstrap(); // converge code-defined reference data (default partition only)
|
|
106
108
|
this.migrated = true;
|
|
107
109
|
});
|
|
108
110
|
}
|
|
111
|
+
// Run this partition's pending app.migrations once, right after migrate() (so a backfill
|
|
112
|
+
// sees the column ADD COLUMN just created) and before bootstrap. Unlike runBootstrap this
|
|
113
|
+
// is NOT default-partition-only: a partition's rows live in ITS DO, so each partition-DO
|
|
114
|
+
// runs its own partition's migrations against its own tables.
|
|
115
|
+
//
|
|
116
|
+
// It deliberately does NOT swallow errors, which is the one place it diverges from
|
|
117
|
+
// runBootstrap. A bootstrap reconciler is idempotent and retried every boot, so logging a
|
|
118
|
+
// failure and carrying on is safe; a data migration is a ONE-SHOT transformation of user
|
|
119
|
+
// rows, and swallowing a half-finished backfill would leave the store silently corrupt
|
|
120
|
+
// AND (via the ledger row it never wrote) look unmigrated forever. Failing closed brings
|
|
121
|
+
// the tenant's first fetch down instead, and the migration retries on the next one.
|
|
122
|
+
async runDataMigrations() {
|
|
123
|
+
const migrations = this.app.migrations;
|
|
124
|
+
if (!migrations?.length)
|
|
125
|
+
return;
|
|
126
|
+
const db = new Db(this.driver, { acl: this.acl, identity: { roles: ["admin"] }, system: true, partition: this.partition, schema: this.app.schema, suppressTriggers: true }, this.app.schema);
|
|
127
|
+
await runPendingDataMigrations(this.driver, migrations, {
|
|
128
|
+
partition: this.partition,
|
|
129
|
+
makeContext: (partition) => ({ db, driver: this.driver, schema: this.app.schema, partition }),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
109
132
|
// Run app.bootstrap() once per DO lifetime, right after migration and inside the same
|
|
110
133
|
// blockConcurrencyWhile block, so the first request sees a converged store and two
|
|
111
134
|
// concurrent first fetches can't double-run it. Default partition ONLY: reference data
|
|
@@ -137,6 +160,14 @@ export class PramenDOBase extends DurableObject {
|
|
|
137
160
|
const partitionHeader = request.headers.get("x-pramen-partition");
|
|
138
161
|
if (partitionHeader)
|
|
139
162
|
this.partition = partitionHeader;
|
|
163
|
+
// READ-ONLY PROBE, answered before the boot. Everything below this line migrates:
|
|
164
|
+
// ensureMigrated() runs migrate(), the data migrations and bootstrap. Answering the
|
|
165
|
+
// ledger after that would make `pramen migrations status` apply every pending backfill
|
|
166
|
+
// as a side effect of ASKING — it could never report PENDING, and `--all-tenants`
|
|
167
|
+
// (advertised as the read-only "is it safe to prune?" check) would silently migrate the
|
|
168
|
+
// whole fleet. Reads no table it might have to create; see handleMigrations.
|
|
169
|
+
if (new URL(request.url).pathname === "/__migrations")
|
|
170
|
+
return this.handleMigrations();
|
|
140
171
|
await this.ensureMigrated();
|
|
141
172
|
await this.ensureRegistered(request);
|
|
142
173
|
// Persist (tenant, partition) once per instance so a cold alarm can rebuild the
|
|
@@ -508,6 +539,23 @@ export class PramenDOBase extends DurableObject {
|
|
|
508
539
|
const tables = byKey.has(tablesKey) ? JSON.parse(byKey.get(tablesKey)) : {};
|
|
509
540
|
return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
|
|
510
541
|
}
|
|
542
|
+
// Introspection: which data migrations this partition has applied (admin-gated at the
|
|
543
|
+
// Worker). Powers the CLI's `migrations status` — the fleet-wide "is it safe to prune this
|
|
544
|
+
// id yet?" answer, which nothing else can give: a tenant nobody has touched since the
|
|
545
|
+
// migration shipped is still unmigrated, and the schema hash says nothing about backfills.
|
|
546
|
+
// Ensure the ledger exists first so a DO that has never applied one answers with [] rather
|
|
547
|
+
// than a missing-table error.
|
|
548
|
+
async handleMigrations() {
|
|
549
|
+
// Strictly read-only — NOT ensureMigrationsTable(). A DO that has never applied a
|
|
550
|
+
// migration has no ledger table, and CREATE-ing one here would make the probe a write
|
|
551
|
+
// (and would boot an otherwise-untouched tenant's store just to answer a question about
|
|
552
|
+
// it). An absent table is simply "none applied".
|
|
553
|
+
const rows = await appliedMigrations(this.driver, this.partition).catch(() => []);
|
|
554
|
+
return Response.json({
|
|
555
|
+
ok: true,
|
|
556
|
+
result: { partition: this.partition, applied: rows.map((r) => ({ id: r.id, appliedAt: r.appliedAt })) },
|
|
557
|
+
});
|
|
558
|
+
}
|
|
511
559
|
// Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
|
|
512
560
|
// Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
|
|
513
561
|
// json/fileRef codec, transactions, and live-query broadcast still apply.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
1
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, ISO_NOW_SQL, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
2
|
+
export { isoTimestampBackfill, timestampColumns, LEGACY_NOW_SQL } from "./sdk/iso-timestamps";
|
|
3
|
+
export type { ExtraTimestampColumns, IsoTimestampBackfillOpts } from "./sdk/iso-timestamps";
|
|
2
4
|
export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
3
5
|
export { isValidUuid } from "./sdk/uuid";
|
|
4
6
|
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, ManyToManyDef, OneHasOneDef, OneHasOneInverseDef, OnDelete, } from "./sdk/schema";
|
|
5
7
|
export { createApp } from "./sdk/app";
|
|
6
8
|
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
7
|
-
export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
|
|
9
|
+
export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn, MigrationContext, DataMigration } from "./sdk/handlers";
|
|
8
10
|
export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
|
|
9
11
|
export type { Action, Identity, IdentityMarker, InputMarker, NowMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
10
12
|
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, JsonObject, SqlValue, CellValue, Row, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
|
|
8
8
|
// and codegen load an app.ts for its schema without dragging in the DO runtime.
|
|
9
9
|
// --- schema authoring ---
|
|
10
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
10
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, ISO_NOW_SQL, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
11
|
+
// The one-off rewrite that goes with `expr.now()` changing shape — see ./sdk/iso-timestamps.
|
|
12
|
+
export { isoTimestampBackfill, timestampColumns, LEGACY_NOW_SQL } from "./sdk/iso-timestamps";
|
|
11
13
|
export { isValidUuid } from "./sdk/uuid";
|
|
12
14
|
// --- app + handlers ---
|
|
13
15
|
export { createApp } from "./sdk/app";
|
package/dist/pramen.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Env } from "./worker";
|
|
2
2
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
3
3
|
import { type SchemaDef } from "./sdk/schema";
|
|
4
|
-
import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
|
|
4
|
+
import type { AppTaskMap, HandlerMap, BootstrapFn, DataMigration } from "./sdk/handlers";
|
|
5
5
|
import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
|
|
6
6
|
import type { Role } from "./sdk/acl";
|
|
7
7
|
import type { EnvBag } from "./sdk/handlers";
|
|
@@ -47,6 +47,12 @@ export interface PramenApp {
|
|
|
47
47
|
* code-defined reference data into the store (see `BootstrapFn`). Each runs with a
|
|
48
48
|
* privileged system Db; failures are logged, never fatal. */
|
|
49
49
|
bootstrap?: readonly BootstrapFn[];
|
|
50
|
+
/** Imperative DATA migrations — backfills, splits, normalizations — run in declaration
|
|
51
|
+
* order after `migrate()` and before `bootstrap`, each recorded ONCE per (id, partition)
|
|
52
|
+
* in `_pramen_migrations` (see `DataMigration`). The declarative migrator diffs shapes
|
|
53
|
+
* and so can only enact structure; this is the transformation half. Unlike `bootstrap`,
|
|
54
|
+
* a failure is NOT swallowed — it fails the boot closed and retries next fetch. */
|
|
55
|
+
migrations?: readonly DataMigration[];
|
|
50
56
|
}
|
|
51
57
|
export type { Env, DoEnv };
|
|
52
58
|
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
package/dist/pramen.js
CHANGED
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
import { makeWorker } from "./worker";
|
|
15
15
|
import { pramenDO } from "./durable-object";
|
|
16
16
|
import { validateTriggerTasks } from "./sdk/schema";
|
|
17
|
+
import { validateMigrations } from "./runtime/data-migrations";
|
|
17
18
|
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
18
19
|
* drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
|
|
19
20
|
* use the D1 store with deferred tasks. */
|
|
20
21
|
export function createPramen(app) {
|
|
21
22
|
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
23
|
+
validateMigrations(app.schema, app.migrations); // fail fast on a duplicate/empty id or an unknown partition
|
|
22
24
|
const worker = makeWorker(app);
|
|
23
25
|
return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
|
|
24
26
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type SchemaDef } from "../sdk/schema";
|
|
2
|
+
import type { DataMigration, MigrationContext } from "../sdk/handlers";
|
|
3
|
+
import type { Driver } from "./driver";
|
|
4
|
+
export declare const MIGRATIONS_TABLE = "_pramen_migrations";
|
|
5
|
+
/** One applied-migration ledger row. */
|
|
6
|
+
export interface AppliedMigration {
|
|
7
|
+
id: string;
|
|
8
|
+
partition: string;
|
|
9
|
+
/** ISO-8601 UTC instant the migration committed. */
|
|
10
|
+
appliedAt: string;
|
|
11
|
+
}
|
|
12
|
+
/** Create the ledger if absent. Idempotent — run on every boot before the runner and from
|
|
13
|
+
* the /__migrations endpoint (a DO that has never applied one still answers). Internal
|
|
14
|
+
* table (`_pramen_` prefix), so `isInternalTable()` keeps the migrator's hands off it.
|
|
15
|
+
*
|
|
16
|
+
* `partition` is a reserved-ish word in some engines, so every identifier goes through
|
|
17
|
+
* `dialect.id(...)` (the single `quoteIdent` source of truth) rather than being interpolated
|
|
18
|
+
* bare — here and in every query below. */
|
|
19
|
+
export declare function ensureMigrationsTable(driver: Driver): Promise<void>;
|
|
20
|
+
/** Ledger rows for one partition — or every partition when `partition` is omitted (the D1
|
|
21
|
+
* store, which holds one shared ledger for all of them). Oldest first. */
|
|
22
|
+
export declare function appliedMigrations(driver: Driver, partition?: string): Promise<AppliedMigration[]>;
|
|
23
|
+
/** The migrations that belong to `partition`: an entry with no declared partition belongs
|
|
24
|
+
* to the default one. Both boot paths select through this, so "which partition owns this
|
|
25
|
+
* migration" is answered in exactly one place. */
|
|
26
|
+
export declare function migrationsForPartition(migrations: readonly DataMigration[], partition: string): readonly DataMigration[];
|
|
27
|
+
export interface RunMigrationsResult {
|
|
28
|
+
/** Ids applied by THIS run, in the order they committed. */
|
|
29
|
+
applied: string[];
|
|
30
|
+
/** Ids already in the ledger (a previous boot applied them). */
|
|
31
|
+
skipped: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Lease timings. Defaults are deliberate, not arbitrary:
|
|
34
|
+
*
|
|
35
|
+
* - `ttlMs` must EXCEED the slowest migration, because a lease that expires under a holder
|
|
36
|
+
* still running is exactly the double-apply this whole mechanism prevents. 60s is above
|
|
37
|
+
* anything that can finish inside a Worker's limits at all, and the docs already require
|
|
38
|
+
* backfills to be bounded.
|
|
39
|
+
* - `waitMs` bounds how long a runner blocks on someone else's live lease before failing
|
|
40
|
+
* closed. Long enough for an ordinary backfill to land (so the waiter proceeds against
|
|
41
|
+
* fully-migrated data), short enough not to hold a request open.
|
|
42
|
+
* - `pollMs` is the re-check interval while waiting. */
|
|
43
|
+
export interface LeaseOpts {
|
|
44
|
+
ttlMs: number;
|
|
45
|
+
waitMs: number;
|
|
46
|
+
pollMs: number;
|
|
47
|
+
}
|
|
48
|
+
export declare const DEFAULT_LEASE: LeaseOpts;
|
|
49
|
+
export interface RunMigrationsOpts {
|
|
50
|
+
/** The partition to run: only its migrations are selected, and each ledger row is keyed
|
|
51
|
+
* by it. OMIT it on the D1 store — see `runDataMigrations` for why that store runs every
|
|
52
|
+
* declared migration regardless of partition. */
|
|
53
|
+
partition?: string;
|
|
54
|
+
/** Build the privileged context for one migration. Called per migration with the
|
|
55
|
+
* partition its ledger row will be keyed under, so a caller can scope `db` accordingly. */
|
|
56
|
+
makeContext: (partition: string) => MigrationContext;
|
|
57
|
+
/** Override the lease timings (see `LeaseOpts`) — for tests, and for a deployment whose
|
|
58
|
+
* backfills legitimately run long. Absent ⇒ `DEFAULT_LEASE`. */
|
|
59
|
+
lease?: Partial<LeaseOpts>;
|
|
60
|
+
}
|
|
61
|
+
/** Run every pending migration, in DECLARATION order, each inside its own
|
|
62
|
+
* `driver.transaction()`, CLAIMING its ledger row before it runs the work — so "the work
|
|
63
|
+
* happened" and "the work is recorded" cannot come apart on a substrate with real
|
|
64
|
+
* transactions (the DO), and cannot double-run on one without (D1). A migration whose claim
|
|
65
|
+
* loses the race (another isolate holds it, or a previous boot applied it) is skipped.
|
|
66
|
+
*
|
|
67
|
+
* D1 CAVEAT: `D1Driver.transaction(fn)` is `fn()` (D1 has no interactive transactions), so
|
|
68
|
+
* there the claim and the work do NOT commit together. A throw therefore RELEASES the claim
|
|
69
|
+
* explicitly (a compensating DELETE, issued inside the transaction so the DO simply rolls it
|
|
70
|
+
* back with everything else) — otherwise a failed migration would stay marked as applied,
|
|
71
|
+
* which is the one outcome the fail-closed contract exists to prevent. A holder that dies
|
|
72
|
+
* without reaching that DELETE is covered by the lease expiring instead. Prefer SQL that
|
|
73
|
+
* tolerates a re-run (`WHERE col IS NULL`) when the D1 store is in play: a mid-flight failure
|
|
74
|
+
* leaves the partial writes behind.
|
|
75
|
+
*
|
|
76
|
+
* Fails CLOSED: the first throw aborts the run, so migrations declared after it do not run
|
|
77
|
+
* either (order is a contract — a later one may depend on an earlier one's output). */
|
|
78
|
+
export declare function runDataMigrations(driver: Driver, migrations: readonly DataMigration[], opts: RunMigrationsOpts): Promise<RunMigrationsResult>;
|
|
79
|
+
/** Static validation, called from `createPramen` next to `validateTriggerTasks` — these are
|
|
80
|
+
* declaration bugs, and the only honest time to surface them is before a single tenant has
|
|
81
|
+
* booted. Throws on the first violation:
|
|
82
|
+
*
|
|
83
|
+
* - an empty id (the ledger key would be meaningless);
|
|
84
|
+
* - a duplicate id — ids are GLOBALLY unique across the array, not per partition, so a
|
|
85
|
+
* copy-pasted id can never quietly mark a different migration as already applied;
|
|
86
|
+
* - a declared `partition` no entity lives in — the migration would be dead code on the
|
|
87
|
+
* DO path (no DO serves that partition) while still running on D1. */
|
|
88
|
+
export declare function validateMigrations(schema: SchemaDef, migrations: readonly DataMigration[] | undefined): void;
|