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
@@ -0,0 +1,97 @@
1
+ import type { EntityKind } from "./kinds.js";
2
+ import type Database from "better-sqlite3";
3
+ /**
4
+ * One row of live world state, kept in sync with the timeline by generated
5
+ * triggers (issue #2). `kind` is typed `EntityKind`, not `string` -- a typo
6
+ * here is a compile error, not a runtime FK violation discovered later.
7
+ *
8
+ * These are exactly design §8's "Core, and this is the correction" row:
9
+ * entity/property concepts (a game, a character, a location, a thing owned
10
+ * by one of those, a numeric value, a link between two entities, a group,
11
+ * a fact someone may or may not know). Dice, combat, abilities, status
12
+ * effects, random tables and quests are the RPG layer built ON this core,
13
+ * not part of it -- they are out of scope for Phase 1 (design §11) and
14
+ * nothing about this registry needs them to exist.
15
+ *
16
+ * Adding a table later -- when the RPG layer's own phase arrives, or when
17
+ * some other real caller needs one -- is one row here. Nothing else changes,
18
+ * because `installProjectionTriggers` and `reconcileTimeline` below are both
19
+ * generated from this list plus the table's live column set: there is no
20
+ * second place carrying a parallel field list to fall out of sync.
21
+ */
22
+ export interface ProjectedTable {
23
+ table: string;
24
+ kind: EntityKind;
25
+ gameIdColumn: string;
26
+ nameColumn: string | null;
27
+ }
28
+ export declare const PROJECTED_TABLES: ProjectedTable[];
29
+ /**
30
+ * The live column list for `table`, excluding `id`. Fact keys are every
31
+ * column except `id` -- reading this from `pragma_table_info` rather than
32
+ * carrying a hand-written field list per table is the entire point: a
33
+ * column added by one of `src/db/schema.ts`'s idempotent `ALTER TABLE`
34
+ * blocks is picked up the next time this runs, with no second place to
35
+ * remember to update. See `installProjectionTriggers`'s doc comment for why
36
+ * this is re-read on every call rather than cached.
37
+ *
38
+ * Exported so the checkpoint (issue #4) reads fact keys from this exact
39
+ * function rather than carrying a second copy of the same query -- one
40
+ * owner for the column list, or a checkpoint could pass while comparing a
41
+ * different set of columns than the triggers actually project.
42
+ */
43
+ export declare function liveColumns(db: Database.Database, table: string): string[];
44
+ /**
45
+ * Installs the three generated triggers (`_ai` / `_au` / `_ad`) for every
46
+ * row of `PROJECTED_TABLES`. `DROP TRIGGER IF EXISTS` then `CREATE`, every
47
+ * time this runs -- not `CREATE TRIGGER IF NOT EXISTS` -- for the same
48
+ * reason `src/timeline/schema.ts`'s append-only guards are DROP-then-CREATE:
49
+ * the column set a trigger was generated from is exactly the thing that
50
+ * changes when a migration adds a column, and `IF NOT EXISTS` would freeze
51
+ * whatever set was live the first time a given database was ever opened.
52
+ * Called from `initializeTimelineSchema()` (`src/timeline/schema.ts`) after
53
+ * every `ALTER TABLE` in `src/db/schema.ts` has already run, so the column
54
+ * list read here is always the final one for this startup.
55
+ *
56
+ * SQLite runs a trigger inside the firing statement's transaction (measured
57
+ * behaviour, see timeline-architecture.md) -- that is what makes the state
58
+ * write and the timeline append one unit by construction, with nothing in
59
+ * `src/tools/` or `src/register/` needing to know any of this exists.
60
+ */
61
+ export declare function installProjectionTriggers(): void;
62
+ /**
63
+ * Backfills `entities`/`facts` for every row of every projected table, so
64
+ * that `replay(t)` (issue #3) is correct even for state the generated
65
+ * triggers above never saw. Exactly two situations produce that gap:
66
+ *
67
+ * 1. a database that predates the timeline (or predates a given
68
+ * projected table being added to it), where every existing row was
69
+ * written before any trigger existed to append for it;
70
+ * 2. a column added to a projected table by one of `src/db/schema.ts`'s
71
+ * `ALTER TABLE` blocks, which the *next* `initializeSchema()` picks up
72
+ * for triggers (via `installProjectionTriggers`) but which every row
73
+ * already on disk was written before that trigger existed either.
74
+ *
75
+ * It runs exactly once, at init, wrapped in one `withTransaction()` so a
76
+ * failure partway through leaves nothing backfilled rather than half of it
77
+ * -- and never mid-session, which is what keeps it from ever being asked to
78
+ * paper over a *lossy log*: if a session-time write failed to append (a bug
79
+ * this issue exists to make impossible), reconciliation running later would
80
+ * quietly manufacture a fact that was never actually recorded when it
81
+ * happened, at the wrong `t`. Startup-only means it only ever backfills
82
+ * state nothing had a chance to record yet, never state something dropped.
83
+ *
84
+ * The per-column reconciliation is deliberately the same close-then-open
85
+ * shape as the `AFTER UPDATE` trigger (`buildUpdateTrigger`), just driven by
86
+ * a table scan instead of `NEW`, so a divergent live value is corrected the
87
+ * same way an update would have corrected it -- including a column that
88
+ * reverted to NULL since the last reconciliation, which closes its stale
89
+ * open fact and opens nothing (hard rule 3: absence is the absence of a
90
+ * fact). The instructions for issue #2 describe this case only for
91
+ * "non-NULL column"; closing an orphaned open fact for a column that is now
92
+ * NULL is this function's own extension of that shape, made for the same
93
+ * reason the update trigger makes it: leaving a stale open fact behind for
94
+ * a column with no live value is exactly the kind of drift `replay(t)` must
95
+ * never be able to produce.
96
+ */
97
+ export declare function reconcileTimeline(): void;
@@ -0,0 +1,330 @@
1
+ import { getDatabase, withTransaction } from "../db/connection.js";
2
+ export const PROJECTED_TABLES = [
3
+ { table: "games", kind: "game", gameIdColumn: "id", nameColumn: "name" },
4
+ { table: "characters", kind: "character", gameIdColumn: "game_id", nameColumn: "name" },
5
+ { table: "locations", kind: "location", gameIdColumn: "game_id", nameColumn: "name" },
6
+ { table: "items", kind: "item", gameIdColumn: "game_id", nameColumn: "name" },
7
+ { table: "resources", kind: "resource", gameIdColumn: "game_id", nameColumn: "name" },
8
+ { table: "relationships", kind: "relationship", gameIdColumn: "game_id", nameColumn: null },
9
+ { table: "factions", kind: "faction", gameIdColumn: "game_id", nameColumn: "name" },
10
+ { table: "secrets", kind: "secret", gameIdColumn: "game_id", nameColumn: "name" },
11
+ ];
12
+ /**
13
+ * The live column list for `table`, excluding `id`. Fact keys are every
14
+ * column except `id` -- reading this from `pragma_table_info` rather than
15
+ * carrying a hand-written field list per table is the entire point: a
16
+ * column added by one of `src/db/schema.ts`'s idempotent `ALTER TABLE`
17
+ * blocks is picked up the next time this runs, with no second place to
18
+ * remember to update. See `installProjectionTriggers`'s doc comment for why
19
+ * this is re-read on every call rather than cached.
20
+ *
21
+ * Exported so the checkpoint (issue #4) reads fact keys from this exact
22
+ * function rather than carrying a second copy of the same query -- one
23
+ * owner for the column list, or a checkpoint could pass while comparing a
24
+ * different set of columns than the triggers actually project.
25
+ */
26
+ export function liveColumns(db, table) {
27
+ return db.prepare(`SELECT name FROM pragma_table_info(?)`).all(table)
28
+ .map((r) => r.name)
29
+ .filter((name) => name !== "id");
30
+ }
31
+ /**
32
+ * The single expression, reused everywhere `t` is needed inside a trigger
33
+ * body: the current position of `gidExpr`'s sequence clock. Centralized so
34
+ * every call site spells it identically -- and every occurrence inside one
35
+ * trigger body evaluates to the same value, because the clock is advanced
36
+ * exactly once, at the top of the body, and this expression only reads it.
37
+ */
38
+ function tExpr(gidExpr) {
39
+ return `(SELECT current_t FROM timeline_clock WHERE game_id = ${gidExpr})`;
40
+ }
41
+ /**
42
+ * `AFTER INSERT`: ensure the game's clock row exists, advance it, insert the
43
+ * entity, insert one fact per non-NULL column, insert a `<kind>.created`
44
+ * event. Column names are interpolated directly (never bound as parameters)
45
+ * because they come from this codebase's own `pragma_table_info`, never
46
+ * from anything a caller supplied -- there is no user input anywhere in
47
+ * this SQL (trigger-sql-skeleton trap #6).
48
+ */
49
+ function buildInsertTrigger(row, cols) {
50
+ const gid = `NEW.${row.gameIdColumn}`;
51
+ const t = tExpr(gid);
52
+ const nameExpr = row.nameColumn ? `NEW.${row.nameColumn}` : "NULL";
53
+ const factInserts = cols
54
+ .map((col) => `
55
+ INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible)
56
+ SELECT lower(hex(randomblob(16))), NEW.id, '${col}', CAST(NEW.${col} AS TEXT), ${t}, NULL, 0
57
+ WHERE NEW.${col} IS NOT NULL;`)
58
+ .join("\n");
59
+ return `
60
+ DROP TRIGGER IF EXISTS timeline_${row.table}_ai;
61
+ CREATE TRIGGER timeline_${row.table}_ai AFTER INSERT ON ${row.table}
62
+ BEGIN
63
+ INSERT OR IGNORE INTO timeline_clock (game_id, current_t, axis_kind, axis_unit, declared_at)
64
+ VALUES (${gid}, 0, 'sequence', 'write', '');
65
+ UPDATE timeline_clock SET current_t = current_t + 1
66
+ WHERE game_id = ${gid} AND axis_kind = 'sequence';
67
+
68
+ INSERT INTO entities (id, game_id, kind, name, created_at_t, destroyed_at_t)
69
+ VALUES (NEW.id, ${gid}, '${row.kind}', ${nameExpr}, ${t}, NULL);
70
+ ${factInserts}
71
+
72
+ INSERT INTO events (id, game_id, at_t, kind, description, causes)
73
+ VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.created', '${row.kind} created',
74
+ json_object('table', '${row.table}', 'row_id', NEW.id));
75
+ END;
76
+ `;
77
+ }
78
+ /**
79
+ * `AFTER UPDATE`: for each column, close the currently-open fact if the new
80
+ * value differs (`IS NOT`, never `<>` -- see trap #4: `<>` against NULL is
81
+ * NULL, so the WHEN/comparison silently never fires on exactly the rows
82
+ * that matter), then open a new one if the new value is non-NULL and
83
+ * differs from whatever is still open. Close-then-open, in that order, per
84
+ * column: reversed, the open's subquery would still see the value the
85
+ * close was about to retire and write nothing (trap #3). The five-case
86
+ * table this produces is walked by the test suite, not re-derived here.
87
+ */
88
+ function buildUpdateTrigger(row, cols) {
89
+ const gid = `NEW.${row.gameIdColumn}`;
90
+ const t = tExpr(gid);
91
+ const perColumn = cols
92
+ .map((col) => `
93
+ UPDATE facts SET valid_to_t = ${t}
94
+ WHERE entity_id = NEW.id AND key = '${col}' AND valid_to_t IS NULL
95
+ AND CAST(NEW.${col} AS TEXT) IS NOT value;
96
+
97
+ INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible)
98
+ SELECT lower(hex(randomblob(16))), NEW.id, '${col}', CAST(NEW.${col} AS TEXT), ${t}, NULL, 0
99
+ WHERE NEW.${col} IS NOT NULL
100
+ AND CAST(NEW.${col} AS TEXT) IS NOT
101
+ (SELECT value FROM facts WHERE entity_id = NEW.id AND key = '${col}' AND valid_to_t IS NULL);`)
102
+ .join("\n");
103
+ return `
104
+ DROP TRIGGER IF EXISTS timeline_${row.table}_au;
105
+ CREATE TRIGGER timeline_${row.table}_au AFTER UPDATE ON ${row.table}
106
+ BEGIN
107
+ UPDATE timeline_clock SET current_t = current_t + 1
108
+ WHERE game_id = ${gid} AND axis_kind = 'sequence';
109
+ ${perColumn}
110
+
111
+ INSERT INTO events (id, game_id, at_t, kind, description, causes)
112
+ VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.updated', '${row.kind} updated',
113
+ json_object('table', '${row.table}', 'row_id', NEW.id));
114
+ END;
115
+ `;
116
+ }
117
+ /**
118
+ * `AFTER DELETE`: close every open fact for the entity, set
119
+ * `destroyed_at_t`, insert a `<kind>.destroyed` event. `games` gets one
120
+ * more thing: a belt-and-braces sweep that closes every open fact and
121
+ * destroys every entity of that game directly, regardless of whether
122
+ * `ON DELETE CASCADE` on the child tables' own foreign keys already fired
123
+ * their `_ad` triggers. Measured (see timeline-architecture.md): cascade
124
+ * *does* fire the child triggers in this engine, which makes the sweep
125
+ * redundant in the common case -- it stays anyway, `WHERE ... IS NULL`
126
+ * throughout so it is idempotent, because it makes the guarantee
127
+ * (a destroyed game leaves no live entity or open fact behind) independent
128
+ * of that cascade setting rather than resting on it.
129
+ */
130
+ function buildDeleteTrigger(row) {
131
+ const gid = `OLD.${row.gameIdColumn}`;
132
+ const t = tExpr(gid);
133
+ const gameSweep = row.table === "games"
134
+ ? `
135
+ UPDATE facts SET valid_to_t = ${t}
136
+ WHERE valid_to_t IS NULL
137
+ AND entity_id IN (SELECT id FROM entities WHERE game_id = OLD.id AND destroyed_at_t IS NULL);
138
+ UPDATE entities SET destroyed_at_t = ${t} WHERE game_id = OLD.id AND destroyed_at_t IS NULL;`
139
+ : "";
140
+ return `
141
+ DROP TRIGGER IF EXISTS timeline_${row.table}_ad;
142
+ CREATE TRIGGER timeline_${row.table}_ad AFTER DELETE ON ${row.table}
143
+ BEGIN
144
+ UPDATE timeline_clock SET current_t = current_t + 1
145
+ WHERE game_id = ${gid} AND axis_kind = 'sequence';
146
+
147
+ UPDATE facts SET valid_to_t = ${t}
148
+ WHERE entity_id = OLD.id AND valid_to_t IS NULL;
149
+
150
+ UPDATE entities SET destroyed_at_t = ${t}
151
+ WHERE id = OLD.id AND destroyed_at_t IS NULL;
152
+ ${gameSweep}
153
+
154
+ INSERT INTO events (id, game_id, at_t, kind, description, causes)
155
+ VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.destroyed', '${row.kind} destroyed',
156
+ json_object('table', '${row.table}', 'row_id', OLD.id));
157
+ END;
158
+ `;
159
+ }
160
+ /**
161
+ * Installs the three generated triggers (`_ai` / `_au` / `_ad`) for every
162
+ * row of `PROJECTED_TABLES`. `DROP TRIGGER IF EXISTS` then `CREATE`, every
163
+ * time this runs -- not `CREATE TRIGGER IF NOT EXISTS` -- for the same
164
+ * reason `src/timeline/schema.ts`'s append-only guards are DROP-then-CREATE:
165
+ * the column set a trigger was generated from is exactly the thing that
166
+ * changes when a migration adds a column, and `IF NOT EXISTS` would freeze
167
+ * whatever set was live the first time a given database was ever opened.
168
+ * Called from `initializeTimelineSchema()` (`src/timeline/schema.ts`) after
169
+ * every `ALTER TABLE` in `src/db/schema.ts` has already run, so the column
170
+ * list read here is always the final one for this startup.
171
+ *
172
+ * SQLite runs a trigger inside the firing statement's transaction (measured
173
+ * behaviour, see timeline-architecture.md) -- that is what makes the state
174
+ * write and the timeline append one unit by construction, with nothing in
175
+ * `src/tools/` or `src/register/` needing to know any of this exists.
176
+ */
177
+ export function installProjectionTriggers() {
178
+ const db = getDatabase();
179
+ for (const row of PROJECTED_TABLES) {
180
+ const cols = liveColumns(db, row.table);
181
+ db.exec(buildInsertTrigger(row, cols));
182
+ db.exec(buildUpdateTrigger(row, cols));
183
+ db.exec(buildDeleteTrigger(row));
184
+ }
185
+ }
186
+ /**
187
+ * Backfills `entities`/`facts` for every row of every projected table, so
188
+ * that `replay(t)` (issue #3) is correct even for state the generated
189
+ * triggers above never saw. Exactly two situations produce that gap:
190
+ *
191
+ * 1. a database that predates the timeline (or predates a given
192
+ * projected table being added to it), where every existing row was
193
+ * written before any trigger existed to append for it;
194
+ * 2. a column added to a projected table by one of `src/db/schema.ts`'s
195
+ * `ALTER TABLE` blocks, which the *next* `initializeSchema()` picks up
196
+ * for triggers (via `installProjectionTriggers`) but which every row
197
+ * already on disk was written before that trigger existed either.
198
+ *
199
+ * It runs exactly once, at init, wrapped in one `withTransaction()` so a
200
+ * failure partway through leaves nothing backfilled rather than half of it
201
+ * -- and never mid-session, which is what keeps it from ever being asked to
202
+ * paper over a *lossy log*: if a session-time write failed to append (a bug
203
+ * this issue exists to make impossible), reconciliation running later would
204
+ * quietly manufacture a fact that was never actually recorded when it
205
+ * happened, at the wrong `t`. Startup-only means it only ever backfills
206
+ * state nothing had a chance to record yet, never state something dropped.
207
+ *
208
+ * The per-column reconciliation is deliberately the same close-then-open
209
+ * shape as the `AFTER UPDATE` trigger (`buildUpdateTrigger`), just driven by
210
+ * a table scan instead of `NEW`, so a divergent live value is corrected the
211
+ * same way an update would have corrected it -- including a column that
212
+ * reverted to NULL since the last reconciliation, which closes its stale
213
+ * open fact and opens nothing (hard rule 3: absence is the absence of a
214
+ * fact). The instructions for issue #2 describe this case only for
215
+ * "non-NULL column"; closing an orphaned open fact for a column that is now
216
+ * NULL is this function's own extension of that shape, made for the same
217
+ * reason the update trigger makes it: leaving a stale open fact behind for
218
+ * a column with no live value is exactly the kind of drift `replay(t)` must
219
+ * never be able to produce.
220
+ */
221
+ export function reconcileTimeline() {
222
+ withTransaction(() => {
223
+ const db = getDatabase();
224
+ for (const row of PROJECTED_TABLES) {
225
+ reconcileTable(db, row);
226
+ }
227
+ });
228
+ }
229
+ function reconcileTable(db, row) {
230
+ const { table, gameIdColumn: gidCol } = row;
231
+ // 1. Every game referenced by a live row in this table gets a clock row
232
+ // if one doesn't already exist -- covers a database with no clock rows
233
+ // at all (the timeline never having run against it before).
234
+ db.prepare(`
235
+ INSERT OR IGNORE INTO timeline_clock (game_id, current_t, axis_kind, axis_unit, declared_at)
236
+ SELECT DISTINCT ${gidCol}, 0, 'sequence', 'write', '' FROM ${table}
237
+ `).run();
238
+ // 2. Every live row gets an entity if it doesn't already have one, created
239
+ // at wherever its game's clock currently sits. There is no real history
240
+ // to backdate a pre-existing row to -- only "as of this reconciliation."
241
+ const nameExpr = row.nameColumn ? `src.${row.nameColumn}` : "NULL";
242
+ db.prepare(`
243
+ INSERT INTO entities (id, game_id, kind, name, created_at_t, destroyed_at_t)
244
+ SELECT src.id, src.${gidCol}, ?, ${nameExpr},
245
+ (SELECT current_t FROM timeline_clock WHERE game_id = src.${gidCol}), NULL
246
+ FROM ${table} src
247
+ WHERE NOT EXISTS (SELECT 1 FROM entities e WHERE e.id = src.id)
248
+ `).run(row.kind);
249
+ // 3. Per column, close-then-open against the live value, restricted to
250
+ // entities whose domain row still exists (a destroyed entity's row is
251
+ // gone from `table` and must not be touched here -- its facts were
252
+ // already closed by the delete trigger). CAST comparisons happen in
253
+ // SQL end to end, never in JS -- see the measured-behaviour note in
254
+ // timeline-architecture.md on why comparing a fact value in JS
255
+ // manufactures divergences that are not real.
256
+ for (const col of liveColumns(db, table)) {
257
+ // `AND facts.irreversible = 0`: an irreversible fact is deliberately
258
+ // left alone here, even when it has diverged from the live column --
259
+ // see the doc comment above for why. Without this clause, a divergence
260
+ // against an irreversible fact would close it and then fail to reopen
261
+ // it (timeline_facts_irreversible in schema.ts refuses the contradicting
262
+ // INSERT below), throwing this whole reconciliation -- and therefore
263
+ // initializeSchema() itself -- out at startup. With it, the close is
264
+ // simply skipped, the divergence persists for timelineDivergences()
265
+ // (checkpoint.ts) to report, and the server still boots.
266
+ //
267
+ // `AND NOT EXISTS (... resolve_only ...)` (issue #13) is the identical
268
+ // rule for the identical reason, one row over: a `resolve_only`
269
+ // constraint's whole job is to make timeline_facts_resolve_only
270
+ // (src/db/schema.ts) refuse an INSERT for its (entity_id, key) with no
271
+ // adjudication window open -- and reconciliation is not an adjudicating
272
+ // call, so the open-INSERT below would hit that trigger and abort for
273
+ // exactly the same reason a divergent irreversible fact hits
274
+ // timeline_facts_irreversible. Skipping the close here (so a fact this
275
+ // guard cannot reopen is never closed in the first place) is what keeps
276
+ // that abort from ever firing, and lets the divergence persist for
277
+ // timelineDivergences() to report instead of taking initializeSchema()
278
+ // down with it. The subquery mirrors timeline_facts_resolve_only's own
279
+ // WHEN clause exactly -- same JOIN, same predicate -- because this is
280
+ // the same question asked from the reconciliation side rather than the
281
+ // trigger side, and a second, differently-shaped query here could
282
+ // silently drift from what the trigger actually enforces.
283
+ db.prepare(`
284
+ UPDATE facts SET valid_to_t = (
285
+ SELECT current_t FROM timeline_clock WHERE game_id = (
286
+ SELECT ${gidCol} FROM ${table} WHERE id = facts.entity_id
287
+ )
288
+ )
289
+ WHERE key = ?
290
+ AND valid_to_t IS NULL
291
+ AND entity_id IN (SELECT id FROM ${table})
292
+ AND CAST((SELECT ${col} FROM ${table} WHERE id = facts.entity_id) AS TEXT) IS NOT value
293
+ AND facts.irreversible = 0
294
+ AND NOT EXISTS (
295
+ SELECT 1 FROM resource_constraints rc
296
+ JOIN resource_constraint_members rcm ON rcm.constraint_id = rc.id
297
+ WHERE rc.kind = 'resolve_only'
298
+ AND rcm.resource_id = facts.entity_id
299
+ AND rc.fact_key = facts.key
300
+ )
301
+ `).run(col);
302
+ // Same guard on the open-INSERT: a resolve_only-governed key whose close
303
+ // above was skipped still has its OLD fact open (valid_to_t IS NULL), so
304
+ // `NOT EXISTS (SELECT 1 FROM facts f WHERE ...)` below is already false
305
+ // for it and this INSERT's own WHERE would skip that row regardless --
306
+ // but a resolve_only-governed key with NO fact open at all (a database
307
+ // predating the timeline entirely, backfilling for the first time) has
308
+ // no such fact to make the WHERE skip it, and the very first INSERT for
309
+ // that key is itself refused with no window open. Repeating the guard
310
+ // here, rather than trusting the close-guard's side effect, covers that
311
+ // case too.
312
+ db.prepare(`
313
+ INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible)
314
+ SELECT lower(hex(randomblob(16))), src.id, ?, CAST(src.${col} AS TEXT),
315
+ (SELECT current_t FROM timeline_clock WHERE game_id = src.${gidCol}), NULL, 0
316
+ FROM ${table} src
317
+ WHERE src.${col} IS NOT NULL
318
+ AND NOT EXISTS (
319
+ SELECT 1 FROM facts f WHERE f.entity_id = src.id AND f.key = ? AND f.valid_to_t IS NULL
320
+ )
321
+ AND NOT EXISTS (
322
+ SELECT 1 FROM resource_constraints rc
323
+ JOIN resource_constraint_members rcm ON rcm.constraint_id = rc.id
324
+ WHERE rc.kind = 'resolve_only'
325
+ AND rcm.resource_id = src.id
326
+ AND rc.fact_key = ?
327
+ )
328
+ `).run(col, col, col);
329
+ }
330
+ }
@@ -0,0 +1,66 @@
1
+ import { type T } from "./t.js";
2
+ /**
3
+ * §5.2c's one hop of causality, as the shape every carrier of a
4
+ * fact-with-provenance shares -- there is exactly one owner of this shape
5
+ * in the codebase, here, not one copy per caller.
6
+ *
7
+ * Before this module existed, the one hop was written out twice: once as
8
+ * `irreversible.ts`'s `IrreversibleFact` (with its own module-private
9
+ * `findOpenedByEventId`), and once implicitly wherever `ConstraintFact`
10
+ * (narration.ts, design §5.2b) would otherwise have re-declared the same
11
+ * five fields and re-implemented the same lookup. Two copies of a five-line
12
+ * shape looks harmless right up until someone fixes a bug -- an off-by-one
13
+ * in the tiebreak, say -- in one and not the other, and the two carriers of
14
+ * "the fact and what opened it" silently disagree about what one hop means.
15
+ * `ConstraintViolationError.contradictedFact` (registry.ts) is typed as
16
+ * `IrreversibleFact`, which is itself now `FactProvenance` with nothing
17
+ * added -- so a THIRD fork was never created for that carrier either.
18
+ *
19
+ * Deliberately just the fact plus one edge, never a chain, never a trace of
20
+ * how the engine reached a verdict or which rules it consulted (§5.2c,
21
+ * design's own words: "One hop -- never a trace"). A caller that wants more
22
+ * than this is asking the wrong question of the engine.
23
+ */
24
+ export interface FactProvenance {
25
+ factId: string;
26
+ entityId: string;
27
+ key: string;
28
+ value: string;
29
+ validFromT: T;
30
+ /** The event that opened this fact, or null if none is recorded. One hop --
31
+ * never a chain, never a trace of how the engine reached a verdict. */
32
+ openedByEventId: string | null;
33
+ }
34
+ /**
35
+ * The one hop of causality (design §5.2c): the event of `gameId` whose
36
+ * `at_t` equals the fact's `valid_from_t` and whose `causes` JSON names this
37
+ * entity as the row it was written for. `causes` is produced entirely by
38
+ * this codebase's own projection triggers (`json_object('table', ...,
39
+ * 'row_id', NEW.id)` in projection.ts) -- matching `$.row_id` here is a
40
+ * literal comparison against a token we defined in output we generated, not
41
+ * an attempt to understand what any event "means" (hard rule 4). Ordered
42
+ * deterministically (`at_t`, then `id`) and only the first row is taken --
43
+ * one hop, never a chain, never a trace of how the engine got here.
44
+ *
45
+ * The `CASE WHEN json_valid(causes)` wrapper is load-bearing, not defensive
46
+ * decoration. `events.causes` has no CHECK constraint, and SQLite's
47
+ * `json_extract` RAISES "malformed JSON" rather than returning NULL when it
48
+ * meets a value that is not JSON -- and that error belongs to the whole
49
+ * query, not to the offending row, so a single bad row anywhere in this
50
+ * game's events would make every function that calls this throw, including
51
+ * ones that have nothing to do with that event. That is reachable in
52
+ * practice: timeline import (export.ts) carries `causes` through verbatim
53
+ * by design, because an importer that rewrote a recorded cause would be
54
+ * inventing history. A hop of provenance must never be able to fail the
55
+ * write it annotates, so a row we cannot read simply does not match.
56
+ * Written as CASE rather than `json_valid(causes) AND json_extract(...)`
57
+ * because SQLite does not guarantee the evaluation order of AND operands --
58
+ * the planner may reorder them, and then the guard is decoration that
59
+ * happens to work today.
60
+ *
61
+ * Moved here verbatim (SQL, doc comment and all) from `irreversible.ts`'s
62
+ * former module-private `findOpenedByEventId` -- this is the ONE owner of
63
+ * §5.2c's hop now; `irreversible.ts` and `narration.ts` both call this
64
+ * rather than each keeping a copy of the query.
65
+ */
66
+ export declare function openingEventId(gameId: string, entityId: string, validFromT: number): string | null;
@@ -0,0 +1,45 @@
1
+ import { getDatabase } from "../db/connection.js";
2
+ /**
3
+ * The one hop of causality (design §5.2c): the event of `gameId` whose
4
+ * `at_t` equals the fact's `valid_from_t` and whose `causes` JSON names this
5
+ * entity as the row it was written for. `causes` is produced entirely by
6
+ * this codebase's own projection triggers (`json_object('table', ...,
7
+ * 'row_id', NEW.id)` in projection.ts) -- matching `$.row_id` here is a
8
+ * literal comparison against a token we defined in output we generated, not
9
+ * an attempt to understand what any event "means" (hard rule 4). Ordered
10
+ * deterministically (`at_t`, then `id`) and only the first row is taken --
11
+ * one hop, never a chain, never a trace of how the engine got here.
12
+ *
13
+ * The `CASE WHEN json_valid(causes)` wrapper is load-bearing, not defensive
14
+ * decoration. `events.causes` has no CHECK constraint, and SQLite's
15
+ * `json_extract` RAISES "malformed JSON" rather than returning NULL when it
16
+ * meets a value that is not JSON -- and that error belongs to the whole
17
+ * query, not to the offending row, so a single bad row anywhere in this
18
+ * game's events would make every function that calls this throw, including
19
+ * ones that have nothing to do with that event. That is reachable in
20
+ * practice: timeline import (export.ts) carries `causes` through verbatim
21
+ * by design, because an importer that rewrote a recorded cause would be
22
+ * inventing history. A hop of provenance must never be able to fail the
23
+ * write it annotates, so a row we cannot read simply does not match.
24
+ * Written as CASE rather than `json_valid(causes) AND json_extract(...)`
25
+ * because SQLite does not guarantee the evaluation order of AND operands --
26
+ * the planner may reorder them, and then the guard is decoration that
27
+ * happens to work today.
28
+ *
29
+ * Moved here verbatim (SQL, doc comment and all) from `irreversible.ts`'s
30
+ * former module-private `findOpenedByEventId` -- this is the ONE owner of
31
+ * §5.2c's hop now; `irreversible.ts` and `narration.ts` both call this
32
+ * rather than each keeping a copy of the query.
33
+ */
34
+ export function openingEventId(gameId, entityId, validFromT) {
35
+ const db = getDatabase();
36
+ const row = db
37
+ .prepare(`SELECT id FROM events
38
+ WHERE game_id = ?
39
+ AND at_t = ?
40
+ AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.row_id') = ?
41
+ ORDER BY at_t, id
42
+ LIMIT 1`)
43
+ .get(gameId, validFromT, entityId);
44
+ return row?.id ?? null;
45
+ }
@@ -0,0 +1,95 @@
1
+ import type { ConstraintKind, DeclaredConstraintKind, MonotonicDirection, ResourceConstraint } from "../types/index.js";
2
+ import type { IrreversibleFact } from "./irreversible.js";
3
+ /**
4
+ * The read side of the resource-constraint registry (design §5.3 / §5.4
5
+ * option (C)): declarative, opt-in, server-enforced invariants on numeric
6
+ * fact keys, keyed on `(entityId, factKey)` rather than on an entity id
7
+ * alone.
8
+ *
9
+ * This lives in src/timeline/ -- not src/tools/constraint.ts, where the code
10
+ * below used to live -- so that the generic write choke point Phase 3
11
+ * introduces next (also under src/timeline/) can consult it WITHOUT
12
+ * importing anything from src/tools/. That import direction is what would
13
+ * otherwise close a cycle: src/tools/constraint.ts already imports
14
+ * src/tools/resource.ts (for getResource()), and the future choke point
15
+ * will need to check what's declared here before it writes a fact -- if the
16
+ * read side still lived in tools/constraint.ts, that edge would run
17
+ * tools/resource.ts -> timeline/<choke point> -> tools/constraint.ts, and
18
+ * tools/constraint.ts already sits downstream of tools/resource.ts. Moving
19
+ * the read side here breaks that cycle before the choke point exists to hit
20
+ * it.
21
+ *
22
+ * Everything that WRITES `resource_constraints` -- insertConstraint() and
23
+ * the declare*() functions, and all the validation that goes with them --
24
+ * stays in src/tools/constraint.ts, which imports the accessors below
25
+ * rather than duplicating the query against resource_constraint_members.
26
+ */
27
+ /** Absolute tolerance for floating-point sum comparisons on 'conserved'
28
+ * constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
29
+ * so repeated addition/subtraction across many transfers can drift by a
30
+ * few ULPs. This is large enough to absorb that drift over realistic
31
+ * transfer volumes while still catching an actual logic bug (which would
32
+ * typically desync the sum by a whole `amount`, not a fraction of one). */
33
+ export declare const CONSERVED_SUM_EPSILON = 0.000001;
34
+ export declare class ConstraintViolationError extends Error {
35
+ readonly constraintKind: DeclaredConstraintKind;
36
+ readonly resourceId: string;
37
+ /** Design decision #7 / §5.2c's one hop of causality, attached as typed
38
+ * data and not only baked into `message` -- a reviewer at a fired check
39
+ * has to decide "is the fact wrong or is the claim wrong," and parsing
40
+ * that back out of a sentence is exactly the shape §5.2c exists to
41
+ * prevent. Only ever set when `constraintKind === "irreversible"`;
42
+ * every other family in this union has no contradicted fact to attach,
43
+ * so `undefined` is the correct default rather than a fourth sentinel
44
+ * value. */
45
+ readonly contradictedFact?: IrreversibleFact | undefined;
46
+ constructor(constraintKind: DeclaredConstraintKind, resourceId: string, message: string,
47
+ /** Design decision #7 / §5.2c's one hop of causality, attached as typed
48
+ * data and not only baked into `message` -- a reviewer at a fired check
49
+ * has to decide "is the fact wrong or is the claim wrong," and parsing
50
+ * that back out of a sentence is exactly the shape §5.2c exists to
51
+ * prevent. Only ever set when `constraintKind === "irreversible"`;
52
+ * every other family in this union has no contradicted fact to attach,
53
+ * so `undefined` is the correct default rather than a fourth sentinel
54
+ * value. */
55
+ contradictedFact?: IrreversibleFact | undefined);
56
+ }
57
+ /** Shape of a raw `resource_constraints` row. Exported so
58
+ * src/tools/constraint.ts's own by-game query (listConstraints(), which has
59
+ * no JOIN to share with queryConstraintsForEntity() below) can build
60
+ * ResourceConstraint values through rowToConstraint() below instead of
61
+ * duplicating the row-shape/mapping logic this module already owns. */
62
+ export interface ConstraintRow {
63
+ id: string;
64
+ game_id: string;
65
+ kind: ConstraintKind;
66
+ direction: MonotonicDirection | null;
67
+ total: number | null;
68
+ fact_key: string;
69
+ created_at: string;
70
+ }
71
+ /** The resource ids belonging to a constraint, in insertion order. Exported
72
+ * alongside ConstraintRow/rowToConstraint for the same reason. */
73
+ export declare function memberIdsFor(constraintId: string): string[];
74
+ export declare function rowToConstraint(row: ConstraintRow): ResourceConstraint;
75
+ /**
76
+ * Every constraint governing `(entityId, factKey)`, ordered by
77
+ * `created_at`. This is the whole point of Phase 3 step 1: a constraint
78
+ * declared on one fact key of an entity must never be visible when a
79
+ * different fact key of that SAME entity is asked about, even though today
80
+ * every constraint happens to govern the same key ('value').
81
+ */
82
+ export declare function constraintsFor(entityId: string, factKey: string): ResourceConstraint[];
83
+ /**
84
+ * Every constraint governing `entityId`, regardless of fact key. Exists
85
+ * only so getConstraintsForResource() (src/tools/constraint.ts) can keep
86
+ * its pre-Phase-3 "all keys" behaviour without a second copy of the JOIN
87
+ * above -- new callers should prefer constraintsFor(), which cannot
88
+ * accidentally forget to scope by key.
89
+ */
90
+ export declare function allConstraintsForEntity(entityId: string): ResourceConstraint[];
91
+ /** The 'conserved' constraint governing `(entityId, factKey)`, or `null` if
92
+ * none is declared. At most one can exist for a given key: declareConservedConstraint()
93
+ * (src/tools/constraint.ts) rejects overlapping conserved membership on the
94
+ * same key. */
95
+ export declare function conservedConstraintFor(entityId: string, factKey: string): ResourceConstraint | null;