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