run-dmcp 0.1.0 → 0.3.0

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 (141) hide show
  1. package/README.md +101 -11
  2. package/dist/bin/run-dmcp.d.ts +2 -0
  3. package/dist/bin/run-dmcp.js +55 -0
  4. package/dist/db/connection.d.ts +32 -0
  5. package/dist/db/connection.js +38 -16
  6. package/dist/db/schema.d.ts +29 -1
  7. package/dist/db/schema.js +594 -10
  8. package/dist/http/server.js +25 -4
  9. package/dist/index.d.ts +69 -2
  10. package/dist/index.js +262 -92
  11. package/dist/mcp-server.d.ts +49 -0
  12. package/dist/mcp-server.js +127 -0
  13. package/dist/reader/turnReader.d.ts +185 -0
  14. package/dist/reader/turnReader.js +288 -0
  15. package/dist/register/batch.js +5 -79
  16. package/dist/register/mcp-resources.d.ts +9 -0
  17. package/dist/register/mcp-resources.js +16 -62
  18. package/dist/register/render.d.ts +17 -0
  19. package/dist/register/render.js +50 -0
  20. package/dist/register/resolve.d.ts +14 -0
  21. package/dist/register/resolve.js +102 -0
  22. package/dist/register/resources.js +13 -6
  23. package/dist/register/timeline.d.ts +2 -0
  24. package/dist/register/timeline.js +311 -0
  25. package/dist/rpg/index.d.ts +29 -0
  26. package/dist/rpg/index.js +55 -0
  27. package/dist/rpg/register/abilities.d.ts +2 -0
  28. package/dist/rpg/register/abilities.js +165 -0
  29. package/dist/rpg/register/batch.d.ts +2 -0
  30. package/dist/rpg/register/batch.js +92 -0
  31. package/dist/rpg/register/combat.d.ts +2 -0
  32. package/dist/rpg/register/combat.js +207 -0
  33. package/dist/rpg/register/mcp-prompts.d.ts +2 -0
  34. package/dist/rpg/register/mcp-prompts.js +684 -0
  35. package/dist/rpg/register/mcp-resources.d.ts +2 -0
  36. package/dist/rpg/register/mcp-resources.js +61 -0
  37. package/dist/rpg/register/quests.d.ts +2 -0
  38. package/dist/rpg/register/quests.js +118 -0
  39. package/dist/rpg/register/status.d.ts +2 -0
  40. package/dist/rpg/register/status.js +130 -0
  41. package/dist/rpg/register/tables.d.ts +2 -0
  42. package/dist/rpg/register/tables.js +146 -0
  43. package/dist/rpg/tools/ability.d.ts +48 -0
  44. package/dist/rpg/tools/ability.js +238 -0
  45. package/dist/rpg/tools/combat.d.ts +13 -0
  46. package/dist/rpg/tools/combat.js +195 -0
  47. package/dist/rpg/tools/dice.d.ts +23 -0
  48. package/dist/rpg/tools/dice.js +111 -0
  49. package/dist/rpg/tools/quest.d.ts +34 -0
  50. package/dist/rpg/tools/quest.js +164 -0
  51. package/dist/rpg/tools/status.d.ts +36 -0
  52. package/dist/rpg/tools/status.js +218 -0
  53. package/dist/rpg/tools/tables.d.ts +33 -0
  54. package/dist/rpg/tools/tables.js +209 -0
  55. package/dist/schemas/index.d.ts +12 -12
  56. package/dist/timeline/adjudication.d.ts +150 -0
  57. package/dist/timeline/adjudication.js +174 -0
  58. package/dist/timeline/changes.d.ts +108 -0
  59. package/dist/timeline/changes.js +169 -0
  60. package/dist/timeline/checkpoint.d.ts +69 -0
  61. package/dist/timeline/checkpoint.js +131 -0
  62. package/dist/timeline/clock.d.ts +89 -0
  63. package/dist/timeline/clock.js +173 -0
  64. package/dist/timeline/constrained.d.ts +220 -0
  65. package/dist/timeline/constrained.js +671 -0
  66. package/dist/timeline/export.d.ts +181 -0
  67. package/dist/timeline/export.js +339 -0
  68. package/dist/timeline/irreversible.d.ts +87 -0
  69. package/dist/timeline/irreversible.js +108 -0
  70. package/dist/timeline/kinds.d.ts +14 -0
  71. package/dist/timeline/kinds.js +22 -0
  72. package/dist/timeline/narration.d.ts +175 -0
  73. package/dist/timeline/narration.js +259 -0
  74. package/dist/timeline/projection.d.ts +97 -0
  75. package/dist/timeline/projection.js +330 -0
  76. package/dist/timeline/provenance.d.ts +66 -0
  77. package/dist/timeline/provenance.js +45 -0
  78. package/dist/timeline/registry.d.ts +95 -0
  79. package/dist/timeline/registry.js +124 -0
  80. package/dist/timeline/render.d.ts +121 -0
  81. package/dist/timeline/render.js +187 -0
  82. package/dist/timeline/replay.d.ts +86 -0
  83. package/dist/timeline/replay.js +126 -0
  84. package/dist/timeline/resolve.d.ts +262 -0
  85. package/dist/timeline/resolve.js +226 -0
  86. package/dist/timeline/schema.d.ts +13 -0
  87. package/dist/timeline/schema.js +264 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/audio.js +13 -9
  91. package/dist/tools/constraint.d.ts +44 -80
  92. package/dist/tools/constraint.js +115 -124
  93. package/dist/tools/game.js +33 -1
  94. package/dist/tools/images.js +17 -10
  95. package/dist/tools/relationship.d.ts +83 -2
  96. package/dist/tools/relationship.js +139 -62
  97. package/dist/tools/resource.d.ts +33 -8
  98. package/dist/tools/resource.js +106 -153
  99. package/dist/tools/time.js +18 -3
  100. package/dist/types/index.d.ts +20 -2
  101. package/dist/utils/media-path.d.ts +52 -0
  102. package/dist/utils/media-path.js +106 -0
  103. package/dist/utils/output-schemas.d.ts +594 -3
  104. package/dist/utils/output-schemas.js +4 -1
  105. package/dist/utils/webui.d.ts +32 -0
  106. package/dist/utils/webui.js +54 -1
  107. package/package.json +25 -5
  108. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  109. package/dist/__tests__/engineVocabulary.test.js +0 -147
  110. package/dist/db/__tests__/connection.test.d.ts +0 -1
  111. package/dist/db/__tests__/connection.test.js +0 -72
  112. package/dist/db/__tests__/testDb.d.ts +0 -33
  113. package/dist/db/__tests__/testDb.js +0 -41
  114. package/dist/test-setup.d.ts +0 -1
  115. package/dist/test-setup.js +0 -13
  116. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  117. package/dist/tools/__tests__/audio.test.js +0 -59
  118. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  119. package/dist/tools/__tests__/conserved.test.js +0 -488
  120. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  121. package/dist/tools/__tests__/constraint.test.js +0 -212
  122. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  123. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  124. package/dist/tools/__tests__/images.test.d.ts +0 -1
  125. package/dist/tools/__tests__/images.test.js +0 -59
  126. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  127. package/dist/tools/__tests__/relationship.test.js +0 -132
  128. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  129. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  130. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  131. package/dist/tools/__tests__/resource.test.js +0 -190
  132. package/dist/tools/__tests__/time.test.d.ts +0 -1
  133. package/dist/tools/__tests__/time.test.js +0 -404
  134. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  135. package/dist/tools/__tests__/timers.test.js +0 -426
  136. package/dist/tools/__tests__/world.test.d.ts +0 -1
  137. package/dist/tools/__tests__/world.test.js +0 -70
  138. package/dist/utils/__tests__/json.test.d.ts +0 -1
  139. package/dist/utils/__tests__/json.test.js +0 -55
  140. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  141. package/dist/utils/__tests__/validation.test.js +0 -90
