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