@pramen/server 0.0.13 → 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.
Files changed (53) hide show
  1. package/dist/auth.d.ts +17 -2
  2. package/dist/auth.js +26 -7
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +311 -0
  5. package/dist/durable-object.d.ts +22 -4
  6. package/dist/durable-object.js +121 -55
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +3 -0
  9. package/dist/pramen.d.ts +6 -0
  10. package/dist/pramen.js +1 -1
  11. package/dist/runtime/acl.js +28 -7
  12. package/dist/runtime/db.d.ts +6 -0
  13. package/dist/runtime/db.js +86 -10
  14. package/dist/runtime/ddl.d.ts +16 -3
  15. package/dist/runtime/ddl.js +28 -8
  16. package/dist/runtime/dispatch.js +2 -0
  17. package/dist/runtime/driver.d.ts +41 -7
  18. package/dist/runtime/driver.js +38 -11
  19. package/dist/runtime/migrate.d.ts +1 -1
  20. package/dist/runtime/migrate.js +222 -33
  21. package/dist/runtime/outbox.js +28 -6
  22. package/dist/runtime/queue-consumer.d.ts +71 -0
  23. package/dist/runtime/queue-consumer.js +63 -0
  24. package/dist/runtime/queue.d.ts +72 -0
  25. package/dist/runtime/queue.js +110 -0
  26. package/dist/runtime/read-engine.js +7 -2
  27. package/dist/runtime/schema-diff.d.ts +28 -5
  28. package/dist/runtime/schema-diff.js +111 -19
  29. package/dist/runtime/storage.d.ts +7 -0
  30. package/dist/runtime/storage.js +0 -0
  31. package/dist/sdk/handlers.d.ts +7 -0
  32. package/dist/worker.d.ts +36 -0
  33. package/dist/worker.js +128 -18
  34. package/package.json +6 -2
  35. package/src/auth.ts +64 -21
  36. package/src/cli.ts +336 -0
  37. package/src/durable-object.ts +118 -52
  38. package/src/index.ts +6 -0
  39. package/src/pramen.ts +7 -1
  40. package/src/runtime/acl.ts +25 -5
  41. package/src/runtime/db.ts +80 -9
  42. package/src/runtime/ddl.ts +26 -8
  43. package/src/runtime/dispatch.ts +2 -0
  44. package/src/runtime/driver.ts +52 -9
  45. package/src/runtime/migrate.ts +246 -34
  46. package/src/runtime/outbox.ts +30 -7
  47. package/src/runtime/queue-consumer.ts +116 -0
  48. package/src/runtime/queue.ts +155 -0
  49. package/src/runtime/read-engine.ts +7 -2
  50. package/src/runtime/schema-diff.ts +137 -23
  51. package/src/runtime/storage.ts +0 -0
  52. package/src/sdk/handlers.ts +7 -0
  53. package/src/worker.ts +162 -19
@@ -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. destructive: a live column the schema no longer declares is DROPPED, a type
9
- // change is applied, and a `renamedFrom` column is renamed — all via the
10
- // standard SQLite table-rebuild (create new, copy, drop old, rename). This is
11
- // auto-applied: a bad deploy CAN lose data, by design (WIP, no backward-compat).
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
@@ -18,10 +38,10 @@
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
40
 
21
- import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
41
+ import { addColumnSql, createTableSql, defaultSqlValue, indexName, indexStatements, sqlType } from "./ddl";
22
42
  import { digest } from "./digest";
23
- import type { Driver } from "./driver";
24
- import { entitiesInPartition, validateSchema } from "../sdk/schema";
43
+ import { quoteIdent, type Driver } from "./driver";
44
+ import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
25
45
  import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
26
46
 
27
47
  export interface MigrationReport {
@@ -66,11 +86,6 @@ function isInternalTable(name: string): boolean {
66
86
  );
67
87
  }
68
88
 
