@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 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:
@@ -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;
@@ -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;