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
@@ -292,22 +292,22 @@ export declare const generatedImageSchema: z.ZodObject<{
292
292
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
293
293
  }, "strip", z.ZodTypeAny, {
294
294
  id: string;
295
- timestamp: string;
296
295
  tool: string;
297
296
  prompt: string;
297
+ timestamp: string;
298
298
  url?: string | undefined;
299
299
  base64?: string | undefined;
300
- metadata?: Record<string, unknown> | undefined;
301
300
  seed?: number | undefined;
301
+ metadata?: Record<string, unknown> | undefined;
302
302
  }, {
303
303
  id: string;
304
- timestamp: string;
305
304
  tool: string;
306
305
  prompt: string;
306
+ timestamp: string;
307
307
  url?: string | undefined;
308
308
  base64?: string | undefined;
309
- metadata?: Record<string, unknown> | undefined;
310
309
  seed?: number | undefined;
310
+ metadata?: Record<string, unknown> | undefined;
311
311
  }>;
312
312
  export declare const imageGenSchema: z.ZodObject<{
313
313
  subject: z.ZodObject<{
@@ -652,22 +652,22 @@ export declare const imageGenSchema: z.ZodObject<{
652
652
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
653
653
  }, "strip", z.ZodTypeAny, {
654
654
  id: string;
655
- timestamp: string;
656
655
  tool: string;
657
656
  prompt: string;
657
+ timestamp: string;
658
658
  url?: string | undefined;
659
659
  base64?: string | undefined;
660
- metadata?: Record<string, unknown> | undefined;
661
660
  seed?: number | undefined;
661
+ metadata?: Record<string, unknown> | undefined;
662
662
  }, {
663
663
  id: string;
664
- timestamp: string;
665
664
  tool: string;
666
665
  prompt: string;
666
+ timestamp: string;
667
667
  url?: string | undefined;
668
668
  base64?: string | undefined;
669
- metadata?: Record<string, unknown> | undefined;
670
669
  seed?: number | undefined;
670
+ metadata?: Record<string, unknown> | undefined;
671
671
  }>, "many">>;
672
672
  consistency: z.ZodOptional<z.ZodObject<{
673
673
  characterRef: z.ZodOptional<z.ZodString>;
@@ -773,13 +773,13 @@ export declare const imageGenSchema: z.ZodObject<{
773
773
  } | undefined;
774
774
  generations?: {
775
775
  id: string;
776
- timestamp: string;
777
776
  tool: string;
778
777
  prompt: string;
778
+ timestamp: string;
779
779
  url?: string | undefined;
780
780
  base64?: string | undefined;
781
- metadata?: Record<string, unknown> | undefined;
782
781
  seed?: number | undefined;
782
+ metadata?: Record<string, unknown> | undefined;
783
783
  }[] | undefined;
784
784
  }, {
785
785
  style: {
@@ -869,13 +869,13 @@ export declare const imageGenSchema: z.ZodObject<{
869
869
  } | undefined;
870
870
  generations?: {
871
871
  id: string;
872
- timestamp: string;
873
872
  tool: string;
874
873
  prompt: string;
874
+ timestamp: string;
875
875
  url?: string | undefined;
876
876
  base64?: string | undefined;
877
- metadata?: Record<string, unknown> | undefined;
878
877
  seed?: number | undefined;
878
+ metadata?: Record<string, unknown> | undefined;
879
879
  }[] | undefined;
880
880
  }>;
881
881
  export declare const voiceSchema: z.ZodObject<{
@@ -0,0 +1,150 @@
1
+ import type Database from "better-sqlite3";
2
+ /**
3
+ * The adjudication window (design §5.2a, §5.3; issue #13): an ephemeral
4
+ * marker that an adjudicating call -- issue #10's resolver, not built here
5
+ * -- is currently in progress. `resolve_only` (the fourth member of the
6
+ * constraint family, `src/types/index.ts`) means "every direct write to
7
+ * this fact key is refused"; this module is what "direct" is measured
8
+ * against. A write made while this window is open is the one write that is
9
+ * NOT direct -- it arrived through the adjudicating call that opened it.
10
+ *
11
+ * ONE SOURCE OF TRUTH, READ IN TWO PLACES. `adjudicationOpen()` below is
12
+ * called from the JS choke point (`assertConstraintsAllow`,
13
+ * src/timeline/constrained.ts) and the exact same table is read by
14
+ * `timeline_facts_resolve_only` (the `BEFORE INSERT ON facts` trigger,
15
+ * src/db/schema.ts) via `NOT EXISTS (SELECT 1 FROM
16
+ * timeline_adjudications_open)`. Two independent checks that happened to
17
+ * agree today would be two representations of one fact waiting to drift --
18
+ * exactly the "two write paths for one idea" shape root CLAUDE.md warns
19
+ * about and hard rule 7 exists to prevent for constrained writes generally.
20
+ * There is exactly one row of truth: this table. Neither reader owns a
21
+ * second copy of "is a window open" to keep in sync with the other.
22
+ *
23
+ * EMPTY AT REST. A database with no adjudicating call ever in flight has
24
+ * zero rows here, forever -- this is not a log and not a history; nothing
25
+ * here is meant to be read back after the window that wrote it closes. It
26
+ * is deliberately NOT a `PROJECTED_TABLES` entry (no entity/fact is ever
27
+ * derived from it -- an adjudication window is not part of the game's
28
+ * timeline, it is a fact about how a write reached the timeline), NOT
29
+ * frozen the way `resource_history`/`relationship_history` are (those hold
30
+ * rows nothing should ever add to again; this table's whole job is to gain
31
+ * and lose rows constantly), and NEVER exported as a query -- there is no
32
+ * `listOpenAdjudications()` beside `adjudicationOpen()` below, because
33
+ * nothing outside this module and its trigger counterpart has legitimate
34
+ * business asking anything about it other than the one boolean.
35
+ *
36
+ * SINGLE CONNECTION, NO RACE. better-sqlite3 is synchronous and this
37
+ * project holds exactly one connection per process (`getDatabase()`,
38
+ * src/db/connection.ts) -- there is no `await` between the INSERT that
39
+ * opens a window and the DELETE that closes it for two different callers'
40
+ * windows to interleave across, and no second connection that could read a
41
+ * half-open state. A future multi-process deployment would need to revisit
42
+ * this; nothing here assumes it.
43
+ *
44
+ * `withAdjudicationOpen` IS INTENDED TO BE CALLED FROM INSIDE
45
+ * `withTransaction()` (src/db/connection.ts) -- the resolver (issue #10,
46
+ * built separately) is expected to nest it as
47
+ * `withTransaction(() => withAdjudicationOpen(gameId, () => { ...writes...;
48
+ * ...event insert... }))`, so the marker row this module inserts commits or
49
+ * rolls back atomically WITH the writes it authorizes, exactly like every
50
+ * other constrained write's fact/event pair (see `applyLiveWrite`,
51
+ * constrained.ts). Getting that order backwards -- opening the window
52
+ * outside any transaction -- is exactly the crash scenario
53
+ * `initializeAdjudicationSchema`'s startup cleanup below exists to recover
54
+ * from; this module cannot enforce the nesting order on a caller, so it
55
+ * says so here instead.
56
+ *
57
+ * IMPORT DIRECTION IS LOAD-BEARING, same rule as constrained.ts's own doc
58
+ * comment states for itself: this file imports nothing from `src/tools/`.
59
+ * It stays a `src/timeline/` leaf -- the only thing it reaches for outside
60
+ * this directory is `getDatabase()` (src/db/connection.ts), exactly like
61
+ * constrained.ts and registry.ts already do.
62
+ */
63
+ /**
64
+ * Creates `timeline_adjudications_open` if it doesn't already exist, and
65
+ * unconditionally clears every row it holds. Called from
66
+ * `src/db/schema.ts`'s `initializeSchema()`, BEFORE
67
+ * `timeline_facts_resolve_only` (the trigger that reads this table) is
68
+ * created -- a `WHEN` clause referencing a table that doesn't exist yet
69
+ * would fail at `CREATE TRIGGER` time, not silently defer.
70
+ *
71
+ * THE STARTUP DELETE IS NOT HOUSEKEEPING -- it closes a fail-OPEN hole that
72
+ * would otherwise be the worst failure mode a guard can have. `withAdjudicationOpen`'s
73
+ * `finally` closes the window on a throw, but nothing in JS runs if the
74
+ * PROCESS itself dies between the INSERT and the DELETE (SIGKILL, OOM, the
75
+ * host losing power) -- and better-sqlite3's default journal mode commits
76
+ * each statement as it runs, so a row that made it to disk stays there.
77
+ * Without this cleanup, that one surviving row would make `adjudicationOpen()`
78
+ * return `true` FOREVER, on every future startup, for the life of the
79
+ * database -- which does not refuse writes (the failure a reviewer would
80
+ * notice) but PERMITS every one of them: `timeline_facts_resolve_only`'s
81
+ * `NOT EXISTS (SELECT 1 FROM timeline_adjudications_open)` would never be
82
+ * satisfied again, silently turning `resolve_only` into a no-op. A guard
83
+ * that quietly stops guarding is worse than one that is visibly broken.
84
+ *
85
+ * WHY UNCONDITIONAL DELETE IS SAFE: startup and "an adjudicating call is in
86
+ * flight" are mutually exclusive by construction. `initializeSchema()` runs
87
+ * once, synchronously, before this process serves anything -- there is no
88
+ * caller that could be mid-`withAdjudicationOpen` while it executes, because
89
+ * the only thing that could be running one is THIS process, and this
90
+ * process is, at this exact moment, inside its own startup path, not inside
91
+ * a request. So any row found here was never going to be closed by the code
92
+ * that opened it -- that code is gone -- and the only correct reading of a
93
+ * leftover row is "not open, and never going to become open on its own."
94
+ * Clearing it fails CLOSED (enforcement resumes) rather than leaving it to
95
+ * fail OPEN (enforcement silently stays off), which is the direction every
96
+ * ambiguous case in this module resolves toward.
97
+ *
98
+ * No index: this table is expected to hold at most a small handful of rows
99
+ * at any instant (one per adjudicating call currently in flight, plus one
100
+ * per level of re-entrant nesting) and every query against it is either "do
101
+ * any rows exist" or "delete this one row by its primary key" -- neither
102
+ * benefits from one.
103
+ */
104
+ export declare function initializeAdjudicationSchema(db: Database.Database): void;
105
+ /**
106
+ * Is ANY adjudication window currently open? Not scoped to a game --
107
+ * `timeline_facts_resolve_only`'s own `WHEN` clause (src/db/schema.ts) isn't
108
+ * either, and the whole point of "one source of truth read in two places"
109
+ * above is that this function and that trigger can never answer the
110
+ * question differently. A resolver call for game A opening a window does,
111
+ * as a consequence, also permit a resolve_only write for game B for the
112
+ * duration -- an acceptable widening given there is exactly one process,
113
+ * exactly one adjudicating call site (issue #10), and no concurrent-game
114
+ * resolution happening on this connection at once. Narrowing this to
115
+ * `game_id` later is possible without changing the trigger's shape (add
116
+ * `AND game_id = NEW.<something-that-names-the-game>` to both sides at
117
+ * once) but is not needed for the window mechanism itself to be correct
118
+ * today, and inventing that requirement ahead of an actual caller needing
119
+ * it would be exactly the "nothing enters the core against an imagined
120
+ * client" mistake root CLAUDE.md's hard rule 1 warns against.
121
+ */
122
+ export declare function adjudicationOpen(): boolean;
123
+ /**
124
+ * Runs `fn` with the adjudication window open, and guarantees the window
125
+ * this call opened is closed again before returning or throwing -- a
126
+ * throwing adjudication must never leave `resolve_only` permanently
127
+ * unenforceable for the rest of the process's life.
128
+ *
129
+ * RE-ENTRANCY: each call inserts its OWN row (a fresh uuid, never reused)
130
+ * and its `finally` deletes only that row, by id -- never every row in the
131
+ * table. Nesting therefore composes correctly with no special-casing: if an
132
+ * adjudicating call invokes another adjudicating call while its own window
133
+ * is open (or, for that matter, if two unrelated adjudications happen to be
134
+ * in flight on this one synchronous connection at the same instant, which
135
+ * given the single-connection note above means one nested inside the
136
+ * other), the inner call's own finally block removes only the inner row.
137
+ * `adjudicationOpen()` asks "does at least one row exist", so the outer
138
+ * window is still reported open for as long as the outer row remains --
139
+ * whether or not the inner call already finished, and whether or not the
140
+ * inner call threw. A DELETE keyed on `WHERE id = ?` (this call's own row),
141
+ * rather than `DELETE FROM timeline_adjudications_open` (every row), is
142
+ * what makes an inner close unable to ever close an outer window.
143
+ *
144
+ * `gameId` is recorded on the row for the same reason `opened_at` is --
145
+ * legibility for anyone inspecting the table mid-flight (e.g. while
146
+ * debugging a wedged process) -- not because any reader queries by it; see
147
+ * `adjudicationOpen()`'s doc comment above for why the read side is
148
+ * deliberately unscoped.
149
+ */
150
+ export declare function withAdjudicationOpen<R>(gameId: string, fn: () => R): R;
@@ -0,0 +1,174 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase } from "../db/connection.js";
3
+ /**
4
+ * The adjudication window (design §5.2a, §5.3; issue #13): an ephemeral
5
+ * marker that an adjudicating call -- issue #10's resolver, not built here
6
+ * -- is currently in progress. `resolve_only` (the fourth member of the
7
+ * constraint family, `src/types/index.ts`) means "every direct write to
8
+ * this fact key is refused"; this module is what "direct" is measured
9
+ * against. A write made while this window is open is the one write that is
10
+ * NOT direct -- it arrived through the adjudicating call that opened it.
11
+ *
12
+ * ONE SOURCE OF TRUTH, READ IN TWO PLACES. `adjudicationOpen()` below is
13
+ * called from the JS choke point (`assertConstraintsAllow`,
14
+ * src/timeline/constrained.ts) and the exact same table is read by
15
+ * `timeline_facts_resolve_only` (the `BEFORE INSERT ON facts` trigger,
16
+ * src/db/schema.ts) via `NOT EXISTS (SELECT 1 FROM
17
+ * timeline_adjudications_open)`. Two independent checks that happened to
18
+ * agree today would be two representations of one fact waiting to drift --
19
+ * exactly the "two write paths for one idea" shape root CLAUDE.md warns
20
+ * about and hard rule 7 exists to prevent for constrained writes generally.
21
+ * There is exactly one row of truth: this table. Neither reader owns a
22
+ * second copy of "is a window open" to keep in sync with the other.
23
+ *
24
+ * EMPTY AT REST. A database with no adjudicating call ever in flight has
25
+ * zero rows here, forever -- this is not a log and not a history; nothing
26
+ * here is meant to be read back after the window that wrote it closes. It
27
+ * is deliberately NOT a `PROJECTED_TABLES` entry (no entity/fact is ever
28
+ * derived from it -- an adjudication window is not part of the game's
29
+ * timeline, it is a fact about how a write reached the timeline), NOT
30
+ * frozen the way `resource_history`/`relationship_history` are (those hold
31
+ * rows nothing should ever add to again; this table's whole job is to gain
32
+ * and lose rows constantly), and NEVER exported as a query -- there is no
33
+ * `listOpenAdjudications()` beside `adjudicationOpen()` below, because
34
+ * nothing outside this module and its trigger counterpart has legitimate
35
+ * business asking anything about it other than the one boolean.
36
+ *
37
+ * SINGLE CONNECTION, NO RACE. better-sqlite3 is synchronous and this
38
+ * project holds exactly one connection per process (`getDatabase()`,
39
+ * src/db/connection.ts) -- there is no `await` between the INSERT that
40
+ * opens a window and the DELETE that closes it for two different callers'
41
+ * windows to interleave across, and no second connection that could read a
42
+ * half-open state. A future multi-process deployment would need to revisit
43
+ * this; nothing here assumes it.
44
+ *
45
+ * `withAdjudicationOpen` IS INTENDED TO BE CALLED FROM INSIDE
46
+ * `withTransaction()` (src/db/connection.ts) -- the resolver (issue #10,
47
+ * built separately) is expected to nest it as
48
+ * `withTransaction(() => withAdjudicationOpen(gameId, () => { ...writes...;
49
+ * ...event insert... }))`, so the marker row this module inserts commits or
50
+ * rolls back atomically WITH the writes it authorizes, exactly like every
51
+ * other constrained write's fact/event pair (see `applyLiveWrite`,
52
+ * constrained.ts). Getting that order backwards -- opening the window
53
+ * outside any transaction -- is exactly the crash scenario
54
+ * `initializeAdjudicationSchema`'s startup cleanup below exists to recover
55
+ * from; this module cannot enforce the nesting order on a caller, so it
56
+ * says so here instead.
57
+ *
58
+ * IMPORT DIRECTION IS LOAD-BEARING, same rule as constrained.ts's own doc
59
+ * comment states for itself: this file imports nothing from `src/tools/`.
60
+ * It stays a `src/timeline/` leaf -- the only thing it reaches for outside
61
+ * this directory is `getDatabase()` (src/db/connection.ts), exactly like
62
+ * constrained.ts and registry.ts already do.
63
+ */
64
+ /**
65
+ * Creates `timeline_adjudications_open` if it doesn't already exist, and
66
+ * unconditionally clears every row it holds. Called from
67
+ * `src/db/schema.ts`'s `initializeSchema()`, BEFORE
68
+ * `timeline_facts_resolve_only` (the trigger that reads this table) is
69
+ * created -- a `WHEN` clause referencing a table that doesn't exist yet
70
+ * would fail at `CREATE TRIGGER` time, not silently defer.
71
+ *
72
+ * THE STARTUP DELETE IS NOT HOUSEKEEPING -- it closes a fail-OPEN hole that
73
+ * would otherwise be the worst failure mode a guard can have. `withAdjudicationOpen`'s
74
+ * `finally` closes the window on a throw, but nothing in JS runs if the
75
+ * PROCESS itself dies between the INSERT and the DELETE (SIGKILL, OOM, the
76
+ * host losing power) -- and better-sqlite3's default journal mode commits
77
+ * each statement as it runs, so a row that made it to disk stays there.
78
+ * Without this cleanup, that one surviving row would make `adjudicationOpen()`
79
+ * return `true` FOREVER, on every future startup, for the life of the
80
+ * database -- which does not refuse writes (the failure a reviewer would
81
+ * notice) but PERMITS every one of them: `timeline_facts_resolve_only`'s
82
+ * `NOT EXISTS (SELECT 1 FROM timeline_adjudications_open)` would never be
83
+ * satisfied again, silently turning `resolve_only` into a no-op. A guard
84
+ * that quietly stops guarding is worse than one that is visibly broken.
85
+ *
86
+ * WHY UNCONDITIONAL DELETE IS SAFE: startup and "an adjudicating call is in
87
+ * flight" are mutually exclusive by construction. `initializeSchema()` runs
88
+ * once, synchronously, before this process serves anything -- there is no
89
+ * caller that could be mid-`withAdjudicationOpen` while it executes, because
90
+ * the only thing that could be running one is THIS process, and this
91
+ * process is, at this exact moment, inside its own startup path, not inside
92
+ * a request. So any row found here was never going to be closed by the code
93
+ * that opened it -- that code is gone -- and the only correct reading of a
94
+ * leftover row is "not open, and never going to become open on its own."
95
+ * Clearing it fails CLOSED (enforcement resumes) rather than leaving it to
96
+ * fail OPEN (enforcement silently stays off), which is the direction every
97
+ * ambiguous case in this module resolves toward.
98
+ *
99
+ * No index: this table is expected to hold at most a small handful of rows
100
+ * at any instant (one per adjudicating call currently in flight, plus one
101
+ * per level of re-entrant nesting) and every query against it is either "do
102
+ * any rows exist" or "delete this one row by its primary key" -- neither
103
+ * benefits from one.
104
+ */
105
+ export function initializeAdjudicationSchema(db) {
106
+ db.exec(`
107
+ CREATE TABLE IF NOT EXISTS timeline_adjudications_open (
108
+ id TEXT PRIMARY KEY,
109
+ game_id TEXT NOT NULL,
110
+ opened_at TEXT NOT NULL
111
+ )
112
+ `);
113
+ db.exec(`DELETE FROM timeline_adjudications_open`);
114
+ }
115
+ /**
116
+ * Is ANY adjudication window currently open? Not scoped to a game --
117
+ * `timeline_facts_resolve_only`'s own `WHEN` clause (src/db/schema.ts) isn't
118
+ * either, and the whole point of "one source of truth read in two places"
119
+ * above is that this function and that trigger can never answer the
120
+ * question differently. A resolver call for game A opening a window does,
121
+ * as a consequence, also permit a resolve_only write for game B for the
122
+ * duration -- an acceptable widening given there is exactly one process,
123
+ * exactly one adjudicating call site (issue #10), and no concurrent-game
124
+ * resolution happening on this connection at once. Narrowing this to
125
+ * `game_id` later is possible without changing the trigger's shape (add
126
+ * `AND game_id = NEW.<something-that-names-the-game>` to both sides at
127
+ * once) but is not needed for the window mechanism itself to be correct
128
+ * today, and inventing that requirement ahead of an actual caller needing
129
+ * it would be exactly the "nothing enters the core against an imagined
130
+ * client" mistake root CLAUDE.md's hard rule 1 warns against.
131
+ */
132
+ export function adjudicationOpen() {
133
+ const db = getDatabase();
134
+ const row = db.prepare(`SELECT 1 FROM timeline_adjudications_open LIMIT 1`).get();
135
+ return row !== undefined;
136
+ }
137
+ /**
138
+ * Runs `fn` with the adjudication window open, and guarantees the window
139
+ * this call opened is closed again before returning or throwing -- a
140
+ * throwing adjudication must never leave `resolve_only` permanently
141
+ * unenforceable for the rest of the process's life.
142
+ *
143
+ * RE-ENTRANCY: each call inserts its OWN row (a fresh uuid, never reused)
144
+ * and its `finally` deletes only that row, by id -- never every row in the
145
+ * table. Nesting therefore composes correctly with no special-casing: if an
146
+ * adjudicating call invokes another adjudicating call while its own window
147
+ * is open (or, for that matter, if two unrelated adjudications happen to be
148
+ * in flight on this one synchronous connection at the same instant, which
149
+ * given the single-connection note above means one nested inside the
150
+ * other), the inner call's own finally block removes only the inner row.
151
+ * `adjudicationOpen()` asks "does at least one row exist", so the outer
152
+ * window is still reported open for as long as the outer row remains --
153
+ * whether or not the inner call already finished, and whether or not the
154
+ * inner call threw. A DELETE keyed on `WHERE id = ?` (this call's own row),
155
+ * rather than `DELETE FROM timeline_adjudications_open` (every row), is
156
+ * what makes an inner close unable to ever close an outer window.
157
+ *
158
+ * `gameId` is recorded on the row for the same reason `opened_at` is --
159
+ * legibility for anyone inspecting the table mid-flight (e.g. while
160
+ * debugging a wedged process) -- not because any reader queries by it; see
161
+ * `adjudicationOpen()`'s doc comment above for why the read side is
162
+ * deliberately unscoped.
163
+ */
164
+ export function withAdjudicationOpen(gameId, fn) {
165
+ const db = getDatabase();
166
+ const id = uuidv4();
167
+ db.prepare(`INSERT INTO timeline_adjudications_open (id, game_id, opened_at) VALUES (?, ?, ?)`).run(id, gameId, new Date().toISOString());
168
+ try {
169
+ return fn();
170
+ }
171
+ finally {
172
+ db.prepare(`DELETE FROM timeline_adjudications_open WHERE id = ?`).run(id);
173
+ }
174
+ }
@@ -0,0 +1,108 @@
1
+ import { type T } from "./t.js";
2
+ /**
3
+ * One event as it landed inside the window. `causes` is the raw JSON string
4
+ * as stored (design §5.2c's one hop of provenance) -- it is returned
5
+ * untouched, never parsed or interpreted here (hard rule 4: nothing in this
6
+ * codebase derives meaning by reading generated text).
7
+ */
8
+ export interface EventChange {
9
+ kind: "event";
10
+ t: T;
11
+ eventId: string;
12
+ eventKind: string;
13
+ description: string | null;
14
+ causes: string | null;
15
+ }
16
+ /**
17
+ * One TRANSITION of one fact's interval, not one row per fact. A fact whose
18
+ * interval opens AND closes inside the window produces two `FactChange`
19
+ * rows -- an `"opened"` at `validFromT` and a `"closed"` at `validToT` --
20
+ * because a single row cannot carry two different `t` values to sort on. A
21
+ * tri-state "both" field was considered and rejected for exactly that
22
+ * reason: two rows sort correctly by `t`, and a caller that wants "did this
23
+ * fact both open and close in my window" recovers it for free by grouping
24
+ * the returned rows on `factId`.
25
+ *
26
+ * `endpoint` says which end of the fact's own interval this row is -- a
27
+ * mechanical property of the row against the window predicate, not a
28
+ * judgement about the fact's meaning. This is deliberately the only
29
+ * "extra" piece of information this module hands back beyond the raw
30
+ * columns (see the module doc comment on why nothing else is).
31
+ */
32
+ export interface FactChange {
33
+ kind: "fact";
34
+ t: T;
35
+ factId: string;
36
+ entityId: string;
37
+ factKey: string;
38
+ value: string;
39
+ /** Which endpoint of this fact's interval landed in the window. */
40
+ endpoint: "opened" | "closed";
41
+ validFromT: T;
42
+ validToT: T | null;
43
+ }
44
+ export type Change = EventChange | FactChange;
45
+ /**
46
+ * design §5.5: "the engine provides the query; the client declares the
47
+ * policy." `changes` is rows, nothing more -- no `isClean`, no severity, no
48
+ * contiguity flag, no count that implies a threshold. A continuous-take
49
+ * renderer reads a non-empty `changes` as a defect to fail; a turn-based
50
+ * consumer reads the same rows to *build* a summary of what happened since
51
+ * last look. Baking either reading into this type would hand the second
52
+ * caller the first caller's policy (root CLAUDE.md hard rule 2). If a
53
+ * future contributor is tempted to add `spansEntireInterval` or
54
+ * `durationCovered` here: don't -- that is a verdict wearing a shape.
55
+ */
56
+ export interface ChangeSet {
57
+ gameId: string;
58
+ t0: T;
59
+ t1: T;
60
+ changes: Change[];
61
+ }
62
+ /**
63
+ * `changesWithin(t0, t1)` -- design §5.5's "because units have duration":
64
+ * every event and fact-interval transition recorded in one game's history
65
+ * during the half-open window `[t0, t1)`.
66
+ *
67
+ * Half-open, matching `replay.ts`'s intervals exactly and for the same
68
+ * reason (design §5.1): `t0` is in, `t1` is not. An event at exactly `t1`,
69
+ * or a fact endpoint landing exactly at `t1`, belongs to whatever window
70
+ * starts there, never to this one.
71
+ *
72
+ * `t1 === t0` is a legal empty window (returns zero rows, refused nowhere).
73
+ * `t1 < t0` is refused loudly, naming both values, before either query
74
+ * runs -- silently returning zero rows for a caller's off-by-one would be
75
+ * far more expensive to track down than a thrown error naming the mistake.
76
+ *
77
+ * Exactly two queries, never one per entity -- same reasoning as
78
+ * `replay()`: this runs over a whole game's history, and one prepared
79
+ * statement per entity would both hit SQLite's bound-variable limit on a
80
+ * large game and defeat better-sqlite3's prepared-statement cache. Facts
81
+ * are scoped to the game via a JOIN to `entities` on `entity_id` (there is
82
+ * no FK-enforced game_id on `facts` itself, and `facts.entity_id` is a real
83
+ * foreign key with referential integrity -- see the task briefing on why
84
+ * that JOIN, not a raw string match, is the identity axis to scope on).
85
+ * Events carry `game_id` directly and need no join.
86
+ *
87
+ * Deliberately NOT filtered by entity aliveness. `replay(t)` answers "what
88
+ * was true at an instant" and needs "alive at t" to make that meaningful;
89
+ * this answers "what transitions were recorded in a window", and a
90
+ * transition belonging to an entity that was later destroyed is still a
91
+ * transition that was recorded -- destroying the entity afterward doesn't
92
+ * retroactively un-happen it. Filtering these rows by aliveness would be
93
+ * exactly the kind of policy this module isn't allowed to have an opinion
94
+ * on (see `ChangeSet`'s doc comment).
95
+ *
96
+ * DECISION(#18): changesWithin() returns every transition, whoever could observe it.
97
+ *
98
+ * Omniscient for the same reason and by the same decision as `replay()` --
99
+ * see its doc comment for the argument. Every transition in the window is
100
+ * returned regardless of which principal could have observed it, and a
101
+ * later per-principal filter arrives as one predicate on the two queries
102
+ * below (issue #18).
103
+ */
104
+ export declare function changesWithin(params: {
105
+ gameId: string;
106
+ t0: T;
107
+ t1: T;
108
+ }): ChangeSet;