69
- function ident(name: string): string {
70
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`invalid identifier: ${name}`);
71
- return name;
72
- }
73
-
74
89
  export function schemaHash(schema: SchemaDef): string {
75
90
  const canon: Record<string, unknown> = {};
76
91
  for (const [table, def] of Object.entries(schema)) canon[table] = def.fields;
@@ -80,10 +95,99 @@ export function schemaHash(schema: SchemaDef): string {
80
95
  /** Live columns of a table -> their declared SQL type (uppercased). Empty if the
81
96
  * table doesn't exist. */
82
97
  async function tableColumns(driver: Driver, table: string): Promise<Map<string, string>> {
83
- const rows = (await driver.exec(`PRAGMA table_info(${ident(table)})`, [])) as { name: string; type: string }[];
98
+ const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, [])) as { name: string; type: string }[];
84
99
  return new Map(rows.map((r) => [r.name, (r.type || "").toUpperCase()]));
85
100
  }
86
101
 
102
+ /** The constraint-bearing facts a migrator compares against a schema field's modifiers:
103
+ * SQL type, NOT NULL, PRIMARY KEY membership, and the raw DEFAULT text (as reported by
104
+ * PRAGMA — e.g. `'pending'`, `1`, `datetime('now')`, or null). */
105
+ interface LiveColumn {
106
+ type: string;
107
+ notNull: boolean;
108
+ pk: boolean;
109
+ default: string | null;
110
+ }
111
+
112
+ /** Full live column info (type + modifiers) via PRAGMA table_info. Empty if absent. */
113
+ async function liveColumnInfo(driver: Driver, table: string): Promise<Map<string, LiveColumn>> {
114
+ const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, [])) as {
115
+ name: string;
116
+ type: string;
117
+ notnull: number;
118
+ dflt_value: string | null;
119
+ pk: number;
120
+ }[];
121
+ const out = new Map<string, LiveColumn>();
122
+ for (const r of rows) {
123
+ out.set(r.name, {
124
+ type: (r.type || "").toUpperCase(),
125
+ notNull: r.notnull === 1,
126
+ pk: r.pk > 0,
127
+ default: r.dflt_value ?? null,
128
+ });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** The columns backed by a single-column UNIQUE index (a `unique()` constraint). Reads
134
+ * PRAGMA index_list + index_info; multi-column indexes are ignored (pramen only emits
135
+ * single-column ones). */
136
+ async function liveUniqueColumns(driver: Driver, table: string): Promise<Set<string>> {
137
+ const idx = (await driver.exec(`PRAGMA index_list(${quoteIdent(table)})`, [])) as { name: string; unique: number }[];
138
+ const out = new Set<string>();
139
+ for (const i of idx) {
140
+ if (i.unique !== 1) continue;
141
+ const cols = (await driver.exec(`PRAGMA index_info(${quoteIdent(i.name)})`, [])) as { name: string | null }[];
142
+ if (cols.length === 1 && cols[0]?.name) out.add(cols[0].name);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** Does the column currently hold any NULL? (Adding NOT NULL to such a column is
148
+ * unsafe without a backfill default.) */
149
+ async function columnHasNulls(driver: Driver, table: string, col: string): Promise<boolean> {
150
+ const rows = await driver.exec(`SELECT 1 FROM ${quoteIdent(table)} WHERE ${quoteIdent(col)} IS NULL LIMIT 1`, []);
151
+ return rows.length > 0;
152
+ }
153
+
154
+ /** Does the column hold a duplicate non-NULL value? (Adding UNIQUE to such a column
155
+ * can't build the index.) NULLs are never "equal" in SQLite, so they're excluded. */
156
+ async function columnHasDuplicates(driver: Driver, table: string, col: string): Promise<boolean> {
157
+ const rows = await driver.exec(
158
+ `SELECT ${quoteIdent(col)} FROM ${quoteIdent(table)} WHERE ${quoteIdent(col)} IS NOT NULL GROUP BY ${quoteIdent(col)} HAVING COUNT(*) > 1 LIMIT 1`,
159
+ [],
160
+ );
161
+ return rows.length > 0;
162
+ }
163
+
164
+ /** Normalize a DEFAULT's SQL text for comparison: trim, and strip balanced outer
165
+ * parens (SQLite reports an expr default with or without the wrapping parens the DDL
166
+ * emitted — `(datetime('now'))` vs `datetime('now')` — depending on the engine, so the
167
+ * comparison must not depend on them). */
168
+ function normalizeDefault(s: string | null): string | null {
169
+ if (s == null) return null;
170
+ let t = s.trim();
171
+ while (t.length >= 2 && t[0] === "(" && t[t.length - 1] === ")" && outerParensBalanced(t)) {
172
+ t = t.slice(1, -1).trim();
173
+ }
174
+ return t;
175
+ }
176
+
177
+ /** Does the leading `(` in `t` match the trailing `)` (i.e. is the whole string wrapped
178
+ * in one paren group)? Prevents stripping `(a) + (b)`. */
179
+ function outerParensBalanced(t: string): boolean {
180
+ let depth = 0;
181
+ for (let i = 0; i < t.length; i++) {
182
+ if (t[i] === "(") depth++;
183
+ else if (t[i] === ")") {
184
+ depth--;
185
+ if (depth === 0 && i !== t.length - 1) return false;
186
+ }
187
+ }
188
+ return depth === 0;
189
+ }
190
+
87
191
  async function readMeta(driver: Driver, key: string): Promise<string | undefined> {
88
192
  const rows = (await driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [key])) as { value: string }[];
89
193
  return rows[0]?.value;
@@ -98,7 +202,7 @@ async function writeMeta(driver: Driver, key: string, value: string): Promise<vo
98
202
  * type change; brand-new columns left NULL), drop the old table, rename the temp. */
99
203
  async function rebuildTable(driver: Driver, table: string, def: { fields: EntityFields }, live: Map<string, string>): Promise<void> {
100
204
  const tmp = `__pramen_rebuild_${table}`;
101
- await driver.exec(`DROP TABLE IF EXISTS ${ident(tmp)}`, []);
205
+ await driver.exec(`DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, []);
102
206
  await driver.exec(createTableSql(tmp, def), []);
103
207
 
104
208
  const destCols: string[] = [];
@@ -108,14 +212,21 @@ async function rebuildTable(driver: Driver, table: string, def: { fields: Entity
108
212
  const src = f.renamedFrom && live.has(f.renamedFrom) ? f.renamedFrom : live.has(name) ? name : undefined;
109
213
  if (!src) continue; // brand-new column with no source -> leave NULL
110
214
  const target = sqlType(f);
111
- destCols.push(ident(name));
112
- srcExprs.push(live.get(src) === target ? ident(src) : `CAST(${ident(src)} AS ${target})`);
215
+ let expr = live.get(src) === target ? quoteIdent(src) : `CAST(${quoteIdent(src)} AS ${target})`;
216
+ // Backfill NULLs when the target is NOT NULL and carries a default — makes adding
217
+ // NOT NULL to a column with NULL rows safe (the copy fills them from the default),
218
+ // instead of the INSERT failing the new NOT NULL constraint.
219
+ const notNull = !!f.notNull || !!f.primaryKey;
220
+ const dflt = defaultSqlValue(f);
221
+ if (notNull && dflt !== null) expr = `COALESCE(${expr}, ${dflt})`;
222
+ destCols.push(quoteIdent(name));
223
+ srcExprs.push(expr);
113
224
  }
114
225
  if (destCols.length > 0) {
115
- await driver.exec(`INSERT INTO ${ident(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${ident(table)}`, []);
226
+ await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
116
227
  }
117
- await driver.exec(`DROP TABLE ${ident(table)}`, []);
118
- await driver.exec(`ALTER TABLE ${ident(tmp)} RENAME TO ${ident(table)}`, []);
228
+ await driver.exec(`DROP TABLE ${quoteIdent(table)}`, []);
229
+ await driver.exec(`ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, []);
119
230
  }
120
231
 
121
232
  export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOptions = {}): Promise<MigrationReport> {
@@ -154,6 +265,9 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
154
265
  const rebuilt: string[] = [];
155
266
  const droppedTables: string[] = [];
156
267
  const skipped: string[] = [];
268
+ // Per-table: columns whose new `unique()` can't be indexed (duplicate values) — the
269
+ // index pass must skip them so it doesn't throw. They're already reported in `skipped`.
270
+ const uniqueIndexSkip = new Map<string, Set<string>>();
157
271
 
158
272
  for (const [table, def] of entries) {
159
273
  const existing = await tableColumns(driver, table);
@@ -174,13 +288,17 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
174
288
  needsAdditiveRebuild = true;
175
289
  continue;
176
290
  }
177
- await driver.exec(`ALTER TABLE ${ident(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
291
+ await driver.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
178
292
  added.push(`${table}.${name}`);
179
293
  }
180
294
 
181
- // Pass 2 — destructive: rebuild if any live column must be dropped, a declared
182
- // column changed type, or a rename hint points at an existing live column.
183
- const live = await tableColumns(driver, table); // re-read (now includes additively-added columns)
295
+ // Pass 2 — reconcile existing columns against the schema. Rebuild the table when a
296
+ // live column must be dropped, a declared column changed type, a rename hint points
297
+ // at an existing live column, or a MODIFIER changed on an existing column (NOT NULL,
298
+ // DEFAULT, PRIMARY KEY). UNIQUE is reconciled with an index (create/drop), no rebuild.
299
+ const liveInfo = await liveColumnInfo(driver, table); // re-read (includes additively-added columns)
300
+ const liveUnique = await liveUniqueColumns(driver, table);
301
+ const live = new Map([...liveInfo].map(([name, info]) => [name, info.type] as const)); // name -> type
184
302
  const desired = new Set(Object.keys(def.fields));
185
303
  const renamedSources = new Set<string>();
186
304
  for (const f of Object.values(def.fields)) {
@@ -191,24 +309,117 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
191
309
  const needsTypeChange = Object.entries(def.fields).some(
192
310
  ([n, f]) => live.has(n) && live.get(n) !== sqlType(f as FieldDef),
193
311
  );
194
- const destructive = needsDrop || needsTypeChange || renamedSources.size > 0;
312
+
313
+ // Modifier reconciliation on existing (same-named) columns. A change to NOT NULL /
314
+ // DEFAULT / PRIMARY KEY is enacted by a table rebuild (which reconstructs the column
315
+ // to its exact declared shape); UNIQUE is an index op. Classify each as either a
316
+ // SAFE change (loses no data — applied always) or a DESTRUCTIVE one (gated behind
317
+ // allowDestructive). `hidden()`/`generated()` are ORM-only (no physical column
318
+ // change), so they never appear here.
319
+ let modifierRebuildSafe = false; // default add/change/remove, notNull widen, safe notNull add
320
+ let modifierRebuildDestructive = false; // notNull add over NULL rows w/o default, PK change
321
+ const destructiveReasons: string[] = [];
322
+ const dropUniqueCols: string[] = []; // `unique()` removed -> drop the managed index
323
+ for (const [name, field] of Object.entries(def.fields)) {
324
+ const info = liveInfo.get(name);
325
+ if (!info) continue; // new column (Pass 1) or a rename target — not an existing column
326
+ const f = field as FieldDef;
327
+ const fieldPk = !!f.primaryKey;
328
+ const fieldNotNull = !!f.notNull || fieldPk;
329
+
330
+ // DEFAULT add / change / remove — a rebuild backfills existing rows and applies the
331
+ // new default going forward; no data loss.
332
+ if (normalizeDefault(defaultSqlValue(f)) !== normalizeDefault(info.default)) modifierRebuildSafe = true;
333
+
334
+ // NOT NULL — PRAGMA reports notnull=0 for a PRIMARY KEY column, so only compare on
335
+ // non-PK columns (PK-ness is compared separately below).
336
+ if (!fieldPk && !info.pk) {
337
+ if (fieldNotNull && !info.notNull) {
338
+ const backfillable = defaultSqlValue(f) !== null;
339
+ if (!backfillable && (await columnHasNulls(driver, table, name))) {
340
+ modifierRebuildDestructive = true;
341
+ destructiveReasons.push(`NOT NULL ${name} (NULL rows, no default)`);
342
+ } else {
343
+ modifierRebuildSafe = true; // no NULLs, or backfilled from the default
344
+ }
345
+ } else if (!fieldNotNull && info.notNull) {
346
+ modifierRebuildSafe = true; // dropping NOT NULL only widens — safe
347
+ }
348
+ }
349
+
350
+ // PRIMARY KEY change — reshapes the table's key; treat as destructive.
351
+ if (fieldPk !== info.pk) {
352
+ modifierRebuildDestructive = true;
353
+ destructiveReasons.push(`PRIMARY KEY ${name}`);
354
+ }
355
+
356
+ // UNIQUE — reconciled with an index (create/drop), not a rebuild.
357
+ const fieldUnique = !!f.unique;
358
+ if (fieldUnique && !liveUnique.has(name)) {
359
+ // Added: safe only if no duplicate values exist; otherwise the index can't build.
360
+ if (await columnHasDuplicates(driver, table, name)) {
361
+ (uniqueIndexSkip.get(table) ?? uniqueIndexSkip.set(table, new Set()).get(table)!).add(name);
362
+ skipped.push(`add UNIQUE ${table}.${name} (duplicate values present)`);
363
+ }
364
+ // else: the index pass creates it below (no rebuild needed).
365
+ } else if (!fieldUnique && liveUnique.has(name)) {
366
+ dropUniqueCols.push(name); // drop the managed unique index (safe — no data loss)
367
+ }
368
+ }
369
+
370
+ const destructive = needsDrop || needsTypeChange || renamedSources.size > 0 || modifierRebuildDestructive;
195
371
  if (destructive && !allowDestructive) {
196
- // The destructive part is gated off — skip the whole rebuild (any pending
197
- // expr-default column waits until destructive migrations are allowed).
198
- skipped.push(`rebuild ${table} (drop/type-change/rename)`);
199
- } else if (destructive || needsAdditiveRebuild) {
200
- // An additive-only rebuild (just an expr-default column) needs no permission —
201
- // it loses no data.
372
+ // The destructive part is gated off — skip the whole rebuild (any pending safe
373
+ // rebuild for this table waits until destructive migrations are allowed).
374
+ const reasons = [
375
+ ...(needsDrop ? ["drop"] : []),
376
+ ...(needsTypeChange ? ["type-change"] : []),
377
+ ...(renamedSources.size > 0 ? ["rename"] : []),
378
+ ...destructiveReasons,
379
+ ];
380
+ skipped.push(`rebuild ${table} (${reasons.join(", ")})`);
381
+ } else if (destructive || needsAdditiveRebuild || modifierRebuildSafe) {
382
+ // A safe rebuild (expr-default column, default/notNull modifier change) needs no
383
+ // permission — it loses no data.
202
384
  await rebuildTable(driver, table, def, live);
203
385
  rebuilt.push(table);
204
386
  }
387
+
388
+ // Drop the managed unique index for a column that no longer declares `unique()`. A
389
+ // rebuild already dropped every index (and the index pass won't recreate this one),
390
+ // so this only matters when no rebuild ran — DROP INDEX IF EXISTS is a safe no-op
391
+ // otherwise. No data loss either way.
392
+ for (const col of dropUniqueCols) {
393
+ await driver.exec(`DROP INDEX IF EXISTS ${quoteIdent(indexName(table, col))}`, []);
394
+ }
395
+ }
396
+
397
+ // A partition MOVE — an entity that was applied in THIS partition before but the
398
+ // current schema assigns to a DIFFERENT partition — is NOT auto-migratable: the data
399
+ // lives in this DO's SQLite and boot migration can't move it across DOs. Detect it
400
+ // (scoped path only; the unscoped/single-store path never strands data), report it as
401
+ // a skipped manual migration, and leave the table in place so its data is preserved
402
+ // for a hand-run migration. Leaving it in `skipped` also withholds the hash.
403
+ if (opts.partition !== undefined) {
404
+ const prevRaw = await readMeta(driver, tablesKey);
405
+ if (prevRaw) {
406
+ const prevApplied = JSON.parse(prevRaw) as Record<string, string[]>;
407
+ for (const t of Object.keys(prevApplied)) {
408
+ if (!inScope.has(t) && t in schema && partitionOf(schema, t) !== opts.partition) {
409
+ skipped.push(
410
+ `move partition ${t} (${opts.partition} → ${partitionOf(schema, t)}) — data stays in this DO; manual cross-DO migration required`,
411
+ );
412
+ }
413
+ }
414
+ }
205
415
  }
206
416
 
207
417
  // Ensure unique/index declarations (idempotent, via IF NOT EXISTS). Indexes can be
208
418
  // added to an existing table without a rebuild; a stale index from a removed
209
- // declaration is left in place (cleanup is future work).
419
+ // declaration is dropped above. A column whose new `unique()` has duplicate values is
420
+ // skipped (reported above) so this doesn't throw.
210
421
  for (const [table, def] of entries) {
211
- for (const stmt of indexStatements(table, def)) await driver.exec(stmt, []);
422
+ for (const stmt of indexStatements(table, def, uniqueIndexSkip.get(table))) await driver.exec(stmt, []);
212
423
  }
213
424
 
214
425
  // Drop tables the schema no longer declares (internal bookkeeping tables skipped).
@@ -224,7 +435,7 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
224
435
  for (const { name } of liveTables) {
225
436
  if (isInternalTable(name) || inScope.has(name) || otherPartitionTables.has(name)) continue;
226
437
  if (allowDestructive) {
227
- await driver.exec(`DROP TABLE ${ident(name)}`, []);
438
+ await driver.exec(`DROP TABLE ${quoteIdent(name)}`, []);
228
439
  droppedTables.push(name);
229
440
  } else {
230
441
  skipped.push(`drop table ${name}`);
@@ -242,7 +453,8 @@ export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOp
242
453
  await writeMeta(driver, tablesKey, tablesValue());
243
454
  } else {
244
455
  console.warn(
245
- `pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`,
456
+ `pramen: ${skipped.length} migration(s) skipped, schema hash left unwritten (a gated change needs ` +
457
+ `PRAMEN_ALLOW_DESTRUCTIVE=true; a UNIQUE-over-duplicates or partition move needs a manual fix): ${skipped.join("; ")}`,
246
458
  );
247
459
  }
248
460
  return { changed: true, created, added, rebuilt, droppedTables, skipped };
@@ -141,6 +141,17 @@ export async function drainOutbox(driver: Driver, tasks: TaskMap, now: number, l
141
141
  const kind = String(row.kind);
142
142
  const attempts = Number(row.attempts) + 1;
143
143
  const handler = tasks[kind];
144
+ // Re-stamp claimedAt to WALL-CLOCK time immediately before running this row, so its
145
+ // stale clock starts when its own processing starts — not when the whole batch was
146
+ // claimed. Otherwise a batch (up to `limit` rows) processed SEQUENTIALLY whose total
147
+ // time exceeds STALE_MS would leave the not-yet-run tail reclaimable by a concurrent
148
+ // drainer under the batch-shared claimedAt, running it twice. We use Date.now() (not
149
+ // the caller's fixed `now`) because that is the only clock that advances across the
150
+ // loop; the atomic claim above still gives disjoint batches for concurrent drainers.
151
+ await driver.exec(
152
+ `UPDATE ${d.id(OUTBOX_TABLE)} SET claimedAt = ${ph(1)} WHERE id = ${ph(2)}`,
153
+ enc(driver, [Date.now(), id]),
154
+ );
144
155
  try {
145
156
  if (!handler) throw new Error(`no task handler registered for kind ${JSON.stringify(kind)}`);
146
157
  await handler(JSON.parse(String(row.payload)), { id, attempts });
@@ -160,22 +171,34 @@ export async function drainOutbox(driver: Driver, tasks: TaskMap, now: number, l
160
171
  }
161
172
  }
162
173
 
163
- // remaining = pending AND due now; nextRunAt = the earliest pending runAt (any), so
164
- // the DO can schedule its alarm exactly when the next task including a backed-off
165
- // retry becomes due.
174
+ // remaining = pending AND due now. nextRunAt = the earliest moment the DO must wake to
175
+ // make progress, so it can re-arm its alarm exactly there. That is the min of:
176
+ // (a) MIN(runAt) over pending rows (a due-now or backed-off retry), and
177
+ // (b) MIN(claimedAt) + STALE_MS over 'processing' rows — a claim stranded by a
178
+ // crashed drainer becomes reclaimable at claimedAt + STALE_MS. Without folding
179
+ // this in, a mid-drain crash would leave a row 'processing' with no pending row
180
+ // to re-arm the alarm, and on a quiet tenant the task would stall forever (the
181
+ // alarm is the only DO-path drain trigger). Any processing rows here belong to a
182
+ // *different* (concurrent or crashed) drainer — our own batch is never left
183
+ // processing after this loop.
166
184
  const stats = await driver.exec(
167
185
  `SELECT ` +
168
186
  `(SELECT COUNT(*) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(1)} AND runAt <= ${ph(2)}) AS due, ` +
169
- `(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS nextRunAt`,
170
- enc(driver, ["pending", now, "pending"]),
187
+ `(SELECT MIN(runAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(3)}) AS nextPending, ` +
188
+ `(SELECT MIN(claimedAt) FROM ${d.id(OUTBOX_TABLE)} WHERE status = ${ph(4)}) AS nextStale`,
189
+ enc(driver, ["pending", now, "pending", "processing"]),
171
190
  );
172
- const nextRaw = stats[0]?.nextRunAt;
191
+ const pendingRaw = stats[0]?.nextPending;
192
+ const staleRaw = stats[0]?.nextStale;
193
+ const candidates: number[] = [];
194
+ if (pendingRaw != null) candidates.push(Number(pendingRaw));
195
+ if (staleRaw != null) candidates.push(Number(staleRaw) + STALE_MS);
173
196
  return {
174
197
  processed: claimed.length,
175
198
  succeeded,
176
199
  failed,
177
200
  remaining: Number(stats[0]?.due ?? 0),
178
- nextRunAt: nextRaw == null ? null : Number(nextRaw),
201
+ nextRunAt: candidates.length ? Math.min(...candidates) : null,
179
202
  };
180
203
  }
181
204
 
@@ -0,0 +1,116 @@
1
+ // Queue consumer dispatch — the receiving half of ctx.queue. A pramen Worker is the
2
+ // consumer for its declared queues (oblaka `new Queue({ binding: "both", ... })`), so
3
+ // `createPramen(app).queue` is the Cloudflare `queue(batch, env, ctx)` entry. It routes
4
+ // each batch to the matching `app.queues[name]` handler and ACKs/RETRIES per message.
5
+ //
6
+ // A consumer runs in the WORKER, not a Durable Object — a queue message isn't bound to a
7
+ // tenant, so there's no direct `ctx.db`. To touch tenant data, carry the tenant in the
8
+ // message body and `ctx.callPrivileged({ name, input, tenant })` into its DO (exactly
9
+ // like a public route). The consumer still gets `ctx.mail` / `ctx.queue` / `ctx.kv` /
10
+ // `ctx.env`, so the canonical "consume a job → send a notification" path is one call.
11
+
12
+ import type { Mail } from "./mail";
13
+ import type { Queue } from "./queue";
14
+ import type { Kv } from "./kv";
15
+
16
+ /** One received message (the Cloudflare Queues `Message` shape). */
17
+ export interface QueueMessage<Body = unknown> {
18
+ readonly id: string;
19
+ readonly timestamp: Date;
20
+ readonly body: Body;
21
+ /** 1-based delivery attempt — grows on each retry (use it to give up / dead-letter). */
22
+ readonly attempts: number;
23
+ /** Mark this message handled (won't be redelivered). The framework calls this for you
24
+ * when the handler resolves; call it yourself only for fine-grained control. */
25
+ ack(): void;
26
+ /** Schedule this message for redelivery (the framework calls it when the handler throws). */
27
+ retry(options?: { delaySeconds?: number }): void;
28
+ }
29
+
30
+ /** A batch delivered to the consumer (the Cloudflare Queues `MessageBatch` shape). */
31
+ export interface QueueBatch<Body = unknown> {
32
+ /** The queue this batch came from (the oblaka `Queue` name; env-prefixed remotely). */
33
+ readonly queue: string;
34
+ readonly messages: readonly QueueMessage<Body>[];
35
+ ackAll(): void;
36
+ retryAll(options?: { delaySeconds?: number }): void;
37
+ }
38
+
39
+ /** The context handed to a queue consumer handler. Worker-level (no `ctx.db`): reach
40
+ * tenant data via `ctx.callPrivileged`. */
41
+ export interface QueueContext {
42
+ /** The Worker environment (bindings + vars + secrets). */
43
+ readonly env: Readonly<Record<string, unknown>>;
44
+ /** Project KV (cross-tenant). */
45
+ readonly kv: Kv;
46
+ /** Send email (the notification path). */
47
+ readonly mail: Mail;
48
+ /** Enqueue onto a (possibly different) queue — fan-out / chaining. */
49
+ readonly queue: Queue;
50
+ /** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
51
+ * The message body should carry the `tenant`. */
52
+ callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
53
+ }
54
+
55
+ /** A queue consumer handler — runs once per message. Resolving ACKs the message;
56
+ * throwing RETRIES it (subject to the queue's max_retries → dead-letter queue). */
57
+ export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMessage<Body>) => void | Promise<void>;
58
+
59
+ /** Map of queue name → consumer handler. Set as `app.queues`; dispatched by
60
+ * `createPramen(app).queue`. */
61
+ export type AppQueueMap = Record<string, QueueHandler>;
62
+
63
+ /** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
64
+ * environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
65
+ * leniently: exact, then the LONGEST `…-<key>` suffix, then — if there's exactly one
66
+ * handler — fall through to it (the common single-queue app). Returns null if nothing
67
+ * matches.
68
+ *
69
+ * The suffix match must prefer the longest key so `email-jobs` wins over `jobs` for
70
+ * `prod-email-jobs` (a plain `find` was insertion-order dependent and could misroute).
71
+ * We only match a handler key that is a `-`-delimited suffix of the incoming queue name
72
+ * (env prefix stripped) — never the reverse (a handler key ending in `-<queueName>`),
73
+ * which let a shorter queue name grab a longer, unrelated handler. */
74
+ export function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null {
75
+ const keys = Object.keys(queues);
76
+ if (queues[queueName]) return queues[queueName];
77
+ let best: string | null = null;
78
+ for (const k of keys) {
79
+ if (queueName.endsWith(`-${k}`) && (best === null || k.length > best.length)) best = k;
80
+ }
81
+ if (best !== null) return queues[best];
82
+ // Single-handler fallback: a lone queue whose env-prefixed name we couldn't suffix-
83
+ // match. Kept for the common single-queue app, but LOG it — otherwise a dead-letter
84
+ // queue (a distinct name) would silently route to the one handler and hide the misroute.
85
+ if (keys.length === 1) {
86
+ console.warn(
87
+ `pramen: routing queue '${queueName}' to the sole handler '${keys[0]}' by fallback ` +
88
+ `(no exact/suffix match — verify this isn't a dead-letter or foreign queue)`,
89
+ );
90
+ return queues[keys[0]];
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** Dispatch one batch: route to the handler, then run it per message, ACKing on success
96
+ * and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
97
+ * An unrouted batch is retried whole (never silently acked) and logged. */
98
+ export async function dispatchQueueBatch(queues: AppQueueMap, ctx: QueueContext, batch: QueueBatch): Promise<void> {
99
+ const handler = routeQueue(queues, batch.queue);
100
+ if (!handler) {
101
+ console.error(`pramen: no app.queues handler for queue '${batch.queue}' — retrying batch (declare it in app.queues)`);
102
+ batch.retryAll();
103
+ return;
104
+ }
105
+ await Promise.all(
106
+ batch.messages.map(async (message) => {
107
+ try {
108
+ await handler(ctx, message);
109
+ message.ack();
110
+ } catch (err) {
111
+ console.error(`pramen: queue '${batch.queue}' message ${message.id} failed (attempt ${message.attempts}) — retrying`, err);
112
+ message.retry();
113
+ }
114
+ }),
115
+ );
116
+ }