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