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,181 @@
1
+ import type { T } from "./t.js";
2
+ import type { EntityKind } from "./kinds.js";
3
+ /**
4
+ * Timeline export/import (GitHub issue #8, design §6): "a client must be
5
+ * able to freeze the entire timeline -- every entity, every fact interval,
6
+ * every event -- into a file it owns. Not a live query. Not a session
7
+ * handle. A file." This module is the library half of that; an MCP tool
8
+ * wrapper (if one is ever added) belongs elsewhere, over these two
9
+ * functions, per §6's "library functions first, MCP tools second."
10
+ *
11
+ * WHAT IS IN THE ARTIFACT: `entities`, `facts` (every column, including
12
+ * `irreversible`), `events`, and the game's `timeline_clock` row (its
13
+ * declared axis and `current_t`). Nothing else.
14
+ *
15
+ * WHAT IS DELIBERATELY NOT IN IT -- and this is a requirement, not an
16
+ * omission:
17
+ *
18
+ * No media references of any kind. A `file_path` in a frozen artifact
19
+ * cannot satisfy the re-import-and-replay exit criterion: the path names a
20
+ * file on the exporting machine, the importing machine has no such file,
21
+ * and `replay(t)` would then differ between the two even though the export
22
+ * claimed they were identical. `file_path` lives only in `stored_images`
23
+ * and `stored_audio` (see `src/db/schema.ts`); neither table appears in
24
+ * `PROJECTED_TABLES` (`./projection.ts`), so no fact -- and therefore
25
+ * nothing this module reads -- can ever carry one. That is verified, not
26
+ * assumed: `src/timeline/__tests__/export.test.ts` creates a stored-image
27
+ * and stored-audio row for the game under test and asserts the exported
28
+ * artifact contains no `file_path` anywhere and that those rows
29
+ * contributed nothing (not even an extra entity).
30
+ *
31
+ * DECISION(#18): the frozen artifact carries no per-principal projection.
32
+ *
33
+ * No visibility filtering either, and the same "requirement, not omission"
34
+ * applies (issue #18): the artifact is the omniscient timeline. Whether a
35
+ * per-principal export should exist is not a small question deferred for
36
+ * tidiness -- it decides whether §6's "one file a deterministic consumer
37
+ * depends on" becomes N files plus a rule for choosing between them. That
38
+ * is a decision for the caller that first needs it to own, and it cannot be
39
+ * made well against no caller, so it is not made here.
40
+ *
41
+ * No live tables either. The live projected tables (`games`, `characters`,
42
+ * `resources`, ...) are a projection of the timeline, not a second source
43
+ * of truth (design §5.4's decided destination) -- a file carrying both
44
+ * would contain two answers to the same question. After `importTimeline`
45
+ * runs, the target database has a timeline and no live rows for the
46
+ * imported game, and that is correct: `replay(t)` reads only the timeline,
47
+ * which is exactly what the exit criterion measures. A caller that wants
48
+ * live rows back would need a separate, as-yet-unbuilt step that replays
49
+ * the timeline forward through the projection triggers -- not this
50
+ * module's job, and not attempted here.
51
+ *
52
+ * TYPES: `facts.irreversible` is stored in SQLite as the INTEGER 0/1 this
53
+ * codebase's triggers write (`projection.ts`). This module converts it to
54
+ * a real `boolean` on the way out (`Boolean(row.irreversible)`) and back to
55
+ * 0/1 on the way in, consistently in both directions -- the artifact itself
56
+ * never carries the raw integer.
57
+ *
58
+ * DETERMINISM: every array below is sorted by a total order that can never
59
+ * tie, because every one of them ends in the row's own primary key, which
60
+ * SQLite guarantees is unique: entities by `(created_at_t, id)`, facts by
61
+ * `(valid_from_t, entity_id, key, id)`, events by `(at_t, id)`. Exporting
62
+ * the same world twice therefore produces byte-identical
63
+ * `JSON.stringify` output -- see the determinism test in
64
+ * `export.test.ts`.
65
+ */
66
+ export declare const TIMELINE_FORMAT_VERSION = 1;
67
+ export interface TimelineExportEntity {
68
+ id: string;
69
+ gameId: string;
70
+ kind: EntityKind;
71
+ name: string | null;
72
+ createdAtT: T;
73
+ destroyedAtT: T | null;
74
+ }
75
+ export interface TimelineExportFact {
76
+ id: string;
77
+ entityId: string;
78
+ key: string;
79
+ value: string;
80
+ validFromT: T;
81
+ validToT: T | null;
82
+ irreversible: boolean;
83
+ }
84
+ export interface TimelineExportEvent {
85
+ id: string;
86
+ gameId: string;
87
+ atT: T;
88
+ kind: string;
89
+ description: string | null;
90
+ causes: string | null;
91
+ }
92
+ export interface TimelineExportClock {
93
+ currentT: T;
94
+ axisKind: "sequence" | "elapsed" | "counter";
95
+ axisUnit: string;
96
+ }
97
+ /** The frozen artifact itself -- design §6's "a file it owns." */
98
+ export interface TimelineExport {
99
+ formatVersion: number;
100
+ gameId: string;
101
+ clock: TimelineExportClock | null;
102
+ entities: TimelineExportEntity[];
103
+ facts: TimelineExportFact[];
104
+ events: TimelineExportEvent[];
105
+ }
106
+ /** What `importTimeline` restored, for a caller that wants to report it. */
107
+ export interface TimelineImportResult {
108
+ gameId: string;
109
+ entities: number;
110
+ facts: number;
111
+ events: number;
112
+ }
113
+ /**
114
+ * Freezes one game's entire timeline into a plain, serializable object.
115
+ * Reads only -- see the module doc comment above for what is deliberately
116
+ * excluded and why.
117
+ *
118
+ * An unknown `gameId` (nothing has ever been declared or written for it)
119
+ * exports an empty-but-valid artifact -- `clock: null`, three empty arrays
120
+ * -- rather than throwing. This falls out of the queries below with no
121
+ * special-casing: each one simply returns zero rows for a `gameId` nothing
122
+ * matches.
123
+ *
124
+ * The four reads are wrapped in one transaction even though nothing here
125
+ * writes. A frozen artifact whose halves came from different moments is not
126
+ * frozen: without this, a write landing between the entity query and the
127
+ * fact query would export an entity with facts that postdate it, or facts
128
+ * whose entity is missing -- and `replay(t)` over the re-imported result
129
+ * would then disagree with the original at exactly the `t` nobody thought to
130
+ * check. SQLite holds one consistent read view for the life of a
131
+ * transaction, which is the cheapest way to make the artifact a snapshot of
132
+ * a single instant rather than of four consecutive ones.
133
+ */
134
+ export declare function exportTimeline(gameId: string): TimelineExport;
135
+ /**
136
+ * Restores a frozen artifact into this database, verbatim. Everything runs
137
+ * inside one `withTransaction()` -- a partial import is worse than none
138
+ * (root CLAUDE.md's first inherited gotcha: `withTransaction()` was dead
139
+ * code until it was wired; this is exactly the kind of multi-table write it
140
+ * exists for).
141
+ *
142
+ * Nothing here is re-derived or re-numbered: ids, every `t` value, and the
143
+ * `irreversible` flag are carried through byte-for-byte. An import that
144
+ * renumbered `t` would be design §14's silent-attachment failure with the
145
+ * engine itself as the culprit.
146
+ *
147
+ * The `timeline_clock` row is restored by a direct INSERT, never through
148
+ * `declareTimeAxis` (`./clock.ts`) -- import is restoring recorded history,
149
+ * not declaring an axis. `declareTimeAxis` enforces "`t` never runs
150
+ * backwards" and refuses to change an already-declared axis, both of which
151
+ * are the right rules for a *live* game choosing its axis going forward,
152
+ * and the wrong rules for replacing an empty clock row with one that
153
+ * already has a history behind it -- `declareTimeAxis` has no path that
154
+ * starts a `counter` or `elapsed` axis already sitting at a nonzero
155
+ * `current_t` with a history of facts/events that predate it, which is
156
+ * exactly what a restored artifact is. `declared_at` (bookkeeping about
157
+ * when the axis was chosen, not part of the frozen timeline data per the
158
+ * artifact shape above) is stamped fresh at import time; it was never
159
+ * exported and never round-trips.
160
+ *
161
+ * `entities` are inserted before `facts` because `facts.entity_id` is a
162
+ * real foreign key and this database runs with `PRAGMA foreign_keys = ON`
163
+ * (`../db/connection.ts`) -- inserting out of order would fail loudly
164
+ * rather than silently, but there is no reason to invite the failure.
165
+ */
166
+ export declare function importTimeline(artifact: TimelineExport): TimelineImportResult;
167
+ /**
168
+ * Thin file wrapper -- the whole point of design §6 is that the artifact is
169
+ * a FILE, not a live query or a session handle. Nothing clever: plain
170
+ * `JSON.stringify(artifact, null, 2)` via `node:fs`.
171
+ */
172
+ export declare function exportTimelineToFile(params: {
173
+ gameId: string;
174
+ filePath: string;
175
+ }): TimelineExport;
176
+ /**
177
+ * Thin file wrapper for the other direction. Reads and parses are each
178
+ * wrapped so a caller gets a message naming the path, rather than a bare
179
+ * `ENOENT` or `SyntaxError` with no context about which import failed.
180
+ */
181
+ export declare function importTimelineFromFile(filePath: string): TimelineImportResult;
@@ -0,0 +1,339 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { getDatabase, withTransaction } from "../db/connection.js";
3
+ /**
4
+ * Timeline export/import (GitHub issue #8, design §6): "a client must be
5
+ * able to freeze the entire timeline -- every entity, every fact interval,
6
+ * every event -- into a file it owns. Not a live query. Not a session
7
+ * handle. A file." This module is the library half of that; an MCP tool
8
+ * wrapper (if one is ever added) belongs elsewhere, over these two
9
+ * functions, per §6's "library functions first, MCP tools second."
10
+ *
11
+ * WHAT IS IN THE ARTIFACT: `entities`, `facts` (every column, including
12
+ * `irreversible`), `events`, and the game's `timeline_clock` row (its
13
+ * declared axis and `current_t`). Nothing else.
14
+ *
15
+ * WHAT IS DELIBERATELY NOT IN IT -- and this is a requirement, not an
16
+ * omission:
17
+ *
18
+ * No media references of any kind. A `file_path` in a frozen artifact
19
+ * cannot satisfy the re-import-and-replay exit criterion: the path names a
20
+ * file on the exporting machine, the importing machine has no such file,
21
+ * and `replay(t)` would then differ between the two even though the export
22
+ * claimed they were identical. `file_path` lives only in `stored_images`
23
+ * and `stored_audio` (see `src/db/schema.ts`); neither table appears in
24
+ * `PROJECTED_TABLES` (`./projection.ts`), so no fact -- and therefore
25
+ * nothing this module reads -- can ever carry one. That is verified, not
26
+ * assumed: `src/timeline/__tests__/export.test.ts` creates a stored-image
27
+ * and stored-audio row for the game under test and asserts the exported
28
+ * artifact contains no `file_path` anywhere and that those rows
29
+ * contributed nothing (not even an extra entity).
30
+ *
31
+ * DECISION(#18): the frozen artifact carries no per-principal projection.
32
+ *
33
+ * No visibility filtering either, and the same "requirement, not omission"
34
+ * applies (issue #18): the artifact is the omniscient timeline. Whether a
35
+ * per-principal export should exist is not a small question deferred for
36
+ * tidiness -- it decides whether §6's "one file a deterministic consumer
37
+ * depends on" becomes N files plus a rule for choosing between them. That
38
+ * is a decision for the caller that first needs it to own, and it cannot be
39
+ * made well against no caller, so it is not made here.
40
+ *
41
+ * No live tables either. The live projected tables (`games`, `characters`,
42
+ * `resources`, ...) are a projection of the timeline, not a second source
43
+ * of truth (design §5.4's decided destination) -- a file carrying both
44
+ * would contain two answers to the same question. After `importTimeline`
45
+ * runs, the target database has a timeline and no live rows for the
46
+ * imported game, and that is correct: `replay(t)` reads only the timeline,
47
+ * which is exactly what the exit criterion measures. A caller that wants
48
+ * live rows back would need a separate, as-yet-unbuilt step that replays
49
+ * the timeline forward through the projection triggers -- not this
50
+ * module's job, and not attempted here.
51
+ *
52
+ * TYPES: `facts.irreversible` is stored in SQLite as the INTEGER 0/1 this
53
+ * codebase's triggers write (`projection.ts`). This module converts it to
54
+ * a real `boolean` on the way out (`Boolean(row.irreversible)`) and back to
55
+ * 0/1 on the way in, consistently in both directions -- the artifact itself
56
+ * never carries the raw integer.
57
+ *
58
+ * DETERMINISM: every array below is sorted by a total order that can never
59
+ * tie, because every one of them ends in the row's own primary key, which
60
+ * SQLite guarantees is unique: entities by `(created_at_t, id)`, facts by
61
+ * `(valid_from_t, entity_id, key, id)`, events by `(at_t, id)`. Exporting
62
+ * the same world twice therefore produces byte-identical
63
+ * `JSON.stringify` output -- see the determinism test in
64
+ * `export.test.ts`.
65
+ */
66
+ export const TIMELINE_FORMAT_VERSION = 1;
67
+ /**
68
+ * Freezes one game's entire timeline into a plain, serializable object.
69
+ * Reads only -- see the module doc comment above for what is deliberately
70
+ * excluded and why.
71
+ *
72
+ * An unknown `gameId` (nothing has ever been declared or written for it)
73
+ * exports an empty-but-valid artifact -- `clock: null`, three empty arrays
74
+ * -- rather than throwing. This falls out of the queries below with no
75
+ * special-casing: each one simply returns zero rows for a `gameId` nothing
76
+ * matches.
77
+ *
78
+ * The four reads are wrapped in one transaction even though nothing here
79
+ * writes. A frozen artifact whose halves came from different moments is not
80
+ * frozen: without this, a write landing between the entity query and the
81
+ * fact query would export an entity with facts that postdate it, or facts
82
+ * whose entity is missing -- and `replay(t)` over the re-imported result
83
+ * would then disagree with the original at exactly the `t` nobody thought to
84
+ * check. SQLite holds one consistent read view for the life of a
85
+ * transaction, which is the cheapest way to make the artifact a snapshot of
86
+ * a single instant rather than of four consecutive ones.
87
+ */
88
+ export function exportTimeline(gameId) {
89
+ return withTransaction(() => readTimeline(gameId));
90
+ }
91
+ function readTimeline(gameId) {
92
+ const db = getDatabase();
93
+ const clockRow = db
94
+ .prepare(`SELECT current_t, axis_kind, axis_unit FROM timeline_clock WHERE game_id = ?`)
95
+ .get(gameId);
96
+ const entityRows = db
97
+ .prepare(`SELECT id, game_id, kind, name, created_at_t, destroyed_at_t
98
+ FROM entities
99
+ WHERE game_id = ?
100
+ ORDER BY created_at_t, id`)
101
+ .all(gameId);
102
+ // Facts have no game_id column of their own (design §5.1's schema) --
103
+ // scoped by joining back to entities, exactly the way replay.ts scopes
104
+ // "what was true of them" to "who was alive."
105
+ const factRows = db
106
+ .prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible
107
+ FROM facts f
108
+ JOIN entities e ON e.id = f.entity_id
109
+ WHERE e.game_id = ?
110
+ ORDER BY f.valid_from_t, f.entity_id, f.key, f.id`)
111
+ .all(gameId);
112
+ const eventRows = db
113
+ .prepare(`SELECT id, game_id, at_t, kind, description, causes
114
+ FROM events
115
+ WHERE game_id = ?
116
+ ORDER BY at_t, id`)
117
+ .all(gameId);
118
+ return {
119
+ formatVersion: TIMELINE_FORMAT_VERSION,
120
+ gameId,
121
+ clock: clockRow
122
+ ? { currentT: clockRow.current_t, axisKind: clockRow.axis_kind, axisUnit: clockRow.axis_unit }
123
+ : null,
124
+ entities: entityRows.map((row) => ({
125
+ id: row.id,
126
+ gameId: row.game_id,
127
+ kind: row.kind,
128
+ name: row.name,
129
+ createdAtT: row.created_at_t,
130
+ destroyedAtT: row.destroyed_at_t,
131
+ })),
132
+ facts: factRows.map((row) => ({
133
+ id: row.id,
134
+ entityId: row.entity_id,
135
+ key: row.key,
136
+ value: row.value,
137
+ validFromT: row.valid_from_t,
138
+ validToT: row.valid_to_t,
139
+ irreversible: Boolean(row.irreversible),
140
+ })),
141
+ events: eventRows.map((row) => ({
142
+ id: row.id,
143
+ gameId: row.game_id,
144
+ atT: row.at_t,
145
+ kind: row.kind,
146
+ description: row.description,
147
+ causes: row.causes,
148
+ })),
149
+ };
150
+ }
151
+ /**
152
+ * Throws with a clear, specific message unless `artifact` has the shape
153
+ * `TimelineExport` requires. Nothing here coerces -- a wrong type is a
154
+ * refusal, never a silent cast, per the issue's own instruction not to
155
+ * "silently coerce anything."
156
+ */
157
+ function assertValidArtifactShape(artifact) {
158
+ if (typeof artifact !== "object" || artifact === null) {
159
+ throw new Error("timeline import: artifact must be an object, got " + typeof artifact);
160
+ }
161
+ const a = artifact;
162
+ if (typeof a.gameId !== "string" || a.gameId.length === 0) {
163
+ throw new Error("timeline import: artifact is missing a non-empty string gameId");
164
+ }
165
+ if (typeof a.formatVersion !== "number") {
166
+ throw new Error("timeline import: artifact is missing a numeric formatVersion");
167
+ }
168
+ if (!Array.isArray(a.entities)) {
169
+ throw new Error(`timeline import: artifact.entities must be an array for game '${a.gameId}'`);
170
+ }
171
+ if (!Array.isArray(a.facts)) {
172
+ throw new Error(`timeline import: artifact.facts must be an array for game '${a.gameId}'`);
173
+ }
174
+ if (!Array.isArray(a.events)) {
175
+ throw new Error(`timeline import: artifact.events must be an array for game '${a.gameId}'`);
176
+ }
177
+ }
178
+ /**
179
+ * Refuses, naming both ids, if any row in the artifact belongs to a game
180
+ * other than `artifact.gameId`.
181
+ *
182
+ * This is what makes `assertTargetIsEmpty` below mean what it says. That
183
+ * check interrogates `artifact.gameId` and nothing else, so on its own it
184
+ * only guarantees "import into an empty game" for artifacts whose rows all
185
+ * belong to that game. A hand-edited or hand-assembled one naming a
186
+ * different game in its rows would pass the emptiness check for the innocent
187
+ * id and then land entities and events on top of a populated timeline that
188
+ * was never examined -- attaching one world's history to another, silently,
189
+ * which is design §14's failure moved off the time axis and onto the
190
+ * identity axis. `exportTimeline` can never produce such an artifact; a text
191
+ * file a human can open trivially can, which is the whole point of §6.
192
+ *
193
+ * `facts` are not checked here because they carry no `gameId` of their own
194
+ * (design §5.1) -- their game is whichever entity they point at, and
195
+ * `facts.entity_id` is a real foreign key, so a fact can only ever reach a
196
+ * game through an entity this function has already vouched for.
197
+ */
198
+ function assertRowsBelongToGame(artifact) {
199
+ for (const entity of artifact.entities) {
200
+ if (entity.gameId !== artifact.gameId) {
201
+ throw new Error(`timeline import: artifact declares game '${artifact.gameId}' but entity '${entity.id}' belongs to ` +
202
+ `game '${entity.gameId}'. Refusing to import an artifact whose rows do not all belong to the game ` +
203
+ `it names -- importing it would attach one world's history to another.`);
204
+ }
205
+ }
206
+ for (const event of artifact.events) {
207
+ if (event.gameId !== artifact.gameId) {
208
+ throw new Error(`timeline import: artifact declares game '${artifact.gameId}' but event '${event.id}' belongs to ` +
209
+ `game '${event.gameId}'. Refusing to import an artifact whose rows do not all belong to the game ` +
210
+ `it names -- importing it would attach one world's history to another.`);
211
+ }
212
+ }
213
+ }
214
+ /**
215
+ * Refuses, naming what was found, if `gameId` already has any recorded
216
+ * history in this database. Checked as three independent conditions
217
+ * (`timeline_clock`, `entities`, `events`) because any one of them alone is
218
+ * evidence this game's timeline is not empty, and merging two timelines
219
+ * would interleave two worlds' `t` with no way to tell them apart
220
+ * afterward -- the whole reason import refuses rather than merges.
221
+ */
222
+ function assertTargetIsEmpty(gameId) {
223
+ const db = getDatabase();
224
+ const existingClock = db.prepare(`SELECT 1 FROM timeline_clock WHERE game_id = ?`).get(gameId);
225
+ if (existingClock) {
226
+ throw new Error(`timeline import: refusing to import into game '${gameId}' -- a timeline_clock row already exists for ` +
227
+ `it. Importing would merge two timelines' t with no way to tell them apart afterward; import into an ` +
228
+ `empty game, never an existing one.`);
229
+ }
230
+ const existingEntity = db.prepare(`SELECT 1 FROM entities WHERE game_id = ? LIMIT 1`).get(gameId);
231
+ if (existingEntity) {
232
+ throw new Error(`timeline import: refusing to import into game '${gameId}' -- entities already exist for it. Importing ` +
233
+ `would merge two timelines' t with no way to tell them apart afterward; import into an empty game, ` +
234
+ `never an existing one.`);
235
+ }
236
+ const existingEvent = db.prepare(`SELECT 1 FROM events WHERE game_id = ? LIMIT 1`).get(gameId);
237
+ if (existingEvent) {
238
+ throw new Error(`timeline import: refusing to import into game '${gameId}' -- events already exist for it. Importing ` +
239
+ `would merge two timelines' t with no way to tell them apart afterward; import into an empty game, ` +
240
+ `never an existing one.`);
241
+ }
242
+ }
243
+ /**
244
+ * Restores a frozen artifact into this database, verbatim. Everything runs
245
+ * inside one `withTransaction()` -- a partial import is worse than none
246
+ * (root CLAUDE.md's first inherited gotcha: `withTransaction()` was dead
247
+ * code until it was wired; this is exactly the kind of multi-table write it
248
+ * exists for).
249
+ *
250
+ * Nothing here is re-derived or re-numbered: ids, every `t` value, and the
251
+ * `irreversible` flag are carried through byte-for-byte. An import that
252
+ * renumbered `t` would be design §14's silent-attachment failure with the
253
+ * engine itself as the culprit.
254
+ *
255
+ * The `timeline_clock` row is restored by a direct INSERT, never through
256
+ * `declareTimeAxis` (`./clock.ts`) -- import is restoring recorded history,
257
+ * not declaring an axis. `declareTimeAxis` enforces "`t` never runs
258
+ * backwards" and refuses to change an already-declared axis, both of which
259
+ * are the right rules for a *live* game choosing its axis going forward,
260
+ * and the wrong rules for replacing an empty clock row with one that
261
+ * already has a history behind it -- `declareTimeAxis` has no path that
262
+ * starts a `counter` or `elapsed` axis already sitting at a nonzero
263
+ * `current_t` with a history of facts/events that predate it, which is
264
+ * exactly what a restored artifact is. `declared_at` (bookkeeping about
265
+ * when the axis was chosen, not part of the frozen timeline data per the
266
+ * artifact shape above) is stamped fresh at import time; it was never
267
+ * exported and never round-trips.
268
+ *
269
+ * `entities` are inserted before `facts` because `facts.entity_id` is a
270
+ * real foreign key and this database runs with `PRAGMA foreign_keys = ON`
271
+ * (`../db/connection.ts`) -- inserting out of order would fail loudly
272
+ * rather than silently, but there is no reason to invite the failure.
273
+ */
274
+ export function importTimeline(artifact) {
275
+ assertValidArtifactShape(artifact);
276
+ if (artifact.formatVersion !== TIMELINE_FORMAT_VERSION) {
277
+ throw new Error(`timeline import: artifact has formatVersion ${artifact.formatVersion}, but this build of run-dmcp reads ` +
278
+ `and writes formatVersion ${TIMELINE_FORMAT_VERSION}. Refusing to import rather than guess at a ` +
279
+ `translation between versions.`);
280
+ }
281
+ assertRowsBelongToGame(artifact);
282
+ return withTransaction(() => {
283
+ const db = getDatabase();
284
+ assertTargetIsEmpty(artifact.gameId);
285
+ if (artifact.clock) {
286
+ db.prepare(`INSERT INTO timeline_clock (game_id, current_t, axis_kind, axis_unit, declared_at) VALUES (?, ?, ?, ?, ?)`).run(artifact.gameId, artifact.clock.currentT, artifact.clock.axisKind, artifact.clock.axisUnit, new Date().toISOString());
287
+ }
288
+ const insertEntity = db.prepare(`INSERT INTO entities (id, game_id, kind, name, created_at_t, destroyed_at_t) VALUES (?, ?, ?, ?, ?, ?)`);
289
+ for (const entity of artifact.entities) {
290
+ insertEntity.run(entity.id, entity.gameId, entity.kind, entity.name, entity.createdAtT, entity.destroyedAtT);
291
+ }
292
+ const insertFact = db.prepare(`INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible) VALUES (?, ?, ?, ?, ?, ?, ?)`);
293
+ for (const fact of artifact.facts) {
294
+ insertFact.run(fact.id, fact.entityId, fact.key, fact.value, fact.validFromT, fact.validToT, fact.irreversible ? 1 : 0);
295
+ }
296
+ const insertEvent = db.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, ?, ?, ?)`);
297
+ for (const event of artifact.events) {
298
+ insertEvent.run(event.id, event.gameId, event.atT, event.kind, event.description, event.causes);
299
+ }
300
+ return {
301
+ gameId: artifact.gameId,
302
+ entities: artifact.entities.length,
303
+ facts: artifact.facts.length,
304
+ events: artifact.events.length,
305
+ };
306
+ });
307
+ }
308
+ /**
309
+ * Thin file wrapper -- the whole point of design §6 is that the artifact is
310
+ * a FILE, not a live query or a session handle. Nothing clever: plain
311
+ * `JSON.stringify(artifact, null, 2)` via `node:fs`.
312
+ */
313
+ export function exportTimelineToFile(params) {
314
+ const artifact = exportTimeline(params.gameId);
315
+ writeFileSync(params.filePath, JSON.stringify(artifact, null, 2));
316
+ return artifact;
317
+ }
318
+ /**
319
+ * Thin file wrapper for the other direction. Reads and parses are each
320
+ * wrapped so a caller gets a message naming the path, rather than a bare
321
+ * `ENOENT` or `SyntaxError` with no context about which import failed.
322
+ */
323
+ export function importTimelineFromFile(filePath) {
324
+ let raw;
325
+ try {
326
+ raw = readFileSync(filePath, "utf8");
327
+ }
328
+ catch (err) {
329
+ throw new Error(`timeline import: could not read export file at '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
330
+ }
331
+ let artifact;
332
+ try {
333
+ artifact = JSON.parse(raw);
334
+ }
335
+ catch (err) {
336
+ throw new Error(`timeline import: export file at '${filePath}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
337
+ }
338
+ return importTimeline(artifact);
339
+ }
@@ -0,0 +1,87 @@
1
+ import { type FactProvenance } from "./provenance.js";
2
+ /**
3
+ * DECISION(#21): contradiction is whole-value comparison under one key.
4
+ *
5
+ * `irreversible` -- the temporal member of the constraint family alongside
6
+ * `bounded`, `monotonic`, and conserved sets (design §5.3). Declared per
7
+ * fact, not per entity or per value: `facts.irreversible` is a column on the
8
+ * fact row itself, so any property a consumer wants irreversible has to live
9
+ * under its own fact `key` -- you cannot flag half a blob (see the schema
10
+ * comment on `facts` in schema.ts, and the "fact granularity" tests in
11
+ * irreversible.test.ts, which are what make that claim true rather than
12
+ * merely asserted).
13
+ *
14
+ * The actual enforcement -- refusing a contradicting assertion, and locking
15
+ * the `irreversible` flag itself to a one-way 0 -> 1 latch -- lives entirely
16
+ * in the two triggers on `facts` in schema.ts (`timeline_facts_irreversible`,
17
+ * `timeline_facts_immutable`). Every one of the 48 write sites in this
18
+ * codebase reaches `facts` through the generated projection triggers in
19
+ * projection.ts, so that is the only choke point a JS-level check could
20
+ * never be bypassed at. This module is a thin, typed API onto that trigger
21
+ * layer -- it never re-implements the rule, and it never returns a verdict
22
+ * (hard rule 2 / design §5.5): no `isClean`, no severity, just the fact
23
+ * that is or isn't there.
24
+ */
25
+ /**
26
+ * §5.2c's one hop of causality, specialized to an irreversible fact. The
27
+ * shape itself now lives in `provenance.ts` as `FactProvenance` -- every
28
+ * carrier of "a fact plus the one event that opened it" extends the same
29
+ * interface rather than each re-declaring it and re-implementing the
30
+ * lookup, so this type and `narration.ts`'s `ConstraintFact` can never
31
+ * quietly disagree about what one hop means. `IrreversibleFact` adds
32
+ * nothing beyond the shared shape; the name is kept because every caller in
33
+ * this file, and `ConstraintViolationError.contradictedFact` (registry.ts),
34
+ * already reads it that way. A type alias rather than an empty `extends`
35
+ * interface -- ESLint's `no-empty-object-type` correctly rejects an
36
+ * interface that declares no members of its own, since it is indistinguishable
37
+ * from its supertype at every call site; a plain alias says the same thing
38
+ * without inventing a nominal type this codebase would then have to keep
39
+ * in sync with its supertype by hand.
40
+ */
41
+ export type IrreversibleFact = FactProvenance;
42
+ /**
43
+ * Marks the currently-open fact for `(entityId, key)` irreversible.
44
+ *
45
+ * Throws, naming what's missing, rather than silently doing nothing, in
46
+ * exactly two cases:
47
+ * - the entity does not exist;
48
+ * - there is no open fact for that key. Irreversibility can only attach to
49
+ * a fact that currently holds -- "say what is, never what is absent"
50
+ * (hard rule 3) applies here too: there is no honest way to declare the
51
+ * irreversibility of an absence.
52
+ *
53
+ * The flip itself is a single `UPDATE facts SET irreversible = 1 WHERE id =
54
+ * ?`, which the amended `timeline_facts_immutable` latch (schema.ts)
55
+ * permits as the one legal transition. That single statement is also what
56
+ * makes this naturally idempotent: calling it again while the same fact is
57
+ * still open re-sends the identical 1 -> 1 update, which the latch treats as
58
+ * a no-op (not a change at all, so it never reaches the "reject anything but
59
+ * 0 -> 1" branch), and this function returns the same record either way.
60
+ */
61
+ export declare function declareIrreversible(params: {
62
+ entityId: string;
63
+ key: string;
64
+ }): IrreversibleFact;
65
+ /**
66
+ * The irreversible fact currently governing `(entityId, key)`, or `null` if
67
+ * none has been declared. Looks at every row with `irreversible = 1` for
68
+ * that key -- open or closed -- the same set `timeline_facts_irreversible`
69
+ * (schema.ts) tests, so this can never report "none" for a key the guard
70
+ * would in fact refuse to contradict. When more than one such row exists
71
+ * (only possible after the key was closed and reopened at the SAME value,
72
+ * per rule 1 -- a reopen at a different value is refused outright), the
73
+ * most recently opened one is returned, ordered deterministically
74
+ * (`valid_from_t` then `id`) rather than left to query-plan order.
75
+ */
76
+ export declare function irreversibleFactFor(entityId: string, key: string): IrreversibleFact | null;
77
+ /**
78
+ * Every irreversible fact belonging to `gameId`, optionally narrowed to one
79
+ * entity. A listing, not a verdict (hard rule 2) -- there is no summary
80
+ * count, no "is this game safe" flag, just the rows. Scoped to `gameId` via
81
+ * a join on `entities` rather than trusting a caller-supplied entity list,
82
+ * so one game's declarations can never leak into another's listing.
83
+ */
84
+ export declare function listIrreversibleFacts(params: {
85
+ gameId: string;
86
+ entityId?: string;
87
+ }): IrreversibleFact[];