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.
- package/README.md +101 -11
- package/dist/bin/run-dmcp.d.ts +2 -0
- package/dist/bin/run-dmcp.js +55 -0
- package/dist/db/connection.d.ts +32 -0
- package/dist/db/connection.js +38 -16
- package/dist/db/schema.d.ts +29 -1
- package/dist/db/schema.js +594 -10
- package/dist/http/server.js +25 -4
- package/dist/index.d.ts +69 -2
- package/dist/index.js +262 -92
- package/dist/mcp-server.d.ts +49 -0
- package/dist/mcp-server.js +127 -0
- package/dist/reader/turnReader.d.ts +185 -0
- package/dist/reader/turnReader.js +288 -0
- package/dist/register/batch.js +5 -79
- package/dist/register/mcp-resources.d.ts +9 -0
- package/dist/register/mcp-resources.js +16 -62
- package/dist/register/render.d.ts +17 -0
- package/dist/register/render.js +50 -0
- package/dist/register/resolve.d.ts +14 -0
- package/dist/register/resolve.js +102 -0
- package/dist/register/resources.js +13 -6
- package/dist/register/timeline.d.ts +2 -0
- package/dist/register/timeline.js +311 -0
- package/dist/rpg/index.d.ts +29 -0
- package/dist/rpg/index.js +55 -0
- package/dist/rpg/register/abilities.d.ts +2 -0
- package/dist/rpg/register/abilities.js +165 -0
- package/dist/rpg/register/batch.d.ts +2 -0
- package/dist/rpg/register/batch.js +92 -0
- package/dist/rpg/register/combat.d.ts +2 -0
- package/dist/rpg/register/combat.js +207 -0
- package/dist/rpg/register/mcp-prompts.d.ts +2 -0
- package/dist/rpg/register/mcp-prompts.js +684 -0
- package/dist/rpg/register/mcp-resources.d.ts +2 -0
- package/dist/rpg/register/mcp-resources.js +61 -0
- package/dist/rpg/register/quests.d.ts +2 -0
- package/dist/rpg/register/quests.js +118 -0
- package/dist/rpg/register/status.d.ts +2 -0
- package/dist/rpg/register/status.js +130 -0
- package/dist/rpg/register/tables.d.ts +2 -0
- package/dist/rpg/register/tables.js +146 -0
- package/dist/rpg/tools/ability.d.ts +48 -0
- package/dist/rpg/tools/ability.js +238 -0
- package/dist/rpg/tools/combat.d.ts +13 -0
- package/dist/rpg/tools/combat.js +195 -0
- package/dist/rpg/tools/dice.d.ts +23 -0
- package/dist/rpg/tools/dice.js +111 -0
- package/dist/rpg/tools/quest.d.ts +34 -0
- package/dist/rpg/tools/quest.js +164 -0
- package/dist/rpg/tools/status.d.ts +36 -0
- package/dist/rpg/tools/status.js +218 -0
- package/dist/rpg/tools/tables.d.ts +33 -0
- package/dist/rpg/tools/tables.js +209 -0
- package/dist/schemas/index.d.ts +12 -12
- package/dist/timeline/adjudication.d.ts +150 -0
- package/dist/timeline/adjudication.js +174 -0
- package/dist/timeline/changes.d.ts +108 -0
- package/dist/timeline/changes.js +169 -0
- package/dist/timeline/checkpoint.d.ts +69 -0
- package/dist/timeline/checkpoint.js +131 -0
- package/dist/timeline/clock.d.ts +89 -0
- package/dist/timeline/clock.js +173 -0
- package/dist/timeline/constrained.d.ts +220 -0
- package/dist/timeline/constrained.js +671 -0
- package/dist/timeline/export.d.ts +181 -0
- package/dist/timeline/export.js +339 -0
- package/dist/timeline/irreversible.d.ts +87 -0
- package/dist/timeline/irreversible.js +108 -0
- package/dist/timeline/kinds.d.ts +14 -0
- package/dist/timeline/kinds.js +22 -0
- package/dist/timeline/narration.d.ts +175 -0
- package/dist/timeline/narration.js +259 -0
- package/dist/timeline/projection.d.ts +97 -0
- package/dist/timeline/projection.js +330 -0
- package/dist/timeline/provenance.d.ts +66 -0
- package/dist/timeline/provenance.js +45 -0
- package/dist/timeline/registry.d.ts +95 -0
- package/dist/timeline/registry.js +124 -0
- package/dist/timeline/render.d.ts +121 -0
- package/dist/timeline/render.js +187 -0
- package/dist/timeline/replay.d.ts +86 -0
- package/dist/timeline/replay.js +126 -0
- package/dist/timeline/resolve.d.ts +262 -0
- package/dist/timeline/resolve.js +226 -0
- package/dist/timeline/schema.d.ts +13 -0
- package/dist/timeline/schema.js +264 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/audio.js +13 -9
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/game.js +33 -1
- package/dist/tools/images.js +17 -10
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +33 -8
- package/dist/tools/resource.js +106 -153
- package/dist/tools/time.js +18 -3
- package/dist/types/index.d.ts +20 -2
- package/dist/utils/media-path.d.ts +52 -0
- package/dist/utils/media-path.js +106 -0
- package/dist/utils/output-schemas.d.ts +594 -3
- package/dist/utils/output-schemas.js +4 -1
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +25 -5
- package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
- package/dist/__tests__/engineVocabulary.test.js +0 -147
- package/dist/db/__tests__/connection.test.d.ts +0 -1
- package/dist/db/__tests__/connection.test.js +0 -72
- package/dist/db/__tests__/testDb.d.ts +0 -33
- package/dist/db/__tests__/testDb.js +0 -41
- package/dist/test-setup.d.ts +0 -1
- package/dist/test-setup.js +0 -13
- package/dist/tools/__tests__/audio.test.d.ts +0 -1
- package/dist/tools/__tests__/audio.test.js +0 -59
- package/dist/tools/__tests__/conserved.test.d.ts +0 -1
- package/dist/tools/__tests__/conserved.test.js +0 -488
- package/dist/tools/__tests__/constraint.test.d.ts +0 -1
- package/dist/tools/__tests__/constraint.test.js +0 -212
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
- package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
- package/dist/tools/__tests__/images.test.d.ts +0 -1
- package/dist/tools/__tests__/images.test.js +0 -59
- package/dist/tools/__tests__/relationship.test.d.ts +0 -1
- package/dist/tools/__tests__/relationship.test.js +0 -132
- package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
- package/dist/tools/__tests__/resource-constraints.test.js +0 -131
- package/dist/tools/__tests__/resource.test.d.ts +0 -1
- package/dist/tools/__tests__/resource.test.js +0 -190
- package/dist/tools/__tests__/time.test.d.ts +0 -1
- package/dist/tools/__tests__/time.test.js +0 -404
- package/dist/tools/__tests__/timers.test.d.ts +0 -1
- package/dist/tools/__tests__/timers.test.js +0 -426
- package/dist/tools/__tests__/world.test.d.ts +0 -1
- package/dist/tools/__tests__/world.test.js +0 -70
- package/dist/utils/__tests__/json.test.d.ts +0 -1
- package/dist/utils/__tests__/json.test.js +0 -55
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|