package/dist/db/schema.js CHANGED
@@ -1,5 +1,52 @@
1
- import { getDatabase } from "./connection.js";
2
- export function initializeSchema() {
1
+ import { getDatabase, withTransaction } from "./connection.js";
2
+ import { createLogger } from "../utils/logger.js";
3
+ import { initializeTimelineSchema } from "../timeline/schema.js";
4
+ import { initializeAdjudicationSchema } from "../timeline/adjudication.js";
5
+ const log = createLogger("schema");
6
+ /**
7
+ * Run one whole-table CHECK rebuild with foreign-key enforcement suspended,
8
+ * and verify afterwards that nothing was left dangling.
9
+ *
10
+ * SQLite cannot ALTER a CHECK, so widening one means rebuilding the table:
11
+ * copy the rows aside, DROP, CREATE with the new CHECK, copy back. Two
12
+ * migrations in this file do that, and both need the same two guarantees.
13
+ *
14
+ * ENFORCEMENT MUST BE OFF ACROSS THE DROP. With it on, `DROP TABLE` performs
15
+ * an implicit per-row DELETE first, precisely so that any ON DELETE action
16
+ * declared against that table fires as though each row had genuinely been
17
+ * deleted -- which for both of these tables means cascade-emptying the tables
18
+ * that reference them. Suspending it is what makes the drop a schema
19
+ * operation instead of a silent mass deletion. It is toggled out here rather
20
+ * than inside `withTransaction`, because `PRAGMA foreign_keys` is a
21
+ * documented no-op while a transaction is pending.
22
+ *
23
+ * AND IT MUST GO BACK ON WHEN THE REBUILD THROWS, which is why the restore is
24
+ * in a `finally` and why this is a shared function rather than two copies.
25
+ * Both call sites previously restored it on the success path only.
26
+ * `getDatabase()` caches one connection at module scope, so a rebuild that
27
+ * threw handed the rest of the process a handle with foreign keys still
28
+ * disabled -- and a disabled foreign key does not announce itself. It means
29
+ * every ON DELETE CASCADE in this schema quietly stops working: deleting a
30
+ * game orphans its characters, resources, locations and secrets instead of
31
+ * taking them with it, and nothing errors. The scenario is not exotic -- a
32
+ * staging table left behind by a rebuild that died partway is exactly what
33
+ * makes the next startup's `CREATE TABLE ..._staging` throw. Covered by
34
+ * `src/db/__tests__/foreignKeysRestored.test.ts`.
35
+ */
36
+ function rebuildWithForeignKeysSuspended(db, label, rebuild) {
37
+ db.pragma("foreign_keys = OFF");
38
+ try {
39
+ withTransaction(rebuild);
40
+ const violations = db.pragma("foreign_key_check");
41
+ if (violations.length > 0) {
42
+ throw new Error(`${label} CHECK migration left dangling foreign keys: ` + JSON.stringify(violations));
43
+ }
44
+ }
45
+ finally {
46
+ db.pragma("foreign_keys = ON");
47
+ }
48
+ }
49
+ export function initializeSchema(options) {
3
50
  const db = getDatabase();
4
51
  // ============================================================================
5
52
  // MIGRATION: Rename 'sessions' to 'games' and 'session_id' to 'game_id'
@@ -199,12 +246,22 @@ export function initializeSchema() {
199
246
  )
200
247
  `);
201
248
  // Resources table (for tracking currency, reputation, counters, etc.)
202
- db.exec(`
249
+ //
250
+ // `RESOURCES_DDL` is a shared constant, not a literal inlined here, for the
251
+ // same reason `RESOURCE_CONSTRAINTS_DDL` further down is one: the CHECK-
252
+ // rebuild migration immediately below needs this exact text executed in
253
+ // TWO places -- here, for a fresh database, and again inside the rebuild,
254
+ // for a database that still carries the OLD two-member CHECK. Sharing one
255
+ // JS string is what makes "a fresh database and a migrated database
256
+ // converge on byte-identical `sqlite_master.sql` for this table" true BY
257
+ // CONSTRUCTION rather than by two hand-written literals happening to agree
258
+ // today.
259
+ const RESOURCES_DDL = `
203
260
  CREATE TABLE IF NOT EXISTS resources (
204
261
  id TEXT PRIMARY KEY,
205
262
  game_id TEXT NOT NULL,
206
263
  owner_id TEXT,
207
- owner_type TEXT NOT NULL CHECK (owner_type IN ('game', 'character')),
264
+ owner_type TEXT NOT NULL CHECK (owner_type IN ('game', 'character', 'faction', 'location')),
208
265
  name TEXT NOT NULL,
209
266
  description TEXT,
210
267
  category TEXT,
@@ -214,8 +271,118 @@ export function initializeSchema() {
214
271
  created_at TEXT NOT NULL,
215
272
  FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
216
273
  )
217
- `);
218
- // Resource history table (tracks all changes)
274
+ `;
275
+ db.exec(RESOURCES_DDL);
276
+ // Migration: widen `resources.owner_type` to admit 'faction' and
277
+ // 'location' alongside 'game' and 'character'. A resource owned by a
278
+ // faction or a location is generic mechanism -- the engine already has
279
+ // `factions` and `locations` tables; this just lets `resources` point at
280
+ // either the way it already points at a `game` or a `character` -- so it
281
+ // belongs here rather than behind a downstream application's own migration.
282
+ //
283
+ // SQLite cannot ALTER a CHECK constraint, so a database that already has
284
+ // `resources` rows under the OLD two-member CHECK needs the same full-
285
+ // table-rebuild recipe the `resource_constraints` CHECK-widening migration
286
+ // uses further down this function (see that block's comment for the
287
+ // detailed reasoning this one leans on): build a replacement table with
288
+ // the new CHECK, copy every row across, drop the old table, put the
289
+ // replacement in its place.
290
+ //
291
+ // DETECTION IS IDEMPOTENT AND LITERAL, not a guess: read this codebase's
292
+ // OWN generated DDL back out of `sqlite_master` and check whether it
293
+ // already contains the token 'faction' -- the same "a literal check for a
294
+ // token we defined in output we generated is fine" carve-out the
295
+ // `resource_constraints` migration's own comment cites (hard rule 4 in the
296
+ // downstream game's own engineering standards; this engine has no
297
+ // narrative-language rule of its own to point at, but the reasoning is the
298
+ // same: this is a substring check against SQL text THIS FUNCTION generated
299
+ // a few lines above, never against anything a player or a model wrote). A
300
+ // truly fresh database never takes the branch below: `RESOURCES_DDL`'s
301
+ // `CREATE TABLE IF NOT EXISTS` a few lines up already carries the widened
302
+ // CHECK, so by the time this runs, this database's own `resources` already
303
+ // contains 'faction'.
304
+ const resourcesDdl = db
305
+ .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'resources'`)
306
+ .get();
307
+ if (resourcesDdl && !resourcesDdl.sql.includes("faction")) {
308
+ // DROP THE PROJECTION TRIGGERS FIRST, UNCONDITIONALLY, IF THEY EXIST.
309
+ // `timeline_resources_ai`/`_au`/`_ad` are defined ON `resources` itself
310
+ // (`AFTER INSERT/UPDATE/DELETE ON resources`), so SQLite drops them
311
+ // automatically the moment `DROP TABLE resources` below runs -- but
312
+ // dropping them here too, explicitly, costs nothing and removes any
313
+ // dependence on that implicit behaviour being exactly right. They are
314
+ // unconditionally reinstalled, generated fresh off the rebuilt table's
315
+ // own `pragma_table_info`, by `installProjectionTriggers()`
316
+ // (`src/timeline/projection.ts`), which `initializeTimelineSchema()`
317
+ // calls LAST in this function -- see the comment on that call for why it
318
+ // runs after every migration above it, this one included.
319
+ db.exec(`DROP TRIGGER IF EXISTS timeline_resources_ai`);
320
+ db.exec(`DROP TRIGGER IF EXISTS timeline_resources_au`);
321
+ db.exec(`DROP TRIGGER IF EXISTS timeline_resources_ad`);
322
+ // Enforcement has to be suspended across the drop, and put back
323
+ // afterwards on every path -- see `rebuildWithForeignKeysSuspended`. Here
324
+ // the tables that would be cascade-emptied are `resource_history` and
325
+ // `resource_constraint_members`, both of which declare
326
+ // `FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE`.
327
+ rebuildWithForeignKeysSuspended(db, "resources.owner_type", () => {
328
+ // 1. Copy the OLD table's rows into a staging table under a temporary
329
+ // name, already carrying the widened CHECK.
330
+ db.exec(`
331
+ CREATE TABLE resources_staging (
332
+ id TEXT PRIMARY KEY,
333
+ game_id TEXT NOT NULL,
334
+ owner_id TEXT,
335
+ owner_type TEXT NOT NULL CHECK (owner_type IN ('game', 'character', 'faction', 'location')),
336
+ name TEXT NOT NULL,
337
+ description TEXT,
338
+ category TEXT,
339
+ value REAL NOT NULL DEFAULT 0,
340
+ min_value REAL,
341
+ max_value REAL,
342
+ created_at TEXT NOT NULL
343
+ )
344
+ `);
345
+ db.exec(`
346
+ INSERT INTO resources_staging (id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at)
347
+ SELECT id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at FROM resources
348
+ `);
349
+ // 2. Drop the OLD table outright -- not renamed. Renaming it out of
350
+ // the way first would rewrite `resource_history` and
351
+ // `resource_constraint_members`'s own stored FOREIGN KEY clauses to
352
+ // point at the temporary name (RENAME TO rewrites every OTHER
353
+ // table's FK text that references the renamed table, regardless of
354
+ // the `foreign_keys` pragma), leaving them permanently dangling
355
+ // once that temporary table is dropped a few steps later. `DROP
356
+ // TABLE`, unlike `RENAME TO`, does not rewrite other tables'
357
+ // references -- there is nothing to rewrite them TO -- so this
358
+ // recipe never renames the table other tables' foreign keys point
359
+ // at; the FINAL name is produced by a genuine `CREATE TABLE`
360
+ // instead, and `resource_history`/`resource_constraint_members`'s
361
+ // FK text is never touched by anything in this block.
362
+ db.exec(`DROP TABLE resources`);
363
+ // 3. Recreate under the FINAL name using the exact same DDL text the
364
+ // fresh-database path executed above -- `RESOURCES_DDL` itself, not
365
+ // a second hand-copied literal -- so its stored SQL matches the
366
+ // fresh-database path byte for byte.
367
+ db.exec(RESOURCES_DDL);
368
+ // 4. Copy every row back across from the staging table with an
369
+ // EXPLICIT column list -- never SELECT * -- and drop the staging
370
+ // table. Every id was copied verbatim, so `resource_history` and
371
+ // `resource_constraint_members`'s own `FOREIGN KEY (resource_id)
372
+ // REFERENCES resources(id)` -- never touched by any of the steps
373
+ // above -- is satisfied by the replacement table throughout,
374
+ // verified for real by the `PRAGMA foreign_key_check` below, not
375
+ // merely assumed here.
376
+ db.exec(`
377
+ INSERT INTO resources (id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at)
378
+ SELECT id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at FROM resources_staging
379
+ `);
380
+ db.exec(`DROP TABLE resources_staging`);
381
+ });
382
+ }
383
+ // Resource history table (tracks all changes) -- FROZEN, see the trigger
384
+ // immediately below. Kept for existing rows only; nothing writes here any
385
+ // more.
219
386
  db.exec(`
220
387
  CREATE TABLE IF NOT EXISTS resource_history (
221
388
  id TEXT PRIMARY KEY,
@@ -227,22 +394,272 @@ export function initializeSchema() {
227
394
  timestamp TEXT NOT NULL,
228
395
  FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
229
396
  )
397
+ `);
398
+ // Design §5.4 option (C) / issue #9, Phase 3: `resource_history` stops
399
+ // being a mechanism. Interval-versioned `facts` (via the timeline's one
400
+ // choke point, writeConstrainedValue/transferConstrainedValue in
401
+ // src/timeline/constrained.ts) are now the ONLY record of what a
402
+ // resource's value used to be -- see valueHistory() there, and
403
+ // getResourceHistory() in src/tools/resource.ts, which reads through it.
404
+ // This trigger makes the second write path this project spent its whole
405
+ // history accumulating (see engineVocabulary.test.ts's epigraph on
406
+ // resource_history/relationship_history) UNCONSTRUCTABLE rather than
407
+ // merely undocumented: any INSERT here -- from old code nobody rewrote,
408
+ // from a copy-pasted query, from anything -- aborts loudly instead of
409
+ // silently reintroducing a second history.
410
+ //
411
+ // The table is frozen, not dropped. This project has no down-migrations
412
+ // (root CLAUDE.md) and no framework beyond "idempotent DDL runs on every
413
+ // startup" -- a migration that DROPped this table would destroy rows a
414
+ // user's existing database may still hold, permanently, the first time
415
+ // they upgraded, which is a strictly worse outcome than a table that
416
+ // simply stops growing. Nothing reads it (getResourceHistory() no longer
417
+ // does) and nothing writes it (this trigger); the rows already on disk
418
+ // are inert history, not a mechanism.
419
+ //
420
+ // DROP TRIGGER IF EXISTS then CREATE, not CREATE TRIGGER IF NOT EXISTS --
421
+ // the same reasoning src/timeline/schema.ts's append-only guards give for
422
+ // their own triggers: IF NOT EXISTS would freeze whatever guard first
423
+ // shipped for a given on-disk database forever, so a later fix to this
424
+ // trigger's logic or message would silently never reach a database that
425
+ // already had an older version installed. Dropping and recreating on
426
+ // every startup keeps the guard a database actually has in sync with the
427
+ // guard this build believes it deployed.
428
+ db.exec(`
429
+ DROP TRIGGER IF EXISTS resource_history_frozen;
430
+ CREATE TRIGGER resource_history_frozen BEFORE INSERT ON resource_history
431
+ BEGIN
432
+ SELECT RAISE(ABORT, 'resource_history is frozen -- interval-versioned facts are now the only record of what a resource value used to be (design section 5.4 option C); write through writeConstrainedValue in src/timeline/constrained.ts instead');
433
+ END;
230
434
  `);
