@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
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Data migrations — the imperative, recorded half of schema evolution.
|
|
2
|
+
//
|
|
3
|
+
// `migrate()` (runtime/migrate.ts) is declarative: it diffs the live table shape against
|
|
4
|
+
// the declared schema and enacts the structural delta. A diff between two SHAPES can only
|
|
5
|
+
// ever express structure, never TRANSFORMATION — it cannot split `name` into
|
|
6
|
+
// `firstName`/`lastName`, backfill the nullable column `ADD COLUMN` just created (SQLite
|
|
7
|
+
// can't add NOT NULL to a populated table, so every new column starts as a hole), rewrite
|
|
8
|
+
// `priceHalers` → `priceCzk`, or normalize a `t.json()` blob whose shape changed.
|
|
9
|
+
//
|
|
10
|
+
// So: structure stays declarative, data becomes imperative, ordered, and RECORDED. Each
|
|
11
|
+
// `app.migrations` entry runs at most once per `(id, partition)`, its ledger row written in
|
|
12
|
+
// `_pramen_migrations` in the SAME transaction as the work. That is what makes a
|
|
13
|
+
// non-idempotent transformation safe, and it is precisely why `app.bootstrap` cannot be
|
|
14
|
+
// stretched to cover this (bootstrap runs on EVERY boot, must be idempotent, and swallows
|
|
15
|
+
// its errors).
|
|
16
|
+
//
|
|
17
|
+
// Fail closed. A throwing migration records no ledger row, does not let the boot complete,
|
|
18
|
+
// and propagates — the tenant's request fails and the migration is retried on the next
|
|
19
|
+
// fetch. This mirrors migrate()'s "withhold the schema hash on a skip" invariant: the store
|
|
20
|
+
// is never marked as having reached a state it did not reach.
|
|
21
|
+
//
|
|
22
|
+
// CLAIM FIRST, UNDER A LEASE. The ledger row is taken BEFORE the work — one atomic upsert
|
|
23
|
+
// that inserts a pending row, or steals one whose lease has expired, or does nothing — and
|
|
24
|
+
// the migration runs only if that statement won the row. On the DO this is merely bookkeeping
|
|
25
|
+
// order inside one transaction. On D1 it is what makes the once-only contract hold at all:
|
|
26
|
+
// there is no single writer and no interactive transaction, `d1Ready` is per-isolate, so two
|
|
27
|
+
// cold isolates racing a `SET n = n * 2` backfill would each read an empty ledger, each run
|
|
28
|
+
// it, and quadruple the data. The conflicting upsert is the lock.
|
|
29
|
+
//
|
|
30
|
+
// The row therefore has two states, distinguished by `leaseUntil`: IN FLIGHT (non-NULL — a
|
|
31
|
+
// runner holds it until that instant) and APPLIED (NULL). Only an applied row counts as
|
|
32
|
+
// applied, so an in-flight migration reads as pending everywhere, including the admin ledger.
|
|
33
|
+
//
|
|
34
|
+
// A runner that finds a LIVE lease does not skip — skipping would let it serve traffic
|
|
35
|
+
// against half-migrated data, and would strand the migration entirely if the holder then
|
|
36
|
+
// failed. It waits for the holder, up to WAIT_BUDGET_MS, and then either proceeds (the holder
|
|
37
|
+
// committed), takes the row (the holder released it), or fails closed. A holder that dies
|
|
38
|
+
// without releasing — an isolate evicted mid-backfill, which no compensating DELETE can
|
|
39
|
+
// cover — is recovered by the lease simply expiring.
|
|
40
|
+
//
|
|
41
|
+
// Written against the `Driver`/`Dialect` seam only (no `cloudflare:workers`), so the same
|
|
42
|
+
// runner drives the DO boot path and the D1/Worker path.
|
|
43
|
+
|
|
44
|
+
import { DEFAULT_PARTITION, partitionsOf, type SchemaDef } from "../sdk/schema";
|
|
45
|
+
import type { DataMigration, MigrationContext } from "../sdk/handlers";
|
|
46
|
+
import type { CellValue } from "../sdk/infer";
|
|
47
|
+
import type { Driver } from "./driver";
|
|
48
|
+
|
|
49
|
+
export const MIGRATIONS_TABLE = "_pramen_migrations";
|
|
50
|
+
|
|
51
|
+
/** One applied-migration ledger row. */
|
|
52
|
+
export interface AppliedMigration {
|
|
53
|
+
id: string;
|
|
54
|
+
partition: string;
|
|
55
|
+
/** ISO-8601 UTC instant the migration committed. */
|
|
56
|
+
appliedAt: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Create the ledger if absent. Idempotent — run on every boot before the runner and from
|
|
60
|
+
* the /__migrations endpoint (a DO that has never applied one still answers). Internal
|
|
61
|
+
* table (`_pramen_` prefix), so `isInternalTable()` keeps the migrator's hands off it.
|
|
62
|
+
*
|
|
63
|
+
* `partition` is a reserved-ish word in some engines, so every identifier goes through
|
|
64
|
+
* `dialect.id(...)` (the single `quoteIdent` source of truth) rather than being interpolated
|
|
65
|
+
* bare — here and in every query below. */
|
|
66
|
+
export async function ensureMigrationsTable(driver: Driver): Promise<void> {
|
|
67
|
+
const d = driver.dialect;
|
|
68
|
+
const t = d.id(MIGRATIONS_TABLE);
|
|
69
|
+
await driver.exec(
|
|
70
|
+
`CREATE TABLE IF NOT EXISTS ${t} (` +
|
|
71
|
+
`${d.id("id")} TEXT NOT NULL, ${d.id("partition")} TEXT NOT NULL, ${d.id("appliedAt")} TEXT NOT NULL, ` +
|
|
72
|
+
`${d.id("leaseUntil")} INTEGER, PRIMARY KEY (${d.id("id")}, ${d.id("partition")}))`,
|
|
73
|
+
[],
|
|
74
|
+
);
|
|
75
|
+
// `leaseUntil` was added after the table's first shape. Probe for it with a zero-row SELECT
|
|
76
|
+
// rather than PRAGMA table_info: the PRAGMA is what workerd's DO SQLite authorizer starts
|
|
77
|
+
// rejecting (SQLITE_AUTH) once the alarm API has run in the object, and this runs on every
|
|
78
|
+
// boot. Adding a nullable column is a plain ALTER — no rebuild, and existing rows read as
|
|
79
|
+
// applied, which is what they are.
|
|
80
|
+
const hasLease = await driver
|
|
81
|
+
.exec(`SELECT ${d.id("leaseUntil")} FROM ${t} LIMIT 0`, [])
|
|
82
|
+
.then(() => true)
|
|
83
|
+
.catch(() => false);
|
|
84
|
+
if (!hasLease) await driver.exec(`ALTER TABLE ${t} ADD COLUMN ${d.id("leaseUntil")} INTEGER`, []);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Ledger rows for one partition — or every partition when `partition` is omitted (the D1
|
|
88
|
+
* store, which holds one shared ledger for all of them). Oldest first. */
|
|
89
|
+
export async function appliedMigrations(driver: Driver, partition?: string): Promise<AppliedMigration[]> {
|
|
90
|
+
const d = driver.dialect;
|
|
91
|
+
// `leaseUntil IS NULL` is the definition of applied: a row a runner is still holding is
|
|
92
|
+
// in flight, and must read as PENDING everywhere — the admin ledger and the CLI included.
|
|
93
|
+
const scope = partition === undefined ? "" : ` AND ${d.id("partition")} = ${d.placeholder(1)}`;
|
|
94
|
+
const where = ` WHERE ${d.id("leaseUntil")} IS NULL${scope}`;
|
|
95
|
+
const rows = await driver.exec(
|
|
96
|
+
`SELECT ${d.id("id")}, ${d.id("partition")}, ${d.id("appliedAt")} FROM ${d.id(MIGRATIONS_TABLE)}${where} ` +
|
|
97
|
+
`ORDER BY ${d.id("appliedAt")}, ${d.id("id")}`,
|
|
98
|
+
partition === undefined ? [] : [d.encode(partition)],
|
|
99
|
+
);
|
|
100
|
+
return rows.map((r) => ({ id: String(r.id), partition: String(r.partition), appliedAt: String(r.appliedAt) }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The migrations that belong to `partition`: an entry with no declared partition belongs
|
|
104
|
+
* to the default one. Both boot paths select through this, so "which partition owns this
|
|
105
|
+
* migration" is answered in exactly one place. */
|
|
106
|
+
export function migrationsForPartition(
|
|
107
|
+
migrations: readonly DataMigration[],
|
|
108
|
+
partition: string,
|
|
109
|
+
): readonly DataMigration[] {
|
|
110
|
+
return migrations.filter((m) => (m.partition ?? DEFAULT_PARTITION) === partition);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RunMigrationsResult {
|
|
114
|
+
/** Ids applied by THIS run, in the order they committed. */
|
|
115
|
+
applied: string[];
|
|
116
|
+
/** Ids already in the ledger (a previous boot applied them). */
|
|
117
|
+
skipped: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Lease timings. Defaults are deliberate, not arbitrary:
|
|
121
|
+
*
|
|
122
|
+
* - `ttlMs` must EXCEED the slowest migration, because a lease that expires under a holder
|
|
123
|
+
* still running is exactly the double-apply this whole mechanism prevents. 60s is above
|
|
124
|
+
* anything that can finish inside a Worker's limits at all, and the docs already require
|
|
125
|
+
* backfills to be bounded.
|
|
126
|
+
* - `waitMs` bounds how long a runner blocks on someone else's live lease before failing
|
|
127
|
+
* closed. Long enough for an ordinary backfill to land (so the waiter proceeds against
|
|
128
|
+
* fully-migrated data), short enough not to hold a request open.
|
|
129
|
+
* - `pollMs` is the re-check interval while waiting. */
|
|
130
|
+
export interface LeaseOpts {
|
|
131
|
+
ttlMs: number;
|
|
132
|
+
waitMs: number;
|
|
133
|
+
pollMs: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const DEFAULT_LEASE: LeaseOpts = { ttlMs: 60_000, waitMs: 5_000, pollMs: 100 };
|
|
137
|
+
|
|
138
|
+
export interface RunMigrationsOpts {
|
|
139
|
+
/** The partition to run: only its migrations are selected, and each ledger row is keyed
|
|
140
|
+
* by it. OMIT it on the D1 store — see `runDataMigrations` for why that store runs every
|
|
141
|
+
* declared migration regardless of partition. */
|
|
142
|
+
partition?: string;
|
|
143
|
+
/** Build the privileged context for one migration. Called per migration with the
|
|
144
|
+
* partition its ledger row will be keyed under, so a caller can scope `db` accordingly. */
|
|
145
|
+
makeContext: (partition: string) => MigrationContext;
|
|
146
|
+
/** Override the lease timings (see `LeaseOpts`) — for tests, and for a deployment whose
|
|
147
|
+
* backfills legitimately run long. Absent ⇒ `DEFAULT_LEASE`. */
|
|
148
|
+
lease?: Partial<LeaseOpts>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Run every pending migration, in DECLARATION order, each inside its own
|
|
152
|
+
* `driver.transaction()`, CLAIMING its ledger row before it runs the work — so "the work
|
|
153
|
+
* happened" and "the work is recorded" cannot come apart on a substrate with real
|
|
154
|
+
* transactions (the DO), and cannot double-run on one without (D1). A migration whose claim
|
|
155
|
+
* loses the race (another isolate holds it, or a previous boot applied it) is skipped.
|
|
156
|
+
*
|
|
157
|
+
* D1 CAVEAT: `D1Driver.transaction(fn)` is `fn()` (D1 has no interactive transactions), so
|
|
158
|
+
* there the claim and the work do NOT commit together. A throw therefore RELEASES the claim
|
|
159
|
+
* explicitly (a compensating DELETE, issued inside the transaction so the DO simply rolls it
|
|
160
|
+
* back with everything else) — otherwise a failed migration would stay marked as applied,
|
|
161
|
+
* which is the one outcome the fail-closed contract exists to prevent. A holder that dies
|
|
162
|
+
* without reaching that DELETE is covered by the lease expiring instead. Prefer SQL that
|
|
163
|
+
* tolerates a re-run (`WHERE col IS NULL`) when the D1 store is in play: a mid-flight failure
|
|
164
|
+
* leaves the partial writes behind.
|
|
165
|
+
*
|
|
166
|
+
* Fails CLOSED: the first throw aborts the run, so migrations declared after it do not run
|
|
167
|
+
* either (order is a contract — a later one may depend on an earlier one's output). */
|
|
168
|
+
export async function runDataMigrations(
|
|
169
|
+
driver: Driver,
|
|
170
|
+
migrations: readonly DataMigration[],
|
|
171
|
+
opts: RunMigrationsOpts,
|
|
172
|
+
): Promise<RunMigrationsResult> {
|
|
173
|
+
const applied: string[] = [];
|
|
174
|
+
const skipped: string[] = [];
|
|
175
|
+
const pending = opts.partition === undefined ? migrations : migrationsForPartition(migrations, opts.partition);
|
|
176
|
+
if (pending.length === 0) return { applied, skipped };
|
|
177
|
+
|
|
178
|
+
const lease = { ...DEFAULT_LEASE, ...opts.lease };
|
|
179
|
+
await ensureMigrationsTable(driver);
|
|
180
|
+
const done = new Set((await appliedMigrations(driver, opts.partition)).map((r) => ledgerKey(r.id, r.partition)));
|
|
181
|
+
|
|
182
|
+
for (const m of pending) {
|
|
183
|
+
const partition = m.partition ?? DEFAULT_PARTITION;
|
|
184
|
+
// The cheap pre-check: skip without opening a transaction for what a previous boot
|
|
185
|
+
// already applied (the common case — every boot after the first). `claim` is what
|
|
186
|
+
// actually decides; this only keeps the steady state free of no-op transactions.
|
|
187
|
+
if (done.has(ledgerKey(m.id, partition))) {
|
|
188
|
+
skipped.push(m.id);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
let ran = false;
|
|
192
|
+
try {
|
|
193
|
+
await driver.transaction(async () => {
|
|
194
|
+
// Take the row, waiting out any live lease. "applied" means another runner finished
|
|
195
|
+
// it while we waited — nothing left to do, and we now know the data IS migrated.
|
|
196
|
+
if ((await acquire(driver, m.id, partition, lease)) === "applied") return;
|
|
197
|
+
ran = true;
|
|
198
|
+
try {
|
|
199
|
+
await m.up(opts.makeContext(partition));
|
|
200
|
+
await complete(driver, m.id, partition);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
await release(driver, m.id, partition); // no-op on the DO (rolled back anyway)
|
|
203
|
+
throw e;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
// Name the migration in the message: the raw SQL error alone gives no clue WHICH of
|
|
208
|
+
// an ordered list failed, and the boot it aborted is the tenant's first request.
|
|
209
|
+
throw new Error(
|
|
210
|
+
`[pramen] data migration ${JSON.stringify(m.id)} failed (partition=${partition}): ` +
|
|
211
|
+
`${e instanceof Error ? e.message : String(e)}`,
|
|
212
|
+
{ cause: e },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!ran) {
|
|
216
|
+
skipped.push(m.id);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
console.log(`[pramen] data migration ${JSON.stringify(m.id)} applied (partition=${partition})`);
|
|
220
|
+
applied.push(m.id);
|
|
221
|
+
}
|
|
222
|
+
return { applied, skipped };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The ledger is keyed by (id, partition) — the same reason the schema hash is
|
|
226
|
+
* `schema_hash:<partition>`: partitions are independent DOs and must not thrash each
|
|
227
|
+
* other's state. NUL-joined, so no (id, partition) pair can collide with another. */
|
|
228
|
+
const ledgerKey = (id: string, partition: string): string => `${id}\u0000${partition}`;
|
|
229
|
+
|
|
230
|
+
/** What one attempt at taking the ledger row found. */
|
|
231
|
+
type ClaimOutcome = "claimed" | "applied" | "held";
|
|
232
|
+
|
|
233
|
+
/** ONE atomic statement that does all three things a claim must: insert the row when the
|
|
234
|
+
* migration is pending, STEAL it when a previous holder's lease has expired (that holder is
|
|
235
|
+
* presumed dead — an isolate evicted mid-backfill releases nothing), and do nothing when the
|
|
236
|
+
* row is applied or a live lease holds it. `RETURNING` fires only when a row was actually
|
|
237
|
+
* written, so it reports which happened. The upsert's `WHERE` is what confines the steal to
|
|
238
|
+
* an expired lease: without it this would be a plain overwrite and two live runners would
|
|
239
|
+
* both "win". */
|
|
240
|
+
async function tryClaim(driver: Driver, id: string, partition: string, now: number, ttlMs: number): Promise<ClaimOutcome> {
|
|
241
|
+
const d = driver.dialect;
|
|
242
|
+
const t = d.id(MIGRATIONS_TABLE);
|
|
243
|
+
const params: CellValue[] = [id, partition, new Date(now).toISOString(), now + ttlMs, now];
|
|
244
|
+
const rows = await driver.exec(
|
|
245
|
+
`INSERT INTO ${t} (${d.id("id")}, ${d.id("partition")}, ${d.id("appliedAt")}, ${d.id("leaseUntil")}) ` +
|
|
246
|
+
`VALUES (${d.placeholder(1)}, ${d.placeholder(2)}, ${d.placeholder(3)}, ${d.placeholder(4)}) ` +
|
|
247
|
+
`ON CONFLICT (${d.id("id")}, ${d.id("partition")}) DO UPDATE SET ` +
|
|
248
|
+
`${d.id("appliedAt")} = excluded.${d.id("appliedAt")}, ${d.id("leaseUntil")} = excluded.${d.id("leaseUntil")} ` +
|
|
249
|
+
`WHERE ${t}.${d.id("leaseUntil")} IS NOT NULL AND ${t}.${d.id("leaseUntil")} <= ${d.placeholder(5)} ` +
|
|
250
|
+
`RETURNING ${d.id("id")}`,
|
|
251
|
+
params.map((v) => d.encode(v)),
|
|
252
|
+
);
|
|
253
|
+
if (rows.length > 0) return "claimed";
|
|
254
|
+
// Nothing written — so the row exists and we did not qualify. Which of the two is it?
|
|
255
|
+
const [row] = await driver.exec(
|
|
256
|
+
`SELECT ${d.id("leaseUntil")} FROM ${t} WHERE ${d.id("id")} = ${d.placeholder(1)} AND ${d.id("partition")} = ${d.placeholder(2)}`,
|
|
257
|
+
[id, partition].map((v) => d.encode(v)),
|
|
258
|
+
);
|
|
259
|
+
// Gone between the two statements (a holder released it) — contended, not applied.
|
|
260
|
+
if (!row) return "held";
|
|
261
|
+
return row.leaseUntil == null ? "applied" : "held";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Take the row, waiting out a live lease. Returns "applied" when someone else finished it
|
|
265
|
+
* while we waited — in which case the caller must NOT run the migration, but CAN proceed
|
|
266
|
+
* knowing the data is migrated. That distinction is the point: skipping a live lease outright
|
|
267
|
+
* (the previous behavior) let a runner serve traffic against half-migrated data, and stranded
|
|
268
|
+
* the migration on that isolate entirely if the holder then failed.
|
|
269
|
+
*
|
|
270
|
+
* On the DO this never waits — one Durable Object is a single writer and the migration runs
|
|
271
|
+
* inside `blockConcurrencyWhile`, so a second concurrent first-fetch is queued by the platform
|
|
272
|
+
* rather than contending here. The wait exists for D1, where nothing serializes isolates. */
|
|
273
|
+
async function acquire(driver: Driver, id: string, partition: string, lease: LeaseOpts): Promise<"claimed" | "applied"> {
|
|
274
|
+
const deadline = Date.now() + lease.waitMs;
|
|
275
|
+
for (;;) {
|
|
276
|
+
const outcome = await tryClaim(driver, id, partition, Date.now(), lease.ttlMs);
|
|
277
|
+
if (outcome !== "held") return outcome;
|
|
278
|
+
if (Date.now() >= deadline) {
|
|
279
|
+
// Fail closed rather than guess. Another runner is mid-flight; the request retries and
|
|
280
|
+
// will usually find it applied. Serving on through a migration we know is unfinished is
|
|
281
|
+
// the one thing that must not happen.
|
|
282
|
+
throw new Error(`another runner holds the lease and did not finish within ${lease.waitMs}ms`);
|
|
283
|
+
}
|
|
284
|
+
await new Promise((r) => setTimeout(r, lease.pollMs));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Mark the migration applied: drop the lease (NULL is the definition of applied) and stamp
|
|
289
|
+
* the instant the work actually committed rather than the one the claim was taken. */
|
|
290
|
+
async function complete(driver: Driver, id: string, partition: string): Promise<void> {
|
|
291
|
+
const d = driver.dialect;
|
|
292
|
+
await driver.exec(
|
|
293
|
+
`UPDATE ${d.id(MIGRATIONS_TABLE)} SET ${d.id("appliedAt")} = ${d.placeholder(1)}, ${d.id("leaseUntil")} = NULL ` +
|
|
294
|
+
`WHERE ${d.id("id")} = ${d.placeholder(2)} AND ${d.id("partition")} = ${d.placeholder(3)}`,
|
|
295
|
+
[new Date().toISOString(), id, partition].map((v) => d.encode(v)),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Give the claim back after a failed `up()`, so the next runner retries immediately instead
|
|
300
|
+
* of waiting out the lease. Only observable where `transaction()` does not roll back (D1); on
|
|
301
|
+
* the DO the enclosing transaction discards this along with the claim itself. Guarded on
|
|
302
|
+
* `leaseUntil IS NOT NULL` so it can only ever delete an IN-FLIGHT row — never an applied one,
|
|
303
|
+
* whatever else has happened to the ledger in between. */
|
|
304
|
+
async function release(driver: Driver, id: string, partition: string): Promise<void> {
|
|
305
|
+
const d = driver.dialect;
|
|
306
|
+
await driver.exec(
|
|
307
|
+
`DELETE FROM ${d.id(MIGRATIONS_TABLE)} WHERE ${d.id("id")} = ${d.placeholder(1)} AND ${d.id("partition")} = ${d.placeholder(2)} ` +
|
|
308
|
+
`AND ${d.id("leaseUntil")} IS NOT NULL`,
|
|
309
|
+
[id, partition].map((v) => d.encode(v)),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Static validation, called from `createPramen` next to `validateTriggerTasks` — these are
|
|
314
|
+
* declaration bugs, and the only honest time to surface them is before a single tenant has
|
|
315
|
+
* booted. Throws on the first violation:
|
|
316
|
+
*
|
|
317
|
+
* - an empty id (the ledger key would be meaningless);
|
|
318
|
+
* - a duplicate id — ids are GLOBALLY unique across the array, not per partition, so a
|
|
319
|
+
* copy-pasted id can never quietly mark a different migration as already applied;
|
|
320
|
+
* - a declared `partition` no entity lives in — the migration would be dead code on the
|
|
321
|
+
* DO path (no DO serves that partition) while still running on D1. */
|
|
322
|
+
export function validateMigrations(schema: SchemaDef, migrations: readonly DataMigration[] | undefined): void {
|
|
323
|
+
if (!migrations?.length) return;
|
|
324
|
+
const known = new Set(partitionsOf(schema));
|
|
325
|
+
known.add(DEFAULT_PARTITION); // always addressable, even for a schema with no entities
|
|
326
|
+
const seen = new Set<string>();
|
|
327
|
+
for (const m of migrations) {
|
|
328
|
+
if (!m.id) throw new Error("app.migrations: every migration needs a non-empty, stable `id` (it is the ledger key).");
|
|
329
|
+
if (seen.has(m.id)) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`app.migrations: duplicate id ${JSON.stringify(m.id)}. Ids are the ledger key and must be globally ` +
|
|
332
|
+
`unique — a reused id makes one of the two silently a no-op.`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
seen.add(m.id);
|
|
336
|
+
if (m.partition !== undefined && !known.has(m.partition)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`app.migrations: migration ${JSON.stringify(m.id)} declares partition ${JSON.stringify(m.partition)}, ` +
|
|
339
|
+
`which no entity lives in. Known partitions: ${[...known].join(", ")}.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
package/src/sdk/acl.ts
CHANGED
|
@@ -81,11 +81,19 @@ export interface NowMarker {
|
|
|
81
81
|
* future timestamp is non-null, so a scheduled row would be readable the moment it
|
|
82
82
|
* is saved.
|
|
83
83
|
*
|
|
84
|
-
* Comparison is lexicographic TEXT, which is exact
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
84
|
+
* Comparison is lexicographic TEXT, which is exact WITHIN one format and wrong across two.
|
|
85
|
+
* Both `'YYYY-MM-DD HH:MM:SS'` and the ISO form open with the same date, so values on
|
|
86
|
+
* different dates still order correctly — it is the SAME date that breaks, where index 10
|
|
87
|
+
* decides and a space (0x20) always sorts below `T` (0x54). A space-form value dated today
|
|
88
|
+
* therefore compares as less than this marker whatever its time-of-day, so a row scheduled
|
|
89
|
+
* for later today reads as already past. The column must hold ISO-8601 UTC —
|
|
90
|
+
* `new Date().toISOString()`.
|
|
91
|
+
*
|
|
92
|
+
* `expr.now()` produces exactly that, so a column defaulted with it is directly comparable
|
|
93
|
+
* here. It did NOT always: it emitted the `datetime('now')` space form, and a policy over
|
|
94
|
+
* such a column was a time boundary that silently did not hold. A store written by a build
|
|
95
|
+
* from before that change still holds space-form values until
|
|
96
|
+
* `isoTimestampBackfill()` rewrites them. */
|
|
89
97
|
export function $now(): NowMarker {
|
|
90
98
|
return { [NOW_MARKER]: true };
|
|
91
99
|
}
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -82,6 +82,49 @@ export interface BootstrapContext<S extends SchemaDef = SchemaDef> {
|
|
|
82
82
|
* tenant's boot; it simply retries on the next boot. Set as `app.bootstrap`. */
|
|
83
83
|
export type BootstrapFn = (ctx: BootstrapContext) => void | Promise<void>;
|
|
84
84
|
|
|
85
|
+
/** Context handed to a data migration's `up()`. Same privileged, SYSTEM-scoped shape as
|
|
86
|
+
* `BootstrapContext` (ACL bypassed, triggers suppressed) but scoped to the migration's
|
|
87
|
+
* own `partition` — each partition-DO runs its own partition's migrations, so `db`/`driver`
|
|
88
|
+
* here address exactly that DO's tables. */
|
|
89
|
+
export interface MigrationContext<S extends SchemaDef = SchemaDef> {
|
|
90
|
+
/** System-scoped Db (ACL bypassed), triggers suppressed, scoped to `partition`. */
|
|
91
|
+
readonly db: Db<S>;
|
|
92
|
+
/** Raw driver — a bulk `UPDATE`/`INSERT … SELECT` is usually the right tool for a
|
|
93
|
+
* backfill; going row-by-row through `db` on a large table is what blows the DO's
|
|
94
|
+
* wall-clock budget. */
|
|
95
|
+
readonly driver: Driver;
|
|
96
|
+
readonly schema: S;
|
|
97
|
+
/** The partition this migration is running (and being recorded) under. */
|
|
98
|
+
readonly partition: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One imperative, ORDERED, recorded-once transformation of existing DATA — the half a
|
|
102
|
+
* declarative diff cannot express: split a column, backfill the nullable column `ADD COLUMN`
|
|
103
|
+
* just created, rewrite units, normalize a `t.json()` blob after its shape changed.
|
|
104
|
+
*
|
|
105
|
+
* The deliberate inverse of `BootstrapFn` on every axis that matters: it runs ONCE ever
|
|
106
|
+
* (recorded in `_pramen_migrations` keyed by `(id, partition)`), so it need NOT be
|
|
107
|
+
* idempotent — a backfill that would double a value on a second run is exactly what this
|
|
108
|
+
* exists for; and a throw is NOT swallowed. It fails CLOSED: no ledger row, the boot does
|
|
109
|
+
* not complete, the request fails, and the migration is retried on the tenant's next fetch.
|
|
110
|
+
* Silently marking a half-finished backfill as done is the one outcome worth bricking a
|
|
111
|
+
* boot to avoid.
|
|
112
|
+
*
|
|
113
|
+
* Runs after `migrate()` (so the column exists) and before `app.bootstrap`. There are no
|
|
114
|
+
* DOWN migrations. Set as `app.migrations`. */
|
|
115
|
+
export interface DataMigration {
|
|
116
|
+
/** Stable, unique, never reused — this is the ledger key. Deleting an id from the array
|
|
117
|
+
* does NOT un-apply it; and a cold tenant is unmigrated until touched, so an id can only
|
|
118
|
+
* be pruned once EVERY live tenant reports it applied (`pramen migrations status
|
|
119
|
+
* --all-tenants`). */
|
|
120
|
+
id: string;
|
|
121
|
+
/** Restrict to one partition; default = the default partition. Only that partition's DO
|
|
122
|
+
* runs it (its tables live in no other DO). The D1 store is one shared database with no
|
|
123
|
+
* partition split, so there every migration runs — still recorded under this key. */
|
|
124
|
+
partition?: string;
|
|
125
|
+
up(ctx: MigrationContext): void | Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
|
|
85
128
|
/** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
|
|
86
129
|
export interface Tasks {
|
|
87
130
|
/** Enqueue a task to run after commit. `kind` selects the `app.tasks` handler;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Rewriting timestamps written before `expr.now()` emitted ISO-8601.
|
|
2
|
+
//
|
|
3
|
+
// `expr.now()` used to emit `datetime('now')` — the `CURRENT_TIMESTAMP` space form,
|
|
4
|
+
// `'2026-09-03 21:33:07'`. It now emits `'2026-09-03T21:33:07.222Z'`. Changing a column's
|
|
5
|
+
// DEFAULT is a MODIFIER change, so `migrate()` rebuilds the table — and a rebuild copies
|
|
6
|
+
// existing values through untouched. New rows would get the new shape and old rows would
|
|
7
|
+
// keep the old one, in the same column.
|
|
8
|
+
//
|
|
9
|
+
// That is worse than either format alone, and the failure is narrower and nastier than
|
|
10
|
+
// "the column is inconsistent". Both forms open with the same `YYYY-MM-DD`, so for values
|
|
11
|
+
// on DIFFERENT dates the date prefix decides and ordering is still correct. It is values on
|
|
12
|
+
// the SAME date that break: index 10 is ` ` (0x20) in the space form and `T` (0x54) in ISO,
|
|
13
|
+
// so a same-day space-form value always sorts BEFORE a same-day ISO one whatever the
|
|
14
|
+
// time-of-day — 23:00 in the old shape sorts below 01:00 in the new.
|
|
15
|
+
//
|
|
16
|
+
// Which makes the deploy window itself the blast radius: the rows written just before and
|
|
17
|
+
// just after the change are exactly the same-day pairs that invert. And a `{ lte: $now() }`
|
|
18
|
+
// policy is the sharp version, because `$now()` is ISO: a space-form value dated today
|
|
19
|
+
// always compares as less than it, so a row scheduled for later TODAY reads as already
|
|
20
|
+
// published until midnight. Nothing reports any of it.
|
|
21
|
+
//
|
|
22
|
+
// So the format change comes with a rewrite, and the rewrite is a DATA migration: it is
|
|
23
|
+
// imperative, it must run exactly once, and it must fail closed. Declaring it is the app's
|
|
24
|
+
// job — this builds it.
|
|
25
|
+
|
|
26
|
+
import { DEFAULT_PARTITION, ISO_NOW_SQL, entitiesInPartition } from "./schema";
|
|
27
|
+
import type { DataMigration, MigrationContext } from "./handlers";
|
|
28
|
+
import type { FieldDef, SchemaDef } from "./schema";
|
|
29
|
+
|
|
30
|
+
/** The old default's SQL, for recognizing a column that carried it. */
|
|
31
|
+
export const LEGACY_NOW_SQL = "datetime('now')";
|
|
32
|
+
|
|
33
|
+
/** Columns to rewrite beyond the ones the schema identifies, as `{ table: [column, …] }`.
|
|
34
|
+
*
|
|
35
|
+
* The schema can only find columns whose DEFAULT is `expr.now()`. A column written by
|
|
36
|
+
* HANDLER code in the same space form — `@pramen/cms` stamped `cms_pages.publishedAt` that
|
|
37
|
+
* way — has no marker on it at all, so the app has to name it. `@pramen/cms` exports its
|
|
38
|
+
* own set as `CMS_LEGACY_TIMESTAMP_COLUMNS`. */
|
|
39
|
+
export type ExtraTimestampColumns = Readonly<Record<string, readonly string[]>>;
|
|
40
|
+
|
|
41
|
+
export interface IsoTimestampBackfillOpts {
|
|
42
|
+
/** Ledger id. Defaults to `pramen:iso-timestamps`. Override only if you have already
|
|
43
|
+
* used that id for something else — it is the key that makes this run once. */
|
|
44
|
+
id?: string;
|
|
45
|
+
/** Partition to run in. A partition-DO only sees its own tables, so an app with several
|
|
46
|
+
* partitions declares one of these per partition, each with its own id. */
|
|
47
|
+
partition?: string;
|
|
48
|
+
/** Columns the schema cannot identify — see {@link ExtraTimestampColumns}. */
|
|
49
|
+
extraColumns?: ExtraTimestampColumns;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A `DataMigration` that rewrites space-form timestamps to ISO-8601 in place.
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { isoTimestampBackfill } from "@pramen/server";
|
|
57
|
+
* import { CMS_LEGACY_TIMESTAMP_COLUMNS } from "@pramen/cms";
|
|
58
|
+
*
|
|
59
|
+
* export const app = {
|
|
60
|
+
* migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })],
|
|
61
|
+
* // …
|
|
62
|
+
* };
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* A **new** deployment can declare it too and pay nothing: every `UPDATE` matches no rows.
|
|
66
|
+
* Declaring it unconditionally is the cheaper habit, because "was this store ever written
|
|
67
|
+
* by an older build?" is not a question the code can answer later.
|
|
68
|
+
*
|
|
69
|
+
* It is not idempotent-by-accident, it is idempotent-by-shape: the `WHERE` matches only the
|
|
70
|
+
* space form, and what it writes is not the space form. That matters because a data
|
|
71
|
+
* migration is not required to be idempotent (it is recorded once) but this one runs over
|
|
72
|
+
* columns an app may also be writing, and a second pass must not corrupt a converted value.
|
|
73
|
+
*/
|
|
74
|
+
export function isoTimestampBackfill(opts: IsoTimestampBackfillOpts = {}): DataMigration {
|
|
75
|
+
const partition = opts.partition ?? DEFAULT_PARTITION;
|
|
76
|
+
return {
|
|
77
|
+
id: opts.id ?? "pramen:iso-timestamps",
|
|
78
|
+
partition,
|
|
79
|
+
async up(ctx: MigrationContext): Promise<void> {
|
|
80
|
+
for (const [table, columns] of timestampColumns(ctx.schema, partition, opts.extraColumns)) {
|
|
81
|
+
for (const column of columns) {
|
|
82
|
+
// A bulk UPDATE, not a walk: this runs inside `blockConcurrencyWhile` on a
|
|
83
|
+
// tenant's first fetch, so a row-by-row rewrite of a large table is what stalls
|
|
84
|
+
// that request and risks the DO's wall-clock limit.
|
|
85
|
+
//
|
|
86
|
+
// The predicate is deliberately exact rather than "does not look like ISO":
|
|
87
|
+
// length 19 with a space at index 11 (SQLite's `substr` is 1-based) is the space
|
|
88
|
+
// form and nothing else. A column holding some third format an app wrote by hand
|
|
89
|
+
// is left alone, because guessing at it would be a silent rewrite of data this
|
|
90
|
+
// migration does not understand.
|
|
91
|
+
await ctx.driver.exec(
|
|
92
|
+
`UPDATE ${q(table)} SET ${q(column)} = replace(${q(column)}, ' ', 'T') || '.000Z' ` +
|
|
93
|
+
`WHERE ${q(column)} IS NOT NULL AND length(${q(column)}) = 19 AND substr(${q(column)}, 11, 1) = ' '`,
|
|
94
|
+
[],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which `(table, column)` pairs this migration touches — the schema's `expr.now()` columns
|
|
104
|
+
* in `partition`, plus whatever the app named.
|
|
105
|
+
*
|
|
106
|
+
* Exported for the tests and for anyone who wants to see the list before running it. An
|
|
107
|
+
* extra column naming a table or column that is not in the schema is IGNORED rather than
|
|
108
|
+
* throwing: the list is a hand-written constant an app may keep across a schema change, and
|
|
109
|
+
* failing the whole boot over a stale entry in it would be a worse outcome than skipping it.
|
|
110
|
+
*/
|
|
111
|
+
export function timestampColumns(
|
|
112
|
+
schema: SchemaDef,
|
|
113
|
+
partition: string = DEFAULT_PARTITION,
|
|
114
|
+
extra: ExtraTimestampColumns = {},
|
|
115
|
+
): Map<string, string[]> {
|
|
116
|
+
const out = new Map<string, string[]>();
|
|
117
|
+
const inPartition = new Set(entitiesInPartition(schema, partition));
|
|
118
|
+
for (const table of inPartition) {
|
|
119
|
+
const fields = schema[table]?.fields as Record<string, FieldDef> | undefined;
|
|
120
|
+
if (!fields) continue;
|
|
121
|
+
const cols = Object.entries(fields)
|
|
122
|
+
// Both spellings: a store written by an older build has the legacy default recorded
|
|
123
|
+
// in its own DDL, and `migrate()` may not have rebuilt the table yet when this runs
|
|
124
|
+
// (it does — migrations run after — but the check costs nothing and makes the set
|
|
125
|
+
// independent of that ordering).
|
|
126
|
+
.filter(([, f]) => f.defaultExpr === ISO_NOW_SQL || f.defaultExpr === LEGACY_NOW_SQL)
|
|
127
|
+
.map(([name]) => name);
|
|
128
|
+
if (cols.length > 0) out.set(table, cols);
|
|
129
|
+
}
|
|
130
|
+
for (const [table, columns] of Object.entries(extra)) {
|
|
131
|
+
if (!inPartition.has(table)) continue;
|
|
132
|
+
const fields = schema[table]?.fields as Record<string, FieldDef> | undefined;
|
|
133
|
+
if (!fields) continue;
|
|
134
|
+
const existing = out.get(table) ?? [];
|
|
135
|
+
for (const column of columns) {
|
|
136
|
+
if (fields[column] && !existing.includes(column)) existing.push(column);
|
|
137
|
+
}
|
|
138
|
+
if (existing.length > 0) out.set(table, existing);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Double-quote an identifier for SQLite. Table and column names here come from the app's
|
|
144
|
+
* own schema, never from a request — but the SQL is built by concatenation, so they are
|
|
145
|
+
* quoted rather than trusted to be quote-free. */
|
|
146
|
+
const q = (ident: string): string => `"${ident.replace(/"/g, '""')}"`;
|
package/src/sdk/schema.ts
CHANGED
|
@@ -257,11 +257,48 @@ export class ExprDefault {
|
|
|
257
257
|
constructor(readonly sql: string) {}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
260
|
+
/**
|
|
261
|
+
* The SQL `expr.now()` emits: the current UTC instant as **ISO-8601 TEXT with
|
|
262
|
+
* milliseconds and a `Z`** — `'2026-09-03T21:33:07.222Z'`, byte-for-byte what
|
|
263
|
+
* `new Date().toISOString()` produces.
|
|
264
|
+
*
|
|
265
|
+
* Exported because three things have to agree on it and none of them can see the others:
|
|
266
|
+
* the DDL that writes the default, {@link isoTimestampBackfill} which rewrites rows
|
|
267
|
+
* written before this format, and the migration diff that decides a column's DEFAULT
|
|
268
|
+
* changed at all.
|
|
269
|
+
*/
|
|
270
|
+
export const ISO_NOW_SQL = "strftime('%Y-%m-%dT%H:%M:%fZ','now')";
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* SQL-expression defaults for `defaultTo(field, expr.now())`.
|
|
274
|
+
*
|
|
275
|
+
* `now()` is the current UTC instant as ISO-8601 TEXT (`'2026-09-03T21:33:07.222Z'`) —
|
|
276
|
+
* pair it with `t.text()`.
|
|
277
|
+
*
|
|
278
|
+
* **It used to emit `datetime('now')`**, the `CURRENT_TIMESTAMP` space form
|
|
279
|
+
* (`'2026-09-03 21:33:07'`). That form is wrong for this codebase in two ways that both
|
|
280
|
+
* fail silently:
|
|
281
|
+
*
|
|
282
|
+
* - It does not compare against `$now()`, which is an ISO string. Lexicographic TEXT
|
|
283
|
+
* comparison is exact within one format and wrong across two: both open with
|
|
284
|
+
* `YYYY-MM-DD`, so values on different dates still order correctly, but on the SAME
|
|
285
|
+
* date index 10 decides — a space (0x20) always sorts below `T` (0x54), whatever the
|
|
286
|
+
* time-of-day. So `{ publishedAt: { lte: $now() } }` over a space-form column matches
|
|
287
|
+
* any row dated today, including one scheduled for later today. A time boundary that
|
|
288
|
+
* silently does not hold, for a day at a time.
|
|
289
|
+
* - It is second-resolution, so two writes in the same second are indistinguishable and
|
|
290
|
+
* `ORDER BY createdAt` falls back to an arbitrary tiebreak. `%f` gives milliseconds.
|
|
291
|
+
*
|
|
292
|
+
* Changing it means a column's stored values change shape, so **existing rows must be
|
|
293
|
+
* rewritten** — a rebuild copies values through untouched and would leave the two formats
|
|
294
|
+
* mixed in one column, which is worse than either alone. {@link isoTimestampBackfill} is
|
|
295
|
+
* that rewrite; declare it in `app.migrations`.
|
|
296
|
+
*
|
|
297
|
+
* `raw(sql)` remains the escape hatch for any other SQLite default expression, including
|
|
298
|
+
* `expr.raw("datetime('now')")` if you deliberately want the old shape.
|
|
299
|
+
*/
|
|
263
300
|
export const expr = {
|
|
264
|
-
now: (): ExprDefault => new ExprDefault(
|
|
301
|
+
now: (): ExprDefault => new ExprDefault(ISO_NOW_SQL),
|
|
265
302
|
raw: (sql: string): ExprDefault => new ExprDefault(sql),
|
|
266
303
|
};
|
|
267
304
|
|