@pramen/server 0.0.14 → 0.0.15
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/auth.d.ts +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +119 -55
- package/dist/runtime/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +23 -4
- package/dist/runtime/migrate.js +213 -20
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +9 -2
- package/dist/runtime/queue-consumer.js +23 -6
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +44 -11
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +116 -52
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +21 -4
- package/src/runtime/migrate.ts +237 -20
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +24 -5
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/worker.ts +62 -11
package/dist/runtime/migrate.js
CHANGED
|
@@ -5,10 +5,30 @@
|
|
|
5
5
|
// Reconciles the live store with the declared schema in two passes:
|
|
6
6
|
// 1. additive (no data loss): missing table -> CREATE TABLE; missing column ->
|
|
7
7
|
// ALTER TABLE ADD COLUMN (nullable).
|
|
8
|
-
// 2.
|
|
9
|
-
// change is applied,
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// 2. reconcile existing columns + drop obsolete ones. A live column the schema no
|
|
9
|
+
// longer declares is DROPPED, a type change is applied, a `renamedFrom` column is
|
|
10
|
+
// renamed, and a MODIFIER change on an existing column (NOT NULL / DEFAULT /
|
|
11
|
+
// PRIMARY KEY) is enacted — all via the standard SQLite table-rebuild (create new,
|
|
12
|
+
// copy, drop old, rename); UNIQUE is reconciled with a CREATE/DROP INDEX. Each
|
|
13
|
+
// change is classified SAFE (loses no data — applied always: a DEFAULT add/change,
|
|
14
|
+
// dropping a constraint, adding NOT NULL when a backfill/default covers it, adding
|
|
15
|
+
// UNIQUE with no duplicates) or DESTRUCTIVE (GATED behind PRAMEN_ALLOW_DESTRUCTIVE,
|
|
16
|
+
// off by default: a drop, a type change, a rename, a PRIMARY KEY change, adding
|
|
17
|
+
// NOT NULL over NULL rows with no default). Adding UNIQUE over duplicate values is
|
|
18
|
+
// always SKIPPED (the index can't build). `hidden()`/`generated()` are ORM-only —
|
|
19
|
+
// no physical column change, so they don't appear here.
|
|
20
|
+
//
|
|
21
|
+
// The guiding invariant: the schema hash is recorded ONLY when the store fully matches
|
|
22
|
+
// the schema. Any detected change that is SKIPPED (destructive-gated, a UNIQUE-over-
|
|
23
|
+
// duplicates, or a partition MOVE — see below) leaves the hash UNWRITTEN, so `schema
|
|
24
|
+
// status` keeps reporting drift and a later opt-in / data-fixed deploy retries. This is
|
|
25
|
+
// what stops a modifier change (e.g. an unenforced NOT NULL) from silently diverging the
|
|
26
|
+
// store from the schema while the hash claims "in sync". Local dev sets the flag on; a
|
|
27
|
+
// bad deploy CAN then lose data (WIP, no backward-compat).
|
|
28
|
+
//
|
|
29
|
+
// A partition MOVE (an entity reassigned to a different Durable Object) is never auto-
|
|
30
|
+
// applied — the data can't cross DOs — so it's detected and reported as a skipped manual
|
|
31
|
+
// migration (hash withheld), leaving the source DO's data intact.
|
|
12
32
|
//
|
|
13
33
|
// A schema hash in the internal `_pramen_meta` table lets an unchanged schema skip
|
|
14
34
|
// introspection entirely on warm boots. The live table (PRAGMA) is the ground
|
|
@@ -17,10 +37,10 @@
|
|
|
17
37
|
// ADD COLUMN is always nullable (SQLite can't add NOT NULL to a populated table).
|
|
18
38
|
// A rename can't be inferred from a diff (a removed + added column is ambiguous),
|
|
19
39
|
// so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
|
|
20
|
-
import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
|
|
40
|
+
import { addColumnSql, createTableSql, defaultSqlValue, indexName, indexStatements, sqlType } from "./ddl";
|
|
21
41
|
import { digest } from "./digest";
|
|
22
42
|
import { quoteIdent } from "./driver";
|
|
23
|
-
import { entitiesInPartition, validateSchema } from "../sdk/schema";
|
|
43
|
+
import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
|
|
24
44
|
/** Internal bookkeeping tables the migrator must never touch — pramen's own, SQLite's,
|
|
25
45
|
* and the substrate's (D1 keeps `_cf_*` / `d1_*` tables in sqlite_master and forbids
|
|
26
46
|
* dropping them). Matched case-insensitively. */
|
|
@@ -44,6 +64,75 @@ async function tableColumns(driver, table) {
|
|
|
44
64
|
const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, []));
|
|
45
65
|
return new Map(rows.map((r) => [r.name, (r.type || "").toUpperCase()]));
|
|
46
66
|
}
|
|
67
|
+
/** Full live column info (type + modifiers) via PRAGMA table_info. Empty if absent. */
|
|
68
|
+
async function liveColumnInfo(driver, table) {
|
|
69
|
+
const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, []));
|
|
70
|
+
const out = new Map();
|
|
71
|
+
for (const r of rows) {
|
|
72
|
+
out.set(r.name, {
|
|
73
|
+
type: (r.type || "").toUpperCase(),
|
|
74
|
+
notNull: r.notnull === 1,
|
|
75
|
+
pk: r.pk > 0,
|
|
76
|
+
default: r.dflt_value ?? null,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** The columns backed by a single-column UNIQUE index (a `unique()` constraint). Reads
|
|
82
|
+
* PRAGMA index_list + index_info; multi-column indexes are ignored (pramen only emits
|
|
83
|
+
* single-column ones). */
|
|
84
|
+
async function liveUniqueColumns(driver, table) {
|
|
85
|
+
const idx = (await driver.exec(`PRAGMA index_list(${quoteIdent(table)})`, []));
|
|
86
|
+
const out = new Set();
|
|
87
|
+
for (const i of idx) {
|
|
88
|
+
if (i.unique !== 1)
|
|
89
|
+
continue;
|
|
90
|
+
const cols = (await driver.exec(`PRAGMA index_info(${quoteIdent(i.name)})`, []));
|
|
91
|
+
if (cols.length === 1 && cols[0]?.name)
|
|
92
|
+
out.add(cols[0].name);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Does the column currently hold any NULL? (Adding NOT NULL to such a column is
|
|
97
|
+
* unsafe without a backfill default.) */
|
|
98
|
+
async function columnHasNulls(driver, table, col) {
|
|
99
|
+
const rows = await driver.exec(`SELECT 1 FROM ${quoteIdent(table)} WHERE ${quoteIdent(col)} IS NULL LIMIT 1`, []);
|
|
100
|
+
return rows.length > 0;
|
|
101
|
+
}
|
|
102
|
+
/** Does the column hold a duplicate non-NULL value? (Adding UNIQUE to such a column
|
|
103
|
+
* can't build the index.) NULLs are never "equal" in SQLite, so they're excluded. */
|
|
104
|
+
async function columnHasDuplicates(driver, table, col) {
|
|
105
|
+
const rows = await driver.exec(`SELECT ${quoteIdent(col)} FROM ${quoteIdent(table)} WHERE ${quoteIdent(col)} IS NOT NULL GROUP BY ${quoteIdent(col)} HAVING COUNT(*) > 1 LIMIT 1`, []);
|
|
106
|
+
return rows.length > 0;
|
|
107
|
+
}
|
|
108
|
+
/** Normalize a DEFAULT's SQL text for comparison: trim, and strip balanced outer
|
|
109
|
+
* parens (SQLite reports an expr default with or without the wrapping parens the DDL
|
|
110
|
+
* emitted — `(datetime('now'))` vs `datetime('now')` — depending on the engine, so the
|
|
111
|
+
* comparison must not depend on them). */
|
|
112
|
+
function normalizeDefault(s) {
|
|
113
|
+
if (s == null)
|
|
114
|
+
return null;
|
|
115
|
+
let t = s.trim();
|
|
116
|
+
while (t.length >= 2 && t[0] === "(" && t[t.length - 1] === ")" && outerParensBalanced(t)) {
|
|
117
|
+
t = t.slice(1, -1).trim();
|
|
118
|
+
}
|
|
119
|
+
return t;
|
|
120
|
+
}
|
|
121
|
+
/** Does the leading `(` in `t` match the trailing `)` (i.e. is the whole string wrapped
|
|
122
|
+
* in one paren group)? Prevents stripping `(a) + (b)`. */
|
|
123
|
+
function outerParensBalanced(t) {
|
|
124
|
+
let depth = 0;
|
|
125
|
+
for (let i = 0; i < t.length; i++) {
|
|
126
|
+
if (t[i] === "(")
|
|
127
|
+
depth++;
|
|
128
|
+
else if (t[i] === ")") {
|
|
129
|
+
depth--;
|
|
130
|
+
if (depth === 0 && i !== t.length - 1)
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return depth === 0;
|
|
135
|
+
}
|
|
47
136
|
async function readMeta(driver, key) {
|
|
48
137
|
const rows = (await driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [key]));
|
|
49
138
|
return rows[0]?.value;
|
|
@@ -66,8 +155,16 @@ async function rebuildTable(driver, table, def, live) {
|
|
|
66
155
|
if (!src)
|
|
67
156
|
continue; // brand-new column with no source -> leave NULL
|
|
68
157
|
const target = sqlType(f);
|
|
158
|
+
let expr = live.get(src) === target ? quoteIdent(src) : `CAST(${quoteIdent(src)} AS ${target})`;
|
|
159
|
+
// Backfill NULLs when the target is NOT NULL and carries a default — makes adding
|
|
160
|
+
// NOT NULL to a column with NULL rows safe (the copy fills them from the default),
|
|
161
|
+
// instead of the INSERT failing the new NOT NULL constraint.
|
|
162
|
+
const notNull = !!f.notNull || !!f.primaryKey;
|
|
163
|
+
const dflt = defaultSqlValue(f);
|
|
164
|
+
if (notNull && dflt !== null)
|
|
165
|
+
expr = `COALESCE(${expr}, ${dflt})`;
|
|
69
166
|
destCols.push(quoteIdent(name));
|
|
70
|
-
srcExprs.push(
|
|
167
|
+
srcExprs.push(expr);
|
|
71
168
|
}
|
|
72
169
|
if (destCols.length > 0) {
|
|
73
170
|
await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
|
|
@@ -109,6 +206,9 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
109
206
|
const rebuilt = [];
|
|
110
207
|
const droppedTables = [];
|
|
111
208
|
const skipped = [];
|
|
209
|
+
// Per-table: columns whose new `unique()` can't be indexed (duplicate values) — the
|
|
210
|
+
// index pass must skip them so it doesn't throw. They're already reported in `skipped`.
|
|
211
|
+
const uniqueIndexSkip = new Map();
|
|
112
212
|
for (const [table, def] of entries) {
|
|
113
213
|
const existing = await tableColumns(driver, table);
|
|
114
214
|
if (existing.size === 0) {
|
|
@@ -132,9 +232,13 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
132
232
|
await driver.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${addColumnSql(name, field)}`, []);
|
|
133
233
|
added.push(`${table}.${name}`);
|
|
134
234
|
}
|
|
135
|
-
// Pass 2 —
|
|
136
|
-
// column changed type,
|
|
137
|
-
|
|
235
|
+
// Pass 2 — reconcile existing columns against the schema. Rebuild the table when a
|
|
236
|
+
// live column must be dropped, a declared column changed type, a rename hint points
|
|
237
|
+
// at an existing live column, or a MODIFIER changed on an existing column (NOT NULL,
|
|
238
|
+
// DEFAULT, PRIMARY KEY). UNIQUE is reconciled with an index (create/drop), no rebuild.
|
|
239
|
+
const liveInfo = await liveColumnInfo(driver, table); // re-read (includes additively-added columns)
|
|
240
|
+
const liveUnique = await liveUniqueColumns(driver, table);
|
|
241
|
+
const live = new Map([...liveInfo].map(([name, info]) => [name, info.type])); // name -> type
|
|
138
242
|
const desired = new Set(Object.keys(def.fields));
|
|
139
243
|
const renamedSources = new Set();
|
|
140
244
|
for (const f of Object.values(def.fields)) {
|
|
@@ -144,24 +248,112 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
144
248
|
}
|
|
145
249
|
const needsDrop = [...live.keys()].some((c) => !desired.has(c) && !renamedSources.has(c));
|
|
146
250
|
const needsTypeChange = Object.entries(def.fields).some(([n, f]) => live.has(n) && live.get(n) !== sqlType(f));
|
|
147
|
-
|
|
251
|
+
// Modifier reconciliation on existing (same-named) columns. A change to NOT NULL /
|
|
252
|
+
// DEFAULT / PRIMARY KEY is enacted by a table rebuild (which reconstructs the column
|
|
253
|
+
// to its exact declared shape); UNIQUE is an index op. Classify each as either a
|
|
254
|
+
// SAFE change (loses no data — applied always) or a DESTRUCTIVE one (gated behind
|
|
255
|
+
// allowDestructive). `hidden()`/`generated()` are ORM-only (no physical column
|
|
256
|
+
// change), so they never appear here.
|
|
257
|
+
let modifierRebuildSafe = false; // default add/change/remove, notNull widen, safe notNull add
|
|
258
|
+
let modifierRebuildDestructive = false; // notNull add over NULL rows w/o default, PK change
|
|
259
|
+
const destructiveReasons = [];
|
|
260
|
+
const dropUniqueCols = []; // `unique()` removed -> drop the managed index
|
|
261
|
+
for (const [name, field] of Object.entries(def.fields)) {
|
|
262
|
+
const info = liveInfo.get(name);
|
|
263
|
+
if (!info)
|
|
264
|
+
continue; // new column (Pass 1) or a rename target — not an existing column
|
|
265
|
+
const f = field;
|
|
266
|
+
const fieldPk = !!f.primaryKey;
|
|
267
|
+
const fieldNotNull = !!f.notNull || fieldPk;
|
|
268
|
+
// DEFAULT add / change / remove — a rebuild backfills existing rows and applies the
|
|
269
|
+
// new default going forward; no data loss.
|
|
270
|
+
if (normalizeDefault(defaultSqlValue(f)) !== normalizeDefault(info.default))
|
|
271
|
+
modifierRebuildSafe = true;
|
|
272
|
+
// NOT NULL — PRAGMA reports notnull=0 for a PRIMARY KEY column, so only compare on
|
|
273
|
+
// non-PK columns (PK-ness is compared separately below).
|
|
274
|
+
if (!fieldPk && !info.pk) {
|
|
275
|
+
if (fieldNotNull && !info.notNull) {
|
|
276
|
+
const backfillable = defaultSqlValue(f) !== null;
|
|
277
|
+
if (!backfillable && (await columnHasNulls(driver, table, name))) {
|
|
278
|
+
modifierRebuildDestructive = true;
|
|
279
|
+
destructiveReasons.push(`NOT NULL ${name} (NULL rows, no default)`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
modifierRebuildSafe = true; // no NULLs, or backfilled from the default
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (!fieldNotNull && info.notNull) {
|
|
286
|
+
modifierRebuildSafe = true; // dropping NOT NULL only widens — safe
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// PRIMARY KEY change — reshapes the table's key; treat as destructive.
|
|
290
|
+
if (fieldPk !== info.pk) {
|
|
291
|
+
modifierRebuildDestructive = true;
|
|
292
|
+
destructiveReasons.push(`PRIMARY KEY ${name}`);
|
|
293
|
+
}
|
|
294
|
+
// UNIQUE — reconciled with an index (create/drop), not a rebuild.
|
|
295
|
+
const fieldUnique = !!f.unique;
|
|
296
|
+
if (fieldUnique && !liveUnique.has(name)) {
|
|
297
|
+
// Added: safe only if no duplicate values exist; otherwise the index can't build.
|
|
298
|
+
if (await columnHasDuplicates(driver, table, name)) {
|
|
299
|
+
(uniqueIndexSkip.get(table) ?? uniqueIndexSkip.set(table, new Set()).get(table)).add(name);
|
|
300
|
+
skipped.push(`add UNIQUE ${table}.${name} (duplicate values present)`);
|
|
301
|
+
}
|
|
302
|
+
// else: the index pass creates it below (no rebuild needed).
|
|
303
|
+
}
|
|
304
|
+
else if (!fieldUnique && liveUnique.has(name)) {
|
|
305
|
+
dropUniqueCols.push(name); // drop the managed unique index (safe — no data loss)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const destructive = needsDrop || needsTypeChange || renamedSources.size > 0 || modifierRebuildDestructive;
|
|
148
309
|
if (destructive && !allowDestructive) {
|
|
149
|
-
// The destructive part is gated off — skip the whole rebuild (any pending
|
|
150
|
-
//
|
|
151
|
-
|
|
310
|
+
// The destructive part is gated off — skip the whole rebuild (any pending safe
|
|
311
|
+
// rebuild for this table waits until destructive migrations are allowed).
|
|
312
|
+
const reasons = [
|
|
313
|
+
...(needsDrop ? ["drop"] : []),
|
|
314
|
+
...(needsTypeChange ? ["type-change"] : []),
|
|
315
|
+
...(renamedSources.size > 0 ? ["rename"] : []),
|
|
316
|
+
...destructiveReasons,
|
|
317
|
+
];
|
|
318
|
+
skipped.push(`rebuild ${table} (${reasons.join(", ")})`);
|
|
152
319
|
}
|
|
153
|
-
else if (destructive || needsAdditiveRebuild) {
|
|
154
|
-
//
|
|
155
|
-
// it loses no data.
|
|
320
|
+
else if (destructive || needsAdditiveRebuild || modifierRebuildSafe) {
|
|
321
|
+
// A safe rebuild (expr-default column, default/notNull modifier change) needs no
|
|
322
|
+
// permission — it loses no data.
|
|
156
323
|
await rebuildTable(driver, table, def, live);
|
|
157
324
|
rebuilt.push(table);
|
|
158
325
|
}
|
|
326
|
+
// Drop the managed unique index for a column that no longer declares `unique()`. A
|
|
327
|
+
// rebuild already dropped every index (and the index pass won't recreate this one),
|
|
328
|
+
// so this only matters when no rebuild ran — DROP INDEX IF EXISTS is a safe no-op
|
|
329
|
+
// otherwise. No data loss either way.
|
|
330
|
+
for (const col of dropUniqueCols) {
|
|
331
|
+
await driver.exec(`DROP INDEX IF EXISTS ${quoteIdent(indexName(table, col))}`, []);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// A partition MOVE — an entity that was applied in THIS partition before but the
|
|
335
|
+
// current schema assigns to a DIFFERENT partition — is NOT auto-migratable: the data
|
|
336
|
+
// lives in this DO's SQLite and boot migration can't move it across DOs. Detect it
|
|
337
|
+
// (scoped path only; the unscoped/single-store path never strands data), report it as
|
|
338
|
+
// a skipped manual migration, and leave the table in place so its data is preserved
|
|
339
|
+
// for a hand-run migration. Leaving it in `skipped` also withholds the hash.
|
|
340
|
+
if (opts.partition !== undefined) {
|
|
341
|
+
const prevRaw = await readMeta(driver, tablesKey);
|
|
342
|
+
if (prevRaw) {
|
|
343
|
+
const prevApplied = JSON.parse(prevRaw);
|
|
344
|
+
for (const t of Object.keys(prevApplied)) {
|
|
345
|
+
if (!inScope.has(t) && t in schema && partitionOf(schema, t) !== opts.partition) {
|
|
346
|
+
skipped.push(`move partition ${t} (${opts.partition} → ${partitionOf(schema, t)}) — data stays in this DO; manual cross-DO migration required`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
159
350
|
}
|
|
160
351
|
// Ensure unique/index declarations (idempotent, via IF NOT EXISTS). Indexes can be
|
|
161
352
|
// added to an existing table without a rebuild; a stale index from a removed
|
|
162
|
-
// declaration is
|
|
353
|
+
// declaration is dropped above. A column whose new `unique()` has duplicate values is
|
|
354
|
+
// skipped (reported above) so this doesn't throw.
|
|
163
355
|
for (const [table, def] of entries) {
|
|
164
|
-
for (const stmt of indexStatements(table, def))
|
|
356
|
+
for (const stmt of indexStatements(table, def, uniqueIndexSkip.get(table)))
|
|
165
357
|
await driver.exec(stmt, []);
|
|
166
358
|
}
|
|
167
359
|
// Drop tables the schema no longer declares (internal bookkeeping tables skipped).
|
|
@@ -195,7 +387,8 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
195
387
|
await writeMeta(driver, tablesKey, tablesValue());
|
|
196
388
|
}
|
|
197
389
|
else {
|
|
198
|
-
console.warn(`pramen: ${skipped.length}
|
|
390
|
+
console.warn(`pramen: ${skipped.length} migration(s) skipped, schema hash left unwritten (a gated change needs ` +
|
|
391
|
+
`PRAMEN_ALLOW_DESTRUCTIVE=true; a UNIQUE-over-duplicates or partition move needs a manual fix): ${skipped.join("; ")}`);
|
|
199
392
|
}
|
|
200
393
|
return { changed: true, created, added, rebuilt, droppedTables, skipped };
|
|
201
394
|
}
|
package/dist/runtime/outbox.js
CHANGED
|
@@ -87,6 +87,14 @@ export async function drainOutbox(driver, tasks, now, limit = 50) {
|
|
|
87
87
|
const kind = String(row.kind);
|
|
88
88
|
const attempts = Number(row.attempts) + 1;
|
|
89
89
|
const handler = tasks[kind];
|
|
90
|
+
// Re-stamp claimedAt to WALL-CLOCK time immediately before running this row, so its
|
|
91
|
+
// stale clock starts when its own processing starts — not when the whole batch was
|
|
92
|
+
// claimed. Otherwise a batch (up to `limit` rows) processed SEQUENTIALLY whose total
|
|
93
|
+
// time exceeds STALE_MS would leave the not-yet-run tail reclaimable by a concurrent
|
|
94
|
+
// drainer under the batch-shared claimedAt, running it twice. We use Date.now() (not
|
|
95
|
+
// the caller's fixed `now`) because that is the only clock that advances across the
|
|
96
|
+
// loop; the atomic claim above still gives disjoint batches for concurrent drainers.
|
|
97
|
+
await driver.exec(`UPDATE ${d.id(OUTBOX_TABLE)} SET claimedAt = ${ph(1)} WHERE id = ${ph(2)}`, enc(driver, [Date.now(), id]));
|
|
90
98
|
try {
|
|
91
99
|
if (!handler)
|
|
92
100
|
throw new Error(`no task handler registered for kind ${JSON.stringify(kind)}`);
|
|
@@ -101,19 +109,33 @@ export async function drainOutbox(driver, tasks, now, limit = 50) {
|
|
|
101
109
|
failed++;
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
|
-
// remaining = pending AND due now
|
|
105
|
-
//
|
|
106
|
-
//
|
|
112
|
+
// remaining = pending AND due now. nextRunAt = the earliest moment the DO must wake to
|
|
113
|
+
// make progress, so it can re-arm its alarm exactly there. That is the min of:
|
|
114
|
+
// (a) MIN(runAt) over pending rows (a due-now or backed-off retry), and
|
|
115
|
+
// (b) MIN(claimedAt) + STALE_MS over 'processing' rows — a claim stranded by a
|
|
116
|
+
// crashed drainer becomes reclaimable at claimedAt + STALE_MS. Without folding
|
|
117
|
+
// this in, a mid-drain crash would leave a row 'processing' with no pending row
|
|
118
|
+
// to re-arm the alarm, and on a quiet tenant the task would stall forever (the
|
|
119
|
+
// alarm is the only DO-path drain trigger). Any processing rows here belong to a
|
|
120
|
+
// *different* (concurrent or crashed) drainer — our own batch is never left
|
|
121
|
+
// processing after this loop.
|
|
107
122
|
const stats = await driver.exec(`SELECT ` +
|
|
108
123
|
`(SELECT COUNT(*) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND runAt <= ${ph(2)}) AS due, ` +
|
|
109
|
-
`(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS
|
|
110
|
-
|
|
124
|
+
`(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS nextPending, ` +
|
|
125
|
+
`(SELECT MIN(claimedAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(4)}) AS nextStale`, enc(driver, ["pending", now, "pending", "processing"]));
|
|
126
|
+
const pendingRaw = stats[0]?.nextPending;
|
|
127
|
+
const staleRaw = stats[0]?.nextStale;
|
|
128
|
+
const candidates = [];
|
|
129
|
+
if (pendingRaw != null)
|
|
130
|
+
candidates.push(Number(pendingRaw));
|
|
131
|
+
if (staleRaw != null)
|
|
132
|
+
candidates.push(Number(staleRaw) + STALE_MS);
|
|
111
133
|
return {
|
|
112
134
|
processed: claimed.length,
|
|
113
135
|
succeeded,
|
|
114
136
|
failed,
|
|
115
137
|
remaining: Number(stats[0]?.due ?? 0),
|
|
116
|
-
nextRunAt:
|
|
138
|
+
nextRunAt: candidates.length ? Math.min(...candidates) : null,
|
|
117
139
|
};
|
|
118
140
|
}
|
|
119
141
|
/** List outbox rows for admin visibility (e.g. inspect dead-lettered tasks). Newest
|
|
@@ -55,8 +55,15 @@ export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMes
|
|
|
55
55
|
export type AppQueueMap = Record<string, QueueHandler>;
|
|
56
56
|
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
57
57
|
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
58
|
-
* leniently: exact, then
|
|
59
|
-
* fall through to it (the common single-queue app). Returns null if nothing
|
|
58
|
+
* leniently: exact, then the LONGEST `…-<key>` suffix, then — if there's exactly one
|
|
59
|
+
* handler — fall through to it (the common single-queue app). Returns null if nothing
|
|
60
|
+
* matches.
|
|
61
|
+
*
|
|
62
|
+
* The suffix match must prefer the longest key so `email-jobs` wins over `jobs` for
|
|
63
|
+
* `prod-email-jobs` (a plain `find` was insertion-order dependent and could misroute).
|
|
64
|
+
* We only match a handler key that is a `-`-delimited suffix of the incoming queue name
|
|
65
|
+
* (env prefix stripped) — never the reverse (a handler key ending in `-<queueName>`),
|
|
66
|
+
* which let a shorter queue name grab a longer, unrelated handler. */
|
|
60
67
|
export declare function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null;
|
|
61
68
|
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
62
69
|
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
@@ -10,17 +10,34 @@
|
|
|
10
10
|
// `ctx.env`, so the canonical "consume a job → send a notification" path is one call.
|
|
11
11
|
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
12
12
|
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
13
|
-
* leniently: exact, then
|
|
14
|
-
* fall through to it (the common single-queue app). Returns null if nothing
|
|
13
|
+
* leniently: exact, then the LONGEST `…-<key>` suffix, then — if there's exactly one
|
|
14
|
+
* handler — fall through to it (the common single-queue app). Returns null if nothing
|
|
15
|
+
* matches.
|
|
16
|
+
*
|
|
17
|
+
* The suffix match must prefer the longest key so `email-jobs` wins over `jobs` for
|
|
18
|
+
* `prod-email-jobs` (a plain `find` was insertion-order dependent and could misroute).
|
|
19
|
+
* We only match a handler key that is a `-`-delimited suffix of the incoming queue name
|
|
20
|
+
* (env prefix stripped) — never the reverse (a handler key ending in `-<queueName>`),
|
|
21
|
+
* which let a shorter queue name grab a longer, unrelated handler. */
|
|
15
22
|
export function routeQueue(queues, queueName) {
|
|
16
23
|
const keys = Object.keys(queues);
|
|
17
24
|
if (queues[queueName])
|
|
18
25
|
return queues[queueName];
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
let best = null;
|
|
27
|
+
for (const k of keys) {
|
|
28
|
+
if (queueName.endsWith(`-${k}`) && (best === null || k.length > best.length))
|
|
29
|
+
best = k;
|
|
30
|
+
}
|
|
31
|
+
if (best !== null)
|
|
32
|
+
return queues[best];
|
|
33
|
+
// Single-handler fallback: a lone queue whose env-prefixed name we couldn't suffix-
|
|
34
|
+
// match. Kept for the common single-queue app, but LOG it — otherwise a dead-letter
|
|
35
|
+
// queue (a distinct name) would silently route to the one handler and hide the misroute.
|
|
36
|
+
if (keys.length === 1) {
|
|
37
|
+
console.warn(`pramen: routing queue '${queueName}' to the sole handler '${keys[0]}' by fallback ` +
|
|
38
|
+
`(no exact/suffix match — verify this isn't a dead-letter or foreign queue)`);
|
|
23
39
|
return queues[keys[0]];
|
|
40
|
+
}
|
|
24
41
|
return null;
|
|
25
42
|
}
|
|
26
43
|
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
@@ -85,8 +85,13 @@ export function compileExpr(expr, dialect, params = []) {
|
|
|
85
85
|
case "false":
|
|
86
86
|
return { sql: "0", params };
|
|
87
87
|
case "cmp":
|
|
88
|
+
// A comparison against NULL is never TRUE in SQL (=, !=, <, > all yield NULL).
|
|
89
|
+
// Only the dedicated `null` node produces `IS NULL`; a `cmp` with a null operand
|
|
90
|
+
// matches nothing. (`eq()` already routes an equality-to-null to the `null` node,
|
|
91
|
+
// and the keyset comparator handles null order-keys explicitly — so no legitimate
|
|
92
|
+
// caller reaches here with a null value.)
|
|
88
93
|
if (expr.value === null)
|
|
89
|
-
return { sql:
|
|
94
|
+
return { sql: "0", params };
|
|
90
95
|
params.push(dialect.encode(expr.value));
|
|
91
96
|
return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
|
|
92
97
|
case "null":
|
|
@@ -144,7 +149,7 @@ export function evalExpr(expr, row) {
|
|
|
144
149
|
case "cmp": {
|
|
145
150
|
const left = bind(row[expr.col]);
|
|
146
151
|
if (expr.value === null)
|
|
147
|
-
return
|
|
152
|
+
return false; // comparison against NULL is never true (use the `null` node for IS NULL)
|
|
148
153
|
if (left === null || left === undefined)
|
|
149
154
|
return false; // NULL compared to a value -> false
|
|
150
155
|
const right = bind(expr.value);
|
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import type { SchemaDef } from "../sdk/schema";
|
|
2
|
-
/**
|
|
3
|
-
export
|
|
2
|
+
/** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
|
|
3
|
+
export interface ColumnShape {
|
|
4
|
+
type: string;
|
|
5
|
+
notNull?: boolean;
|
|
6
|
+
unique?: boolean;
|
|
7
|
+
primaryKey?: boolean;
|
|
8
|
+
generated?: boolean;
|
|
9
|
+
hidden?: boolean;
|
|
10
|
+
/** The literal or raw-SQL default, normalized to a string for comparison. */
|
|
11
|
+
default?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The comparable fingerprint of a table: its partition + each column's shape. */
|
|
14
|
+
export interface TableShape {
|
|
15
|
+
partition: string;
|
|
16
|
+
columns: Record<string, ColumnShape>;
|
|
17
|
+
}
|
|
18
|
+
/** table -> table shape. The comparable surface of a schema. */
|
|
19
|
+
export type SchemaShape = Record<string, TableShape>;
|
|
4
20
|
export declare function schemaShape(schema: SchemaDef): SchemaShape;
|
|
5
21
|
export interface SchemaChange {
|
|
6
|
-
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type";
|
|
22
|
+
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type" | "change-column" | "move-partition";
|
|
7
23
|
table: string;
|
|
8
24
|
column?: string;
|
|
9
25
|
detail?: string;
|
|
10
|
-
/** true = rebuilds the table and may lose data (drop / type change)
|
|
11
|
-
*
|
|
26
|
+
/** true = rebuilds the table and may lose data (drop / type change). false = additive
|
|
27
|
+
* OR a metadata-only change (modifier / partition move) — see `appliesOnBoot`. */
|
|
12
28
|
destructive: boolean;
|
|
29
|
+
/** Whether migrate() enacts this change on the next DO boot. Additive changes are
|
|
30
|
+
* always applied; destructive changes (type/drop, or a constraint-tightening modifier
|
|
31
|
+
* change) apply only when the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true (and are
|
|
32
|
+
* skipped when the live data conflicts, leaving the hash unwritten). `false` here means
|
|
33
|
+
* the boot migrator will NEVER enact it — today only a partition MOVE (needs a manual
|
|
34
|
+
* cross-DO data migration). Reported for honesty. */
|
|
35
|
+
appliesOnBoot: boolean;
|
|
13
36
|
}
|
|
14
37
|
export declare function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[];
|
|
@@ -1,41 +1,133 @@
|
|
|
1
|
-
// Schema shape + diff — powers the CLI's `schema diff`.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
1
|
+
// Schema shape + diff — powers the CLI's `schema diff`. The diff is a REPORTING tool; it
|
|
2
|
+
// does not itself migrate. On the next DO boot migrate() applies ADDITIVE changes only
|
|
3
|
+
// (new table -> CREATE TABLE; new column -> ALTER TABLE ADD COLUMN). DESTRUCTIVE changes
|
|
4
|
+
// (drop column/table, type change, table rebuild) are SKIPPED unless the deploy sets
|
|
5
|
+
// PRAMEN_ALLOW_DESTRUCTIVE=true — and when skipped the schema hash is left unwritten so a
|
|
6
|
+
// later opt-in deploy retries. A rename can't be detected from a shape diff; it shows as
|
|
7
|
+
// drop+add unless declared with `renamedFrom` in the schema.
|
|
8
|
+
//
|
|
9
|
+
// The shape records column type + the migration-relevant modifiers (notNull, unique,
|
|
10
|
+
// primaryKey, generated, default, hidden) and the entity's partition, so the diff REPORTS
|
|
11
|
+
// modifier and partition changes too. migrate() now RECONCILES modifier changes on an
|
|
12
|
+
// existing column (a NOT NULL / DEFAULT / PRIMARY KEY change via a rebuild, a UNIQUE
|
|
13
|
+
// change via a create/drop index), so a `change-column` is `appliesOnBoot: true`. It is
|
|
14
|
+
// flagged `destructive` when it tightens a constraint (adds NOT NULL / UNIQUE / PRIMARY
|
|
15
|
+
// KEY) — those apply only under PRAMEN_ALLOW_DESTRUCTIVE, or are skipped when the live
|
|
16
|
+
// data conflicts (NULL rows / duplicates), leaving the hash unwritten. A partition MOVE
|
|
17
|
+
// still CANNOT be enacted on boot (a partition is a separate Durable Object — it needs a
|
|
18
|
+
// manual cross-DO data migration), so it stays `appliesOnBoot: false`.
|
|
19
|
+
import { partitionOf } from "../sdk/schema";
|
|
20
|
+
function columnShape(f) {
|
|
21
|
+
const c = { type: f.type };
|
|
22
|
+
if (f.notNull)
|
|
23
|
+
c.notNull = true;
|
|
24
|
+
if (f.unique)
|
|
25
|
+
c.unique = true;
|
|
26
|
+
if (f.primaryKey)
|
|
27
|
+
c.primaryKey = true;
|
|
28
|
+
if (f.generated)
|
|
29
|
+
c.generated = true;
|
|
30
|
+
if (f.hidden)
|
|
31
|
+
c.hidden = true;
|
|
32
|
+
if (f.defaultExpr !== undefined)
|
|
33
|
+
c.default = `(${f.defaultExpr})`;
|
|
34
|
+
else if (f.default !== undefined)
|
|
35
|
+
c.default = JSON.stringify(f.default);
|
|
36
|
+
return c;
|
|
37
|
+
}
|
|
6
38
|
export function schemaShape(schema) {
|
|
7
39
|
const out = {};
|
|
8
40
|
for (const [table, def] of Object.entries(schema)) {
|
|
9
|
-
const
|
|
41
|
+
const columns = {};
|
|
10
42
|
for (const [col, f] of Object.entries(def.fields))
|
|
11
|
-
|
|
12
|
-
out[table] =
|
|
43
|
+
columns[col] = columnShape(f);
|
|
44
|
+
out[table] = { partition: partitionOf(schema, table), columns };
|
|
13
45
|
}
|
|
14
46
|
return out;
|
|
15
47
|
}
|
|
48
|
+
/** The modifier fields compared for a `change-column` (everything but `type`). */
|
|
49
|
+
const MODIFIER_KEYS = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
|
|
50
|
+
/** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
|
|
51
|
+
* Such a change may require the destructive gate or be skipped when the live data
|
|
52
|
+
* conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
|
|
53
|
+
function tightensConstraint(prev, next) {
|
|
54
|
+
return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
|
|
55
|
+
}
|
|
56
|
+
function modifierDiff(prev, next) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const k of MODIFIER_KEYS) {
|
|
59
|
+
if (prev[k] !== next[k])
|
|
60
|
+
parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length ? parts.join(", ") : null;
|
|
63
|
+
}
|
|
64
|
+
function fmt(v) {
|
|
65
|
+
return v === undefined ? "—" : String(v);
|
|
66
|
+
}
|
|
16
67
|
export function diffSchemaShape(prev, next) {
|
|
17
68
|
const changes = [];
|
|
18
69
|
for (const table of Object.keys(next)) {
|
|
19
|
-
|
|
20
|
-
|
|
70
|
+
const pt = prev[table];
|
|
71
|
+
if (!pt) {
|
|
72
|
+
changes.push({ kind: "add-table", table, destructive: false, appliesOnBoot: true });
|
|
21
73
|
continue;
|
|
22
74
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
75
|
+
const nt = next[table];
|
|
76
|
+
if (pt.partition !== nt.partition) {
|
|
77
|
+
changes.push({
|
|
78
|
+
kind: "move-partition",
|
|
79
|
+
table,
|
|
80
|
+
detail: `${pt.partition} → ${nt.partition}`,
|
|
81
|
+
destructive: false,
|
|
82
|
+
// A partition is a separate Durable Object; boot migration can't move a table's
|
|
83
|
+
// data across DOs. Needs a manual data migration.
|
|
84
|
+
appliesOnBoot: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
for (const col of Object.keys(nt.columns)) {
|
|
88
|
+
const pc = pt.columns[col];
|
|
89
|
+
const ncol = nt.columns[col];
|
|
90
|
+
if (!pc) {
|
|
91
|
+
changes.push({ kind: "add-column", table, column: col, destructive: false, appliesOnBoot: true });
|
|
92
|
+
}
|
|
93
|
+
else if (pc.type !== ncol.type) {
|
|
94
|
+
changes.push({
|
|
95
|
+
kind: "change-type",
|
|
96
|
+
table,
|
|
97
|
+
column: col,
|
|
98
|
+
detail: `${pc.type} → ${ncol.type}`,
|
|
99
|
+
destructive: true,
|
|
100
|
+
// Applied only under PRAMEN_ALLOW_DESTRUCTIVE (a table rebuild). Report it as
|
|
101
|
+
// boot-applicable — the destructive-gating note explains the opt-in.
|
|
102
|
+
appliesOnBoot: true,
|
|
103
|
+
});
|
|
26
104
|
}
|
|
27
|
-
else
|
|
28
|
-
|
|
105
|
+
else {
|
|
106
|
+
const md = modifierDiff(pc, ncol);
|
|
107
|
+
if (md) {
|
|
108
|
+
changes.push({
|
|
109
|
+
kind: "change-column",
|
|
110
|
+
table,
|
|
111
|
+
column: col,
|
|
112
|
+
detail: md,
|
|
113
|
+
// Tightening a constraint (add NOT NULL / UNIQUE / PRIMARY KEY) rebuilds/
|
|
114
|
+
// indexes and applies only under PRAMEN_ALLOW_DESTRUCTIVE (or is skipped when
|
|
115
|
+
// live data conflicts). Loosening or a DEFAULT change is additive.
|
|
116
|
+
destructive: tightensConstraint(pc, ncol),
|
|
117
|
+
// migrate() now reconciles modifier changes on an existing column on boot.
|
|
118
|
+
appliesOnBoot: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
29
121
|
}
|
|
30
122
|
}
|
|
31
|
-
for (const col of Object.keys(
|
|
32
|
-
if (!(col in
|
|
33
|
-
changes.push({ kind: "drop-column", table, column: col, destructive: true });
|
|
123
|
+
for (const col of Object.keys(pt.columns)) {
|
|
124
|
+
if (!(col in nt.columns))
|
|
125
|
+
changes.push({ kind: "drop-column", table, column: col, destructive: true, appliesOnBoot: true });
|
|
34
126
|
}
|
|
35
127
|
}
|
|
36
128
|
for (const table of Object.keys(prev)) {
|
|
37
129
|
if (!(table in next))
|
|
38
|
-
changes.push({ kind: "drop-table", table, destructive: true });
|
|
130
|
+
changes.push({ kind: "drop-table", table, destructive: true, appliesOnBoot: true });
|
|
39
131
|
}
|
|
40
132
|
return changes;
|
|
41
133
|
}
|