231
435
  // Resource constraints table -- optional, server-enforced invariants on
232
436
  // resource values (see src/tools/constraint.ts). Opt-in: a resource with
233
437
  // no row here (directly or via resource_constraint_members) behaves
234
438
  // exactly as before this feature existed.
235
- db.exec(`
439
+ //
440
+ // `RESOURCE_CONSTRAINTS_DDL` is a shared constant, not a literal inlined
441
+ // here, because issue #13's `resolve_only` (design §5.3's fourth
442
+ // row-based member) needs this exact CHECK-widened text executed in TWO
443
+ // places: here, for a fresh database, and again inside the CHECK-rebuild
444
+ // migration further below, for a database that still carries the OLD
445
+ // three-member CHECK (SQLite cannot ALTER a CHECK constraint -- widening
446
+ // one needs a full table rebuild; see that block's comment for the
447
+ // recipe). Sharing one JS string is what makes "a fresh database and a
448
+ // migrated database converge on byte-identical `sqlite_master.sql` for
449
+ // this table" true BY CONSTRUCTION rather than by two hand-written
450
+ // literals happening to agree today -- resolveOnly.test.ts asserts
451
+ // exactly that equality, and it would be a permanent, silent trap to
452
+ // maintain as two copies.
453
+ const RESOURCE_CONSTRAINTS_DDL = `
236
454
  CREATE TABLE IF NOT EXISTS resource_constraints (
237
455
  id TEXT PRIMARY KEY,
238
456
  game_id TEXT NOT NULL,
239
- kind TEXT NOT NULL CHECK (kind IN ('bounded', 'monotonic', 'conserved')),
457
+ kind TEXT NOT NULL CHECK (kind IN ('bounded', 'monotonic', 'conserved', 'resolve_only')),
240
458
  direction TEXT CHECK (direction IN ('increasing', 'decreasing')),
241
459
  total REAL,
242
460
  created_at TEXT NOT NULL,
243
461
  FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
244
462
  )
245
- `);
463
+ `;
464
+ db.exec(RESOURCE_CONSTRAINTS_DDL);
465
+ // Add fact_key column to resource_constraints (migration, Phase 3 / issue
466
+ // #9 step 1, design §5.4 option (C)). A constraint governs a numeric
467
+ // *fact key* on an entity, not an entire row -- once the generic
468
+ // (entityId, factKey) choke point this column exists to prepare for
469
+ // lands, an entity can carry more than one numeric fact, and a constraint
470
+ // must say which one it governs or it would silently apply to all of
471
+ // them. 'value' is the default because every constraint that exists
472
+ // today (declared through declareBoundedConstraint/declareMonotonicConstraint/
473
+ // declareConservedConstraint in src/tools/constraint.ts) governs
474
+ // `resources.value` -- the sole numeric fact key any of them has ever
475
+ // constrained -- so defaulting means an existing database needs no data
476
+ // migration to keep reading its own constraints correctly. This column belongs on
477
+ // resource_constraints (the constraint), NOT on resource_constraint_members
478
+ // below: a 'conserved' set's members all share one key by construction
479
+ // (they are summed against a single total), and putting it on the member
480
+ // row would permit a set whose members are constrained on different
481
+ // keys -- a generality nothing has asked for.
482
+ //
483
+ // Shared for the same reason RESOURCE_CONSTRAINTS_DDL is above: the
484
+ // CHECK-rebuild migration below re-adds this exact column to its
485
+ // replacement table, and reusing the identical statement text (rather
486
+ // than a second hand-written ALTER) is what makes the fresh and migrated
487
+ // paths' final stored SQL provably identical instead of coincidentally
488
+ // similar.
489
+ const RESOURCE_CONSTRAINTS_ADD_FACT_KEY_DDL = `ALTER TABLE resource_constraints ADD COLUMN fact_key TEXT NOT NULL DEFAULT 'value'`;
490
+ try {
491
+ db.exec(RESOURCE_CONSTRAINTS_ADD_FACT_KEY_DDL);
492
+ }
493
+ catch {
494
+ // Column already exists
495
+ }
496
+ // Issue #13 / design §5.3, §5.4 option (C): widen resource_constraints'
497
+ // `kind` CHECK to admit 'resolve_only', the fourth row-based constraint
498
+ // family member. SQLite cannot ALTER a CHECK constraint -- unlike the
499
+ // fact_key column just above, there is no `ALTER TABLE ... ADD` for
500
+ // "loosen this CHECK" -- so a database that already has resource_constraints
501
+ // rows under the OLD three-member CHECK needs SQLite's own documented
502
+ // recipe for "other kinds of table schema changes": build a replacement
503
+ // table with the new CHECK, copy every row across, drop the old table, and
504
+ // put the replacement in its place. This is the riskiest part of issue #13
505
+ // precisely because it is a real ALTER on a table other code already
506
+ // depends on (resource_constraint_members' own FOREIGN KEY points at it),
507
+ // so every step below is deliberate -- see resolveOnly.test.ts for the
508
+ // "an existing database survives the rebuild with every row and every FK
509
+ // intact" test this comment describes.
510
+ //
511
+ // DETECTION IS IDEMPOTENT AND LITERAL, not a guess: read this codebase's
512
+ // OWN generated DDL back out of `sqlite_master` and check whether it
513
+ // already contains the token 'resolve_only'. Hard rule 4 (never
514
+ // pattern-match meaning) forbids deriving state by matching words or
515
+ // phrases against natural language -- narrative text a model wrote, or a
516
+ // player typed. This is neither: it is a literal substring check against
517
+ // SQL text THIS FUNCTION generated a few lines above, for a token THIS
518
+ // FUNCTION defined (the CHECK's own enum member). Rule 4 explicitly
519
+ // permits exactly that ("a literal check for a token we defined in output
520
+ // we generated is fine; understanding English is not") -- flagged here in
521
+ // as many words because a future reader skimming a string-literal
522
+ // `.includes()` check might otherwise mistake it for the thing rule 4
523
+ // bans. A truly fresh database never takes the branch below:
524
+ // RESOURCE_CONSTRAINTS_DDL's CREATE TABLE IF NOT EXISTS a few lines up
525
+ // already carries the widened CHECK, so by the time this runs, this
526
+ // database's own resource_constraints already contains 'resolve_only'.
527
+ const resourceConstraintsDdl = db
528
+ .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'resource_constraints'`)
529
+ .get();
530
+ if (resourceConstraintsDdl && !resourceConstraintsDdl.sql.includes("resolve_only")) {
531
+ // DROP THE resolve_only TRIGGER FIRST, UNCONDITIONALLY, IF IT EXISTS --
532
+ // made ORDER-INDEPENDENT rather than relying on "this trigger has never
533
+ // been created yet the one time this branch runs." That reliance was
534
+ // real on today's code (this block runs before initializeTimelineSchema()
535
+ // ever installs timeline_facts_resolve_only for the very first time, so
536
+ // on the one startup that performs a rebuild, the trigger genuinely does
537
+ // not exist yet) -- but it is an ACCIDENT OF ORDERING, not a structural
538
+ // guarantee, and root CLAUDE.md's "test against an existing database,
539
+ // not just a fresh one" warning exists precisely because accidents of
540
+ // ordering are how this kind of bug survives review. The trigger's own
541
+ // `WHEN` clause names `resource_constraints`, and SQLite revalidates
542
+ // every trigger body during schema surgery on a table it references
543
+ // (measured, not assumed -- see resolveOnly.test.ts, whose migration
544
+ // fixture hit exactly this: `ALTER TABLE ... RENAME TO resource_constraints`
545
+ // failed with "no such table: resource_constraints" while this trigger
546
+ // still existed and the table was transiently absent mid-rebuild) --
547
+ // so a database where the trigger was somehow installed before a rebuild
548
+ // runs (a future change that reorders trigger installation, or widens
549
+ // this CHECK a second time some other way) would break at startup with
550
+ // the schema half-rebuilt. Dropping it here costs nothing: the
551
+ // DROP-then-CREATE discipline every trigger in this codebase already
552
+ // follows (see the trigger's own creation site, far below, and the
553
+ // reasoning timeline_facts_irreversible gives for the same pattern)
554
+ // means it is unconditionally reinstalled before this function returns
555
+ // regardless -- this is that discipline paying for itself a second time.
556
+ db.exec(`DROP TRIGGER IF EXISTS timeline_facts_resolve_only`);
557
+ // Enforcement has to be suspended across the drop, and put back
558
+ // afterwards on every path -- see `rebuildWithForeignKeysSuspended`. Here
559
+ // the table that would be cascade-emptied is `resource_constraint_members`,
560
+ // which declares
561
+ // `FOREIGN KEY (constraint_id) REFERENCES resource_constraints(id) ON DELETE CASCADE`.
562
+ rebuildWithForeignKeysSuspended(db, "resource_constraints (resolve_only, issue #13)", () => {
563
+ // EMPIRICALLY MEASURED, NOT ASSUMED (see resolveOnly.test.ts, whose
564
+ // FK-check assertion caught a real bug in an earlier version of this
565
+ // block): `ALTER TABLE ... RENAME TO` does not just rename the table
566
+ // being renamed -- it also rewrites the stored FOREIGN KEY clause of
567
+ // every OTHER table that references it by name (here,
568
+ // resource_constraint_members' `FOREIGN KEY (constraint_id)
569
+ // REFERENCES resource_constraints(id)`), REGARDLESS of the
570
+ // `foreign_keys` pragma above. Renaming the OLD table out of the way
571
+ // (the obvious first move) would therefore silently rewrite
572
+ // resource_constraint_members to reference the OLD table's new,
573
+ // temporary name -- and once that old table is dropped a few steps
574
+ // later, that reference is permanently dangling, with no further
575
+ // rename ever pointed at it to fix it back. `DROP TABLE`, unlike
576
+ // `RENAME TO`, does NOT rewrite other tables' references (there is
577
+ // nothing to rewrite them TO), so this recipe routes around the
578
+ // problem entirely: the old table is copied out of and then DROPped
579
+ // (never renamed), and the FINAL name is produced by a genuine
580
+ // `CREATE TABLE` (also never a rename target) -- so
581
+ // resource_constraint_members' FK text is never touched by anything
582
+ // in this block, and is simply valid again the moment step 4 below
583
+ // recreates a table under the name it always pointed at.
584
+ //
585
+ // 1. Copy the OLD table's rows into a staging table under a temporary
586
+ // name -- widened CHECK, and fact_key included from the start
587
+ // (unlike the final table a few steps down, this one is never
588
+ // compared to RESOURCE_CONSTRAINTS_DDL's stored text, so there is
589
+ // no reason to reproduce the fresh path's "add fact_key later via
590
+ // ALTER" shape here).
591
+ db.exec(`
592
+ CREATE TABLE resource_constraints_staging (
593
+ id TEXT PRIMARY KEY,
594
+ game_id TEXT NOT NULL,
595
+ kind TEXT NOT NULL CHECK (kind IN ('bounded', 'monotonic', 'conserved', 'resolve_only')),
596
+ direction TEXT CHECK (direction IN ('increasing', 'decreasing')),
597
+ total REAL,
598
+ created_at TEXT NOT NULL,
599
+ fact_key TEXT NOT NULL DEFAULT 'value'
600
+ )
601
+ `);
602
+ db.exec(`
603
+ INSERT INTO resource_constraints_staging (id, game_id, kind, direction, total, created_at, fact_key)
604
+ SELECT id, game_id, kind, direction, total, created_at, fact_key FROM resource_constraints
605
+ `);
606
+ // 2. Drop the OLD table outright -- not renamed, per the note above.
607
+ db.exec(`DROP TABLE resource_constraints`);
608
+ // 3. Recreate under the FINAL name using the exact same DDL text the
609
+ // fresh-database path executed above -- RESOURCE_CONSTRAINTS_DDL
610
+ // itself, not a second hand-copied literal. This is a genuine
611
+ // `CREATE TABLE resource_constraints (...)`, never a rename
612
+ // target, so its stored SQL stays unquoted -- matching the
613
+ // fresh-database path byte for byte (verified empirically, see
614
+ // resolveOnly.test.ts's "byte-identical" assertion) rather than
615
+ // picking up RENAME TO's habit of re-quoting the identifier.
616
+ db.exec(RESOURCE_CONSTRAINTS_DDL);
617
+ // 4. Copy every row across from the staging table with an EXPLICIT
618
+ // column list -- never SELECT * -- naming every column except
619
+ // fact_key, which the just-recreated table does not have yet. It
620
+ // is added next, the same way the fresh path adds it.
621
+ db.exec(`
622
+ INSERT INTO resource_constraints (id, game_id, kind, direction, total, created_at)
623
+ SELECT id, game_id, kind, direction, total, created_at FROM resource_constraints_staging
624
+ `);
625
+ // 5. Add fact_key via the IDENTICAL statement text the fresh path
626
+ // uses (RESOURCE_CONSTRAINTS_ADD_FACT_KEY_DDL, not a hand-written
627
+ // equivalent). This is what makes the two paths' stored SQL
628
+ // provably byte-identical after this point: SQLite's ALTER TABLE
629
+ // ADD COLUMN rewrites a table's stored CREATE TABLE text by
630
+ // inserting the new column definition right after the last
631
+ // existing column and before any table-level constraint (verified
632
+ // empirically, not assumed -- see resolveOnly.test.ts) -- running
633
+ // the same statement against two structurally identical tables
634
+ // produces the same rewritten text, with no need to hand-guess
635
+ // that formatting.
636
+ db.exec(RESOURCE_CONSTRAINTS_ADD_FACT_KEY_DDL);
637
+ // 6. The ALTER above just backfilled every just-copied row's fact_key
638
+ // with its DEFAULT 'value' -- correct for every row that can exist
639
+ // today (every declare*Constraint() function other than
640
+ // declareResolveOnlyConstraint hardcodes factKey 'value', and
641
+ // declareResolveOnlyConstraint cannot have written a row before
642
+ // this migration exists to run), but this UPDATE restores each
643
+ // row's ACTUAL prior fact_key from the staging table by id rather
644
+ // than leaning on that staying true forever. An UPDATE never
645
+ // touches sqlite_master, so it cannot disturb the byte-identical
646
+ // DDL text step 5 just produced.
647
+ db.exec(`
648
+ UPDATE resource_constraints
649
+ SET fact_key = (
650
+ SELECT fact_key FROM resource_constraints_staging
651
+ WHERE resource_constraints_staging.id = resource_constraints.id
652
+ )
653
+ `);
654
+ // 7. Drop the staging table. Every id was copied verbatim in step 4,
655
+ // so resource_constraint_members' FOREIGN KEY (constraint_id)
656
+ // REFERENCES resource_constraints(id) -- never touched by any of
657
+ // the steps above -- is satisfied by the replacement table
658
+ // throughout, verified for real by the PRAGMA foreign_key_check
659
+ // below, not merely assumed here.
660
+ db.exec(`DROP TABLE resource_constraints_staging`);
661
+ });
662
+ }
246
663
  // Members of a resource constraint. 'bounded' and 'monotonic' constraints
247
664
  // have exactly one member (the resource they govern); 'conserved'
248
665
  // constraints have two or more (the set that must sum to a fixed total).
@@ -255,6 +672,12 @@ export function initializeSchema() {
255
672
  FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
256
673
  )
257
674
  `);
675
+ // The adjudication window (design §5.3, §5.2a; issue #13): created here,
676
+ // BEFORE `timeline_facts_resolve_only` (the trigger that reads it, added
677
+ // near the end of this function once `facts` exists) -- see
678
+ // src/timeline/adjudication.ts for the table's own doc comment, including
679
+ // why its startup pass unconditionally clears every row.
680
+ initializeAdjudicationSchema(db);
258
681
  // Game time table (one per game)
259
682
  db.exec(`
260
683
  CREATE TABLE IF NOT EXISTS game_time (
@@ -366,7 +789,8 @@ export function initializeSchema() {
366
789
  FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
367
790
  )
368
791
  `);
369
- // Relationship history table
792
+ // Relationship history table -- FROZEN, see the trigger immediately below.
793
+ // Kept for existing rows only; nothing writes here any more.
370
794
  db.exec(`
371
795
  CREATE TABLE IF NOT EXISTS relationship_history (
372
796
  id TEXT PRIMARY KEY,
@@ -377,6 +801,25 @@ export function initializeSchema() {
377
801
  timestamp TEXT NOT NULL,
378
802
  FOREIGN KEY (relationship_id) REFERENCES relationships(id) ON DELETE CASCADE
379
803
  )
804
+ `);
805
+ // Design §5.4 option (C) / issue #9, Phase 3 step 3: `relationship_history`
806
+ // stops being a mechanism, same as `resource_history` immediately above --
807
+ // see that trigger's comment for the shared reasoning (why frozen and not
808
+ // dropped, why DROP-then-CREATE on every startup). The one thing specific
809
+ // here: `relationships` was already a `PROJECTED_TABLES` row (projection.ts)
810
+ // before this trigger existed, so its `value` column was already being
811
+ // dual-written into interval-versioned `facts` -- this freeze is what makes
812
+ // that the ONLY record, by routing every relationship value write through
813
+ // writeConstrainedValue() (src/timeline/constrained.ts) and cutting off the
814
+ // second path this table represented. See valueHistory() there, and
815
+ // getRelationshipHistory() in src/tools/relationship.ts, which reads
816
+ // through it.
817
+ db.exec(`
818
+ DROP TRIGGER IF EXISTS relationship_history_frozen;
819
+ CREATE TRIGGER relationship_history_frozen BEFORE INSERT ON relationship_history
820
+ BEGIN
821
+ SELECT RAISE(ABORT, 'relationship_history is frozen -- interval-versioned facts are now the only record of what a relationship value used to be (design section 5.4 option C); write through writeConstrainedValue in src/timeline/constrained.ts instead');
822
+ END;
380
823
  `);
381
824
  // Factions table
382
825
  db.exec(`
@@ -722,4 +1165,145 @@ export function initializeSchema() {
722
1165
  CREATE INDEX IF NOT EXISTS idx_stored_audio_primary ON stored_audio(entity_id, entity_type, is_primary);
723
1166
  CREATE INDEX IF NOT EXISTS idx_stored_audio_voice_ref ON stored_audio(game_id, is_voice_reference);
724
1167
  `);
1168
+ // Consumer-registered migrations (see `SchemaMigration` above) run after
1169
+ // every core table above exists.
1170
+ runConsumerMigrations(db, options?.migrations);
1171
+ // Timeline substrate (design §5.1), and it must be LAST -- after the
1172
+ // consumer's migrations, not merely after this function's own DDL.
1173
+ //
1174
+ // The projection triggers are generated from a live `pragma_table_info`
1175
+ // read of each projected table (see timeline/projection.ts), so whatever
1176
+ // ran most recently is what they are built against. A consumer migration
1177
+ // that adds a column to a projected table therefore gets that column
1178
+ // projected as a fact key, and reconciliation backfills it, with no code
1179
+ // change on either side. Run this before `runConsumerMigrations` instead
1180
+ // and such a column would be silently absent from the timeline until some
1181
+ // later startup happened to regenerate -- which is precisely the drift the
1182
+ // Phase 1 checkpoint exists to catch, arriving through the one door the
1183
+ // engine hands a consumer.
1184
+ initializeTimelineSchema();
1185
+ // Issue #13 / design §5.3, §5.2a: `resolve_only`'s enforcement backstop --
1186
+ // the SQL-level twin of the `resolve_only` branch in assertConstraintsAllow()
1187
+ // (src/timeline/constrained.ts). That JS check covers every write that
1188
+ // goes through writeConstrainedValue/transferConstrainedValue; THIS
1189
+ // trigger is what makes a write that skips those functions entirely --
1190
+ // a raw `UPDATE resources SET value = ...`, old code nobody rewrote, a
1191
+ // copy-pasted query -- unconstructable rather than merely unchecked. It
1192
+ // has to live HERE, not inside initializeTimelineSchema() (src/timeline/schema.ts)
1193
+ // alongside timeline_facts_irreversible (the trigger it is modeled on):
1194
+ // it reads resource_constraints/resource_constraint_members, which are
1195
+ // src/db/schema.ts's own tables, AND facts, which initializeTimelineSchema()
1196
+ // just created a line above -- and initializeTimelineSchema() is the LAST
1197
+ // thing this function calls (see the comment above it), so `facts` does
1198
+ // not exist at any point before that call returns. Creating this trigger
1199
+ // any earlier would `CREATE TRIGGER` against a table that isn't there
1200
+ // yet.
1201
+ //
1202
+ // ORDERING REQUIREMENT, STATED EXPLICITLY SO NOBODY MOVES THIS LATER: this
1203
+ // CREATE must run AFTER resource_constraints, resource_constraint_members
1204
+ // and timeline_adjudications_open all exist (true from here on, since all
1205
+ // three are created earlier in this same function) AND after the CHECK
1206
+ // -rebuild migration above has already run for this call. The rebuild
1207
+ // block enforces the second half of that itself -- it unconditionally
1208
+ // `DROP TRIGGER IF EXISTS`s this exact trigger before doing any table
1209
+ // surgery, precisely so that surgery can never run while this trigger
1210
+ // still exists to have its `WHEN` clause revalidated mid-rebuild against a
1211
+ // table that is transiently missing (see that block's own comment). Do
1212
+ // not reorder this CREATE ahead of that block on the theory that "it
1213
+ // always ran after anyway" -- that theory is exactly what the rebuild
1214
+ // block's DROP now makes irrelevant, and reordering would silently
1215
+ // reintroduce the dependency the DROP exists to remove.
1216
+ //
1217
+ // SCOPED PER (entity_id, key), unlike timeline_facts_irreversible's
1218
+ // per-(entity_id, key) VALUE comparison -- resolve_only has no value to
1219
+ // compare, only a declaration to look up, via the same JOIN
1220
+ // registry.ts's constraintsFor() runs in JS: is there a 'resolve_only'
1221
+ // constraint whose member list includes NEW.entity_id and whose fact_key
1222
+ // matches NEW.key. AND conditional on the window: the second half of the
1223
+ // WHEN clause, `NOT EXISTS (SELECT 1 FROM timeline_adjudications_open)`,
1224
+ // reads the exact same table `adjudicationOpen()` (src/timeline/adjudication.js)
1225
+ // reads in JS -- see that module's doc comment for why this is one source
1226
+ // of truth read in two places, never two independent checks that happen
1227
+ // to agree.
1228
+ //
1229
+ // BEFORE INSERT ON facts, not UPDATE: facts are append-only
1230
+ // (timeline_facts_immutable, src/timeline/schema.ts), so a new value for a
1231
+ // key always arrives as a fresh INSERT. This is deliberately an INSERT
1232
+ // guard for the same reason timeline_facts_irreversible is one: closing an
1233
+ // interval is `UPDATE facts SET valid_to_t = ...`, a different table
1234
+ // event entirely, and is NOT a write of a new value -- it never reaches
1235
+ // this trigger. That is what keeps destroying an entity, or otherwise
1236
+ // closing a resolve_only-governed fact, legal even with no adjudication
1237
+ // window open: the engine records decisions, it does not impose policy on
1238
+ // top of them (hard rule 2), and refusing to let an entity be destroyed
1239
+ // because it happens to carry a resolve_only fact would be exactly that.
1240
+ //
1241
+ // DROP-then-CREATE, never CREATE TRIGGER IF NOT EXISTS -- the same
1242
+ // reasoning timeline_facts_irreversible and every other trigger in this
1243
+ // codebase give for themselves: IF NOT EXISTS would freeze whatever guard
1244
+ // first shipped for a given on-disk database forever, so a later fix to
1245
+ // this trigger's logic or wording would silently never reach a database
1246
+ // that already had an older version installed. Dropping and recreating on
1247
+ // every startup keeps the guard a database actually has in sync with the
1248
+ // guard this build believes it deployed.
1249
+ //
1250
+ // NOTE FOR THE NEXT READER OF reconcileTimeline() (src/timeline/projection.ts):
1251
+ // reconciliation runs at the end of every startup and, per column, closes
1252
+ // a diverged fact and opens a new one -- an INSERT that would hit this
1253
+ // very trigger for a resolve_only-governed key with no window open
1254
+ // (reconciliation is not an adjudicating call). projection.ts's
1255
+ // reconcileTable() carries its own guard for exactly that, modeled on the
1256
+ // one it already had for `irreversible` -- see the comment on its
1257
+ // close-UPDATE and open-INSERT statements.
1258
+ db.exec(`
1259
+ DROP TRIGGER IF EXISTS timeline_facts_resolve_only;
1260
+ CREATE TRIGGER timeline_facts_resolve_only
1261
+ BEFORE INSERT ON facts
1262
+ WHEN EXISTS (
1263
+ SELECT 1 FROM resource_constraints rc
1264
+ JOIN resource_constraint_members rcm ON rcm.constraint_id = rc.id
1265
+ WHERE rc.kind = 'resolve_only'
1266
+ AND rcm.resource_id = NEW.entity_id
1267
+ AND rc.fact_key = NEW.key
1268
+ ) AND NOT EXISTS (SELECT 1 FROM timeline_adjudications_open)
1269
+ BEGIN
1270
+ SELECT RAISE(ABORT, 'timeline: ''' || NEW.key || ''' on this entity is resolve_only-constrained; direct writes are refused -- this value can only change through the adjudicating call that opens the resolution window');
1271
+ END;
1272
+ `);
1273
+ }
1274
+ function runConsumerMigrations(db, migrations) {
1275
+ if (!migrations || migrations.length === 0) {
1276
+ return;
1277
+ }
1278
+ validateMigrations(migrations);
1279
+ for (const migration of migrations) {
1280
+ try {
1281
+ db.transaction(() => {
1282
+ migration.up(db);
1283
+ })();
1284
+ }
1285
+ catch (err) {
1286
+ log.error("Consumer schema migration failed", {
1287
+ migration: migration.name,
1288
+ error: err instanceof Error ? err.message : String(err),
1289
+ });
1290
+ throw new Error(`Schema migration '${migration.name}' failed`, { cause: err });
1291
+ }
1292
+ }
1293
+ }
1294
+ function validateMigrations(migrations) {
1295
+ const seen = new Set();
1296
+ for (const migration of migrations) {
1297
+ const name = migration?.name;
1298
+ if (typeof name !== "string" || name.trim().length === 0) {
1299
+ throw new Error(`Invalid schema migration: 'name' must be a non-empty string, got ${JSON.stringify(name)}`);
1300
+ }
1301
+ if (seen.has(name)) {
1302
+ throw new Error(`Duplicate schema migration name: '${name}'`);
1303
+ }
1304
+ seen.add(name);
1305
+ if (typeof migration.up !== "function") {
1306
+ throw new Error(`Schema migration '${name}' has no 'up' function`);
1307
+ }
1308
+ }
725
1309
  }