run-dmcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -10
- 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 +439 -7
- package/dist/http/server.js +3 -3
- package/dist/index.d.ts +36 -2
- package/dist/index.js +184 -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 +11 -4
- 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 +100 -0
- package/dist/timeline/changes.js +161 -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 +171 -0
- package/dist/timeline/export.js +329 -0
- package/dist/timeline/irreversible.d.ts +85 -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 +64 -0
- package/dist/timeline/replay.js +104 -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 +262 -0
- package/dist/timeline/t.d.ts +80 -0
- package/dist/timeline/t.js +37 -0
- package/dist/tools/constraint.d.ts +44 -80
- package/dist/tools/constraint.js +115 -124
- package/dist/tools/relationship.d.ts +83 -2
- package/dist/tools/relationship.js +139 -62
- package/dist/tools/resource.d.ts +31 -6
- package/dist/tools/resource.js +106 -153
- package/dist/types/index.d.ts +19 -1
- package/dist/utils/output-schemas.d.ts +593 -2
- package/dist/utils/output-schemas.js +3 -0
- package/dist/utils/webui.d.ts +32 -0
- package/dist/utils/webui.js +54 -1
- package/package.json +20 -4
- 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,671 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { getDatabase, withTransaction } from "../db/connection.js";
|
|
3
|
+
import { assertT, compareT } from "./t.js";
|
|
4
|
+
import { currentStoryTime } from "./clock.js";
|
|
5
|
+
import { PROJECTED_TABLES, liveColumns } from "./projection.js";
|
|
6
|
+
import { constraintsFor, conservedConstraintFor, ConstraintViolationError, CONSERVED_SUM_EPSILON } from "./registry.js";
|
|
7
|
+
import { irreversibleFactFor } from "./irreversible.js";
|
|
8
|
+
import { adjudicationOpen } from "./adjudication.js";
|
|
9
|
+
/**
|
|
10
|
+
* A1: resolves `entityId` to the live table its `key` column lives in, and
|
|
11
|
+
* confirms that column actually exists -- the generic replacement for
|
|
12
|
+
* hardcoding "this is a resource, its column is `resources.value`" that
|
|
13
|
+
* makes this choke point work for `(entityId, factKey)` in general rather
|
|
14
|
+
* than one hand-picked pair.
|
|
15
|
+
*
|
|
16
|
+
* Reuses `PROJECTED_TABLES`/`liveColumns` (projection.ts) rather than
|
|
17
|
+
* carrying a second table/column registry -- the same "one owner for the
|
|
18
|
+
* column list" rule `checkpoint.ts`'s `timelineDivergences` already follows,
|
|
19
|
+
* for the same reason: a second copy could silently drift from what the
|
|
20
|
+
* projection triggers actually project, and this function would then
|
|
21
|
+
* validate against a set of columns nothing else agrees with.
|
|
22
|
+
*
|
|
23
|
+
* Throws, naming the entity and key, rather than returning undefined --
|
|
24
|
+
* every caller of this function is about to either read or write a live
|
|
25
|
+
* column, and a silent "couldn't resolve" would surface many statements
|
|
26
|
+
* later as a confusing SQL error against a nonexistent column instead of a
|
|
27
|
+
* clear one here.
|
|
28
|
+
*/
|
|
29
|
+
function resolveProjection(entityId, key) {
|
|
30
|
+
const db = getDatabase();
|
|
31
|
+
const entity = db.prepare(`SELECT game_id, kind FROM entities WHERE id = ?`).get(entityId);
|
|
32
|
+
if (!entity) {
|
|
33
|
+
throw new Error(`timeline: cannot resolve a constrained write -- entity '${entityId}' does not exist`);
|
|
34
|
+
}
|
|
35
|
+
// Cannot happen in practice: PROJECTED_TABLES has exactly one row per
|
|
36
|
+
// ENTITY_KINDS member today, and entities.kind is FK-constrained to
|
|
37
|
+
// entity_kinds (schema.ts). Guarded anyway rather than asserted, because
|
|
38
|
+
// this function has no business trusting that invariant silently forever.
|
|
39
|
+
const projected = PROJECTED_TABLES.find((p) => p.kind === entity.kind);
|
|
40
|
+
if (!projected) {
|
|
41
|
+
throw new Error(`timeline: entity '${entityId}' has kind '${entity.kind}', which has no projected table -- ` +
|
|
42
|
+
`there is no live column to write a constrained value through`);
|
|
43
|
+
}
|
|
44
|
+
const cols = liveColumns(db, projected.table);
|
|
45
|
+
if (!cols.includes(key)) {
|
|
46
|
+
throw new Error(`timeline: '${key}' is not a live column of '${projected.table}' -- entity '${entityId}' ` +
|
|
47
|
+
`(kind '${entity.kind}') has no fact key by that name`);
|
|
48
|
+
}
|
|
49
|
+
return { entityId, gameId: entity.game_id, table: projected.table, key };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads the current live value of a resolved (entityId, key). Throws rather
|
|
53
|
+
* than returning a sentinel on either failure: a missing row means the
|
|
54
|
+
* entity was destroyed since `resolveProjection` confirmed it in the
|
|
55
|
+
* timeline (a real race, however narrow); a NULL column means there is no
|
|
56
|
+
* value to move from, and "say what is, never what is absent" (hard rule 3)
|
|
57
|
+
* means this function will not invent a zero to paper over that -- the
|
|
58
|
+
* caller asked for a NUMBER, and an absent one is a caller error to report,
|
|
59
|
+
* not a caller error to guess past.
|
|
60
|
+
*/
|
|
61
|
+
function readLiveValue(db, resolved) {
|
|
62
|
+
const row = db
|
|
63
|
+
.prepare(`SELECT ${resolved.key} AS value FROM ${resolved.table} WHERE id = ?`)
|
|
64
|
+
.get(resolved.entityId);
|
|
65
|
+
if (!row) {
|
|
66
|
+
throw new Error(`timeline: no live row in '${resolved.table}' for entity '${resolved.entityId}' -- ` +
|
|
67
|
+
`it may have been destroyed since it was last confirmed to exist`);
|
|
68
|
+
}
|
|
69
|
+
if (row.value === null) {
|
|
70
|
+
throw new Error(`timeline: '${resolved.key}' is NULL on entity '${resolved.entityId}' -- a constrained numeric ` +
|
|
71
|
+
`write needs an existing numeric value to move from`);
|
|
72
|
+
}
|
|
73
|
+
return row.value;
|
|
74
|
+
}
|
|
75
|
+
function clamp(value, minValue, maxValue) {
|
|
76
|
+
let result = value;
|
|
77
|
+
if (minValue !== null)
|
|
78
|
+
result = Math.max(result, minValue);
|
|
79
|
+
if (maxValue !== null)
|
|
80
|
+
result = Math.min(result, maxValue);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A2: the single site where the declared constraint family (design §5.3) --
|
|
85
|
+
* `monotonic`, `bounded`, `conserved`, and (issue #13) `resolve_only` -- is
|
|
86
|
+
* evaluated against an intended change. Every one of `checkResourceConstraints()`
|
|
87
|
+
* and `checkBoundedAndMonotonicConstraints()`'s (formerly src/tools/constraint.ts)
|
|
88
|
+
* rules lives here now and ONLY here -- grep the tree for
|
|
89
|
+
* `constraint.direction ===` or `constraint.kind === "bounded"` and this file
|
|
90
|
+
* is the only hit outside a test.
|
|
91
|
+
*
|
|
92
|
+
* Reads via `constraintsFor(entityId, key)` (registry.ts), not
|
|
93
|
+
* `allConstraintsForEntity` -- this is the behavioral point of Phase 3 step
|
|
94
|
+
* 1's key-scoping: a `monotonic` (or, now, `resolve_only`) constraint
|
|
95
|
+
* declared on one fact key must never reach a write to a different key on
|
|
96
|
+
* the same entity, even though every constraint declared through today's
|
|
97
|
+
* `declare*()` functions other than `declareResolveOnlyConstraint` happens
|
|
98
|
+
* to govern `'value'`.
|
|
99
|
+
*
|
|
100
|
+
* `bounds` is optional. When a caller has no bounds to check against (e.g.
|
|
101
|
+
* `updateResource`'s own guard below, which is not itself moving a value
|
|
102
|
+
* against declared min/max -- it is refusing a conserved reclamp), the
|
|
103
|
+
* `bounded` branch is simply skipped rather than crashing on a missing
|
|
104
|
+
* object. `monotonic` and `conserved` need no bounds and are always
|
|
105
|
+
* evaluated when applicable.
|
|
106
|
+
*
|
|
107
|
+
* MESSAGE PRESERVATION: every string thrown below is copied verbatim from
|
|
108
|
+
* `checkResourceConstraints`/`checkBoundedAndMonotonicConstraints` as they
|
|
109
|
+
* stood before this module existed -- `conserved.test.ts` asserts against
|
|
110
|
+
* the conserved-rejection text by regex, and nothing here is worth rewording
|
|
111
|
+
* away from wording a real caller may already be matching on.
|
|
112
|
+
*/
|
|
113
|
+
export function assertConstraintsAllow(params) {
|
|
114
|
+
const { entityId, key, previousValue, intendedValue, bounds, conservedMemberWrite, context } = params;
|
|
115
|
+
const constraints = constraintsFor(entityId, key);
|
|
116
|
+
for (const constraint of constraints) {
|
|
117
|
+
if (constraint.kind === "monotonic") {
|
|
118
|
+
if (constraint.direction === "increasing" && intendedValue < previousValue) {
|
|
119
|
+
throw new ConstraintViolationError("monotonic", entityId, `Resource '${entityId}' is constrained to never decrease; rejected change from ${previousValue} to ${intendedValue}.`);
|
|
120
|
+
}
|
|
121
|
+
if (constraint.direction === "decreasing" && intendedValue > previousValue) {
|
|
122
|
+
throw new ConstraintViolationError("monotonic", entityId, `Resource '${entityId}' is constrained to never increase; rejected change from ${previousValue} to ${intendedValue}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (constraint.kind === "bounded" && bounds) {
|
|
126
|
+
if (bounds.minValue !== null && intendedValue < bounds.minValue) {
|
|
127
|
+
throw new ConstraintViolationError("bounded", entityId, `Resource '${entityId}' is bounded-constrained (min ${bounds.minValue}); rejected value ${intendedValue} instead of clamping.`);
|
|
128
|
+
}
|
|
129
|
+
if (bounds.maxValue !== null && intendedValue > bounds.maxValue) {
|
|
130
|
+
throw new ConstraintViolationError("bounded", entityId, `Resource '${entityId}' is bounded-constrained (max ${bounds.maxValue}); rejected value ${intendedValue} instead of clamping.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// 'resolve_only' (design §5.3, §5.2a; issue #13): every direct write to
|
|
134
|
+
// this (entityId, key) is refused, full stop -- unconditionally, not
|
|
135
|
+
// only when `conservedMemberWrite === "reject"`. That parameter exists
|
|
136
|
+
// to disambiguate 'conserved' membership (a direct single-resource
|
|
137
|
+
// write is ambiguous; transferConstrainedValue's two-leg write is not),
|
|
138
|
+
// and it has no bearing on 'resolve_only': neither writeConstrainedValue
|
|
139
|
+
// NOR transferConstrainedValue IS the adjudicating call, so both are
|
|
140
|
+
// "direct" from resolve_only's point of view and both are checked here,
|
|
141
|
+
// which is why this branch runs regardless of `conservedMemberWrite`.
|
|
142
|
+
// `adjudicationOpen()` (src/timeline/adjudication.ts) is the ONLY
|
|
143
|
+
// question this branch asks; see that module's doc comment for why it
|
|
144
|
+
// and the `timeline_facts_resolve_only` trigger (src/db/schema.ts) are
|
|
145
|
+
// two readers of one row of truth rather than two independent checks
|
|
146
|
+
// that merely agree today.
|
|
147
|
+
//
|
|
148
|
+
// THIS DOES NOT DUPLICATE THE TRIGGER. Design decision #7 / hard rule 7:
|
|
149
|
+
// a constrained numeric value changes through this module and nowhere
|
|
150
|
+
// else, and all four `ConstraintKind` members are evaluated here -- this
|
|
151
|
+
// is the ONE JS-level check for 'resolve_only', giving a caller a typed,
|
|
152
|
+
// reviewable `ConstraintViolationError` instead of an opaque SQLite
|
|
153
|
+
// `RAISE(ABORT)`. `timeline_facts_resolve_only` is the backstop that
|
|
154
|
+
// makes bypassing THIS module (a raw `UPDATE resources SET value = ...`
|
|
155
|
+
// that never calls writeConstrainedValue at all) unconstructable rather
|
|
156
|
+
// than merely inconvenient -- exactly the relationship
|
|
157
|
+
// `translateIrreversibleFailure` below describes for 'irreversible',
|
|
158
|
+
// except 'irreversible' has no JS-level check at all (its trigger is the
|
|
159
|
+
// only enforcement, translated after the fact) while 'resolve_only' is
|
|
160
|
+
// enforced at BOTH layers because, unlike a contradicted fact, "is a
|
|
161
|
+
// window open" is cheap to ask before ever touching the database.
|
|
162
|
+
if (constraint.kind === "resolve_only" && !adjudicationOpen()) {
|
|
163
|
+
throw new ConstraintViolationError("resolve_only", entityId, `Resource '${entityId}' is resolve_only-constrained for key '${key}'; direct writes are refused. ` +
|
|
164
|
+
`This value can only change through the adjudicating call that opens the resolution window.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (conservedMemberWrite === "reject") {
|
|
168
|
+
const conserved = constraints.find((c) => c.kind === "conserved");
|
|
169
|
+
if (conserved) {
|
|
170
|
+
const suffix = context ??
|
|
171
|
+
`and cannot be written directly via update_resource_value -- a single-resource write is ambiguous about where ` +
|
|
172
|
+
`the counterpart delta should come from, and could silently break the set's total. Use transfer_resource_value ` +
|
|
173
|
+
`to move value between two members of this set atomically instead.`;
|
|
174
|
+
throw new ConstraintViolationError("conserved", entityId, `Resource '${entityId}' is a member of a 'conserved' constraint (id '${conserved.id}', total ${conserved.total}) ${suffix}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Rejects (never clamps) a transfer leg that would push an entity's key
|
|
180
|
+
* outside `bounds`. Ported from `assertWithinBoundsForTransfer`
|
|
181
|
+
* (src/tools/resource.ts) with the same reasoning: clamping one side of a
|
|
182
|
+
* transfer would apply an uneven delta and silently create or destroy value,
|
|
183
|
+
* so any bound violation on either side rejects the whole transfer instead
|
|
184
|
+
* -- regardless of whether a `bounded` constraint is separately declared.
|
|
185
|
+
* `bounds` here is the resource's own plain minValue/maxValue, which this
|
|
186
|
+
* check always enforces once supplied; declared `bounded` constraints are a
|
|
187
|
+
* SEPARATE, opt-in check (`assertConstraintsAllow` above).
|
|
188
|
+
*/
|
|
189
|
+
function assertWithinBoundsForTransfer(entityId, label, bounds, intendedValue, role) {
|
|
190
|
+
if (!bounds)
|
|
191
|
+
return;
|
|
192
|
+
if (bounds.minValue !== null && intendedValue < bounds.minValue) {
|
|
193
|
+
throw new ConstraintViolationError("conserved", entityId, `Transfer rejected: '${label}' (${entityId}) would go below its minimum value ` +
|
|
194
|
+
`(${bounds.minValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
|
|
195
|
+
`clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
|
|
196
|
+
`Choose a smaller amount.`);
|
|
197
|
+
}
|
|
198
|
+
if (bounds.maxValue !== null && intendedValue > bounds.maxValue) {
|
|
199
|
+
throw new ConstraintViolationError("conserved", entityId, `Transfer rejected: '${label}' (${entityId}) would exceed its maximum value ` +
|
|
200
|
+
`(${bounds.maxValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
|
|
201
|
+
`clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
|
|
202
|
+
`Choose a smaller amount.`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Performs one live column write plus its one annotation event, and returns
|
|
207
|
+
* the resulting `ValueTransition`. Shared by `writeConstrainedValue` (one
|
|
208
|
+
* leg) and `transferConstrainedValue` (two legs, same transaction) so the
|
|
209
|
+
* "how do we find what fact this opened, and what do we tell `events`"
|
|
210
|
+
* logic is written exactly once.
|
|
211
|
+
*
|
|
212
|
+
* MUST be called from inside a `withTransaction()` -- it performs the
|
|
213
|
+
* `UPDATE` (which fires the generated `_au` projection trigger,
|
|
214
|
+
* projection.ts, closing the old fact and opening the new one INSIDE that
|
|
215
|
+
* same statement) and the annotation-event `INSERT` as two separate
|
|
216
|
+
* statements that need to land together or not at all.
|
|
217
|
+
*
|
|
218
|
+
* Column and table names are interpolated, never bound -- `resolved.table`
|
|
219
|
+
* and `resolved.key` were validated by `resolveProjection` against
|
|
220
|
+
* `pragma_table_info`/`PROJECTED_TABLES`, this codebase's own vocabulary,
|
|
221
|
+
* never a caller-supplied string. See projection.ts's doc comments for the
|
|
222
|
+
* fuller statement of the same rule.
|
|
223
|
+
*/
|
|
224
|
+
function applyLiveWrite(params) {
|
|
225
|
+
const db = getDatabase();
|
|
226
|
+
const { entityId, gameId, table, key, previousValue, newValue, reason } = params;
|
|
227
|
+
db.prepare(`UPDATE ${table} SET ${key} = ? WHERE id = ?`).run(newValue, entityId);
|
|
228
|
+
let factId = null;
|
|
229
|
+
let t;
|
|
230
|
+
if (newValue === previousValue) {
|
|
231
|
+
// The projection trigger's INSERT is guarded `WHERE NEW.<col> IS NOT
|
|
232
|
+
// (SELECT value FROM facts ...)` (projection.ts) -- an unchanged value
|
|
233
|
+
// opens no new fact. Detected here from the value comparison, not by
|
|
234
|
+
// inspecting rows, because inspecting rows can't tell "nothing opened"
|
|
235
|
+
// apart from "something opened and was immediately superseded" without
|
|
236
|
+
// extra bookkeeping this choke point has no reason to carry.
|
|
237
|
+
const story = currentStoryTime(gameId);
|
|
238
|
+
if (!story) {
|
|
239
|
+
throw new Error(`timeline: game '${gameId}' has no timeline clock -- cannot record a no-op constrained write with no t to attach it to`);
|
|
240
|
+
}
|
|
241
|
+
t = story.t;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
// The fact the trigger just opened: the currently-open interval for
|
|
245
|
+
// this (entityId, key). In a correctly functioning system this row's
|
|
246
|
+
// valid_from_t already equals "the game's clock now" -- it was opened
|
|
247
|
+
// by the UPDATE just above -- so there is no separate clock read to
|
|
248
|
+
// reconcile it against.
|
|
249
|
+
const openFact = db
|
|
250
|
+
.prepare(`SELECT id, valid_from_t FROM facts WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL
|
|
251
|
+
ORDER BY valid_from_t DESC, id DESC LIMIT 1`)
|
|
252
|
+
.get(entityId, key);
|
|
253
|
+
if (openFact) {
|
|
254
|
+
factId = openFact.id;
|
|
255
|
+
t = openFact.valid_from_t;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
// Only reachable if newValue is NULL (the trigger's INSERT is also
|
|
259
|
+
// guarded `WHERE NEW.<col> IS NOT NULL`) -- not a case a numeric
|
|
260
|
+
// constrained write produces, but this function is not itself the
|
|
261
|
+
// place to assume that; fall back to the clock rather than throw.
|
|
262
|
+
const story = currentStoryTime(gameId);
|
|
263
|
+
if (!story) {
|
|
264
|
+
throw new Error(`timeline: game '${gameId}' has no timeline clock to attach this write to`);
|
|
265
|
+
}
|
|
266
|
+
t = story.t;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
assertT(t);
|
|
270
|
+
const at = new Date().toISOString();
|
|
271
|
+
const delta = newValue - previousValue;
|
|
272
|
+
const eventId = uuidv4();
|
|
273
|
+
// `causes` deliberately does NOT carry a `row_id` key. irreversible.ts's
|
|
274
|
+
// `findOpenedByEventId` matches `json_extract(causes, '$.row_id')` to
|
|
275
|
+
// attach design §5.2c's one hop of provenance, picking the first event at
|
|
276
|
+
// a given `t` by a random hex id when more than one matches. `row_id` is
|
|
277
|
+
// the PROJECTION triggers' own token for "the live row this projection
|
|
278
|
+
// event was generated from" (projection.ts) -- an annotation event this
|
|
279
|
+
// choke point writes is not a projection event, and if it carried
|
|
280
|
+
// `row_id` too, a projection event and an annotation event sharing one
|
|
281
|
+
// `t` (which a constrained write's own UPDATE produces: the `_au`
|
|
282
|
+
// trigger's `<kind>.updated` event and this `value.changed` event both
|
|
283
|
+
// land at the same `t`) would make `findOpenedByEventId`'s pick
|
|
284
|
+
// non-deterministic. `entity_id` is the accurate key for what this event
|
|
285
|
+
// is about anyway, so using it instead of `row_id` is both the honest
|
|
286
|
+
// name and the one that can never collide with that lookup.
|
|
287
|
+
const causes = JSON.stringify({
|
|
288
|
+
source: "constrained_write",
|
|
289
|
+
entity_id: entityId,
|
|
290
|
+
key,
|
|
291
|
+
fact_id: factId,
|
|
292
|
+
previous_value: previousValue,
|
|
293
|
+
new_value: newValue,
|
|
294
|
+
delta,
|
|
295
|
+
at,
|
|
296
|
+
});
|
|
297
|
+
db.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'value.changed', ?, ?)`).run(eventId, gameId, t, reason, causes);
|
|
298
|
+
return { entityId, key, previousValue, newValue, delta, reason, t, factId, eventId, at };
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The TEXT form SQLite's projection triggers would have produced for
|
|
302
|
+
* `attemptedValue` had this write actually landed in `table.key` --
|
|
303
|
+
* reproduced by asking SQLite itself, not by formatting the number in JS.
|
|
304
|
+
*
|
|
305
|
+
* This exists because of a measured trap (checkpoint.ts's doc comment,
|
|
306
|
+
* timeline-architecture.md): a bound JS number carries no column affinity of
|
|
307
|
+
* its own, so `String(100)` is `"100"`, but SQLite's own
|
|
308
|
+
* `CAST(100 AS REAL)` -- what actually happens when 100 is stored into a
|
|
309
|
+
* REAL-affinity column like `resources.value` -- renders as TEXT
|
|
310
|
+
* `"100.0"`. Comparing the JS-formatted string against an irreversible
|
|
311
|
+
* fact's stored value would then silently never match a REAL column, which
|
|
312
|
+
* is every constrained numeric column this choke point writes today.
|
|
313
|
+
*
|
|
314
|
+
* `CAST(x AS <type-name>)` uses the same 5-rule algorithm SQLite uses to
|
|
315
|
+
* derive column affinity from a declared type name (SQLite's own
|
|
316
|
+
* documentation for CAST expressions says so explicitly), so casting
|
|
317
|
+
* through the column's OWN declared type -- read from `pragma_table_info`,
|
|
318
|
+
* exactly where `resolveProjection`/`liveColumns` already get their column
|
|
319
|
+
* vocabulary from, never a caller-supplied string -- reproduces the
|
|
320
|
+
* identical conversion `CAST(NEW.<col> AS TEXT)` (projection.ts) applies to
|
|
321
|
+
* a value that actually lands in that column. `NUMERIC` is the fallback for
|
|
322
|
+
* a column with no declared type; SQLite treats undeclared/empty type names
|
|
323
|
+
* as BLOB affinity for real columns, but every live column this function is
|
|
324
|
+
* ever called for is numeric by construction (`readLiveValue` already
|
|
325
|
+
* required one to get this far), so NUMERIC -- not BLOB -- is the honest
|
|
326
|
+
* default here.
|
|
327
|
+
*/
|
|
328
|
+
function castedTextForm(db, table, key, attemptedValue) {
|
|
329
|
+
const info = db.prepare(`SELECT type FROM pragma_table_info(?) WHERE name = ?`).get(table, key);
|
|
330
|
+
const declaredType = info?.type && info.type.length > 0 ? info.type : "NUMERIC";
|
|
331
|
+
const row = db.prepare(`SELECT CAST(CAST(? AS ${declaredType}) AS TEXT) AS text_form`).get(attemptedValue);
|
|
332
|
+
return row.text_form;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Design decision #7 / §5.2c: translates a write that failed inside
|
|
336
|
+
* `withTransaction` into a typed `ConstraintViolationError` carrying one hop
|
|
337
|
+
* of causality -- but ONLY when it can show, structurally, that an
|
|
338
|
+
* irreversible fact is why. `timeline_facts_irreversible` (the BEFORE
|
|
339
|
+
* INSERT trigger on `facts`, schema.ts) remains the only thing that decided
|
|
340
|
+
* to refuse the write; by the time this function runs, `withTransaction`
|
|
341
|
+
* has already rolled the whole attempt back, and nothing here can change
|
|
342
|
+
* that outcome -- it can only name it.
|
|
343
|
+
*
|
|
344
|
+
* Called with one attempt for `writeConstrainedValue`'s single leg, two for
|
|
345
|
+
* `transferConstrainedValue`'s two legs. A transfer's legs write different
|
|
346
|
+
* entities under the same key, and after rollback neither leg's UPDATE is
|
|
347
|
+
* visible any more -- there is no way to ask "which leg's row changed" from
|
|
348
|
+
* the live tables, only "does either leg's entity carry an irreversible
|
|
349
|
+
* fact that disagrees with what that leg tried to assert," which is exactly
|
|
350
|
+
* what `irreversibleFactFor` (irreversible.ts) can answer without touching
|
|
351
|
+
* the failed error at all.
|
|
352
|
+
*
|
|
353
|
+
* Deliberately does NOT inspect `err`'s message or SQLite error code --
|
|
354
|
+
* hard rule 4 (never pattern-match meaning) applies to this codebase's own
|
|
355
|
+
* generated text too, and a check against rows this module itself wrote
|
|
356
|
+
* keeps working even if the trigger's wording ever changes. If no attempt's
|
|
357
|
+
* entity/key carries an irreversible fact that disagrees with what was
|
|
358
|
+
* written, `err` is rethrown completely untouched -- losing an unrelated
|
|
359
|
+
* failure (the conserved-sum invariant check, a fault in some other
|
|
360
|
+
* trigger, anything that isn't this rejection) inside a translation layer
|
|
361
|
+
* would be far worse than leaving it untranslated.
|
|
362
|
+
*/
|
|
363
|
+
function translateIrreversibleFailure(db, attempts, err) {
|
|
364
|
+
for (const attempt of attempts) {
|
|
365
|
+
const fact = irreversibleFactFor(attempt.entityId, attempt.key);
|
|
366
|
+
if (!fact)
|
|
367
|
+
continue;
|
|
368
|
+
const attemptedText = castedTextForm(db, attempt.table, attempt.key, attempt.attemptedValue);
|
|
369
|
+
if (fact.value !== attemptedText) {
|
|
370
|
+
throw new ConstraintViolationError("irreversible", attempt.entityId, `Resource '${attempt.entityId}' has an irreversible fact for key '${attempt.key}': value '${fact.value}' ` +
|
|
371
|
+
`holds as of t=${fact.validFromT}` +
|
|
372
|
+
(fact.openedByEventId !== null
|
|
373
|
+
? ` (opened by event '${fact.openedByEventId}')`
|
|
374
|
+
: ` (no event is recorded for when this was opened)`) +
|
|
375
|
+
`. The attempted value '${attemptedText}' contradicts it and is refused.`, fact);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* A3: the only way a constrained numeric value changes. Resolve, check,
|
|
382
|
+
* clamp, write -- in that order, and the order is load-bearing: clamping
|
|
383
|
+
* BEFORE the constraint check would let a declared `bounded` constraint's
|
|
384
|
+
* rejection be silently satisfied by the very clamp it exists to prevent
|
|
385
|
+
* (an intended value of 150 against a [0, 100] bound would arrive at the
|
|
386
|
+
* check already clamped to 100, and a `bounded` constraint's whole point is
|
|
387
|
+
* to refuse 150, not to see 100). Clamping AFTER means the constraint check
|
|
388
|
+
* always sees the caller's actual, unclamped intent.
|
|
389
|
+
*/
|
|
390
|
+
export function writeConstrainedValue(params) {
|
|
391
|
+
const resolved = resolveProjection(params.entityId, params.key);
|
|
392
|
+
const db = getDatabase();
|
|
393
|
+
const previousValue = readLiveValue(db, resolved);
|
|
394
|
+
const intendedValue = params.mode === "delta" ? previousValue + params.value : params.value;
|
|
395
|
+
assertConstraintsAllow({
|
|
396
|
+
entityId: params.entityId,
|
|
397
|
+
key: params.key,
|
|
398
|
+
previousValue,
|
|
399
|
+
intendedValue,
|
|
400
|
+
bounds: params.bounds,
|
|
401
|
+
conservedMemberWrite: "reject",
|
|
402
|
+
context: params.context,
|
|
403
|
+
});
|
|
404
|
+
const newValue = params.bounds ? clamp(intendedValue, params.bounds.minValue, params.bounds.maxValue) : intendedValue;
|
|
405
|
+
const reason = params.reason ?? null;
|
|
406
|
+
try {
|
|
407
|
+
return withTransaction(() => applyLiveWrite({
|
|
408
|
+
entityId: params.entityId,
|
|
409
|
+
gameId: resolved.gameId,
|
|
410
|
+
table: resolved.table,
|
|
411
|
+
key: params.key,
|
|
412
|
+
previousValue,
|
|
413
|
+
newValue,
|
|
414
|
+
reason,
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: newValue }], err);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* A4: the counterpart-carrying two-leg write for conserved sets. Ported from
|
|
423
|
+
* `transferResourceValue` (src/tools/resource.ts) minus its argument
|
|
424
|
+
* validation (self-transfer, non-finite, negative, not-found), which stays
|
|
425
|
+
* in resource.ts because it is about resource IDENTITY, not about the
|
|
426
|
+
* constraint family this module owns -- see the doc comment there.
|
|
427
|
+
*
|
|
428
|
+
* Checks run BEFORE the transaction (membership, then bounded/monotonic on
|
|
429
|
+
* both legs, then the never-clamp bounds rejection), exactly as
|
|
430
|
+
* `transferResourceValue` ordered them -- a transfer that is going to be
|
|
431
|
+
* rejected should never touch either live row. Both legs' writes, plus the
|
|
432
|
+
* defense-in-depth sum re-verification, land in ONE `withTransaction()`.
|
|
433
|
+
*/
|
|
434
|
+
export function transferConstrainedValue(params) {
|
|
435
|
+
const { fromEntityId, toEntityId, key, amount } = params;
|
|
436
|
+
const fromLabel = params.fromLabel ?? fromEntityId;
|
|
437
|
+
const toLabel = params.toLabel ?? toEntityId;
|
|
438
|
+
const reason = params.reason ?? null;
|
|
439
|
+
const fromResolved = resolveProjection(fromEntityId, key);
|
|
440
|
+
const toResolved = resolveProjection(toEntityId, key);
|
|
441
|
+
const db = getDatabase();
|
|
442
|
+
const fromConstraint = conservedConstraintFor(fromEntityId, key);
|
|
443
|
+
const toConstraint = conservedConstraintFor(toEntityId, key);
|
|
444
|
+
if (!fromConstraint || !toConstraint || fromConstraint.id !== toConstraint.id) {
|
|
445
|
+
const details = [];
|
|
446
|
+
if (!fromConstraint)
|
|
447
|
+
details.push(`'${fromLabel}' (${fromEntityId}) is not a member of any 'conserved' constraint.`);
|
|
448
|
+
if (!toConstraint)
|
|
449
|
+
details.push(`'${toLabel}' (${toEntityId}) is not a member of any 'conserved' constraint.`);
|
|
450
|
+
if (fromConstraint && toConstraint && fromConstraint.id !== toConstraint.id) {
|
|
451
|
+
details.push(`They belong to different 'conserved' constraints ('${fromConstraint.id}' and '${toConstraint.id}').`);
|
|
452
|
+
}
|
|
453
|
+
throw new ConstraintViolationError("conserved", fromEntityId, `transfer_resource_value requires fromResourceId and toResourceId to both be members of the same declared ` +
|
|
454
|
+
`'conserved' constraint -- moving value between resources outside a shared conserved set would change ` +
|
|
455
|
+
`each side's total independently, which is what update_resource_value is for. ${details.join(" ")}`);
|
|
456
|
+
}
|
|
457
|
+
const fromPrev = readLiveValue(db, fromResolved);
|
|
458
|
+
const toPrev = readLiveValue(db, toResolved);
|
|
459
|
+
const fromIntended = fromPrev - amount;
|
|
460
|
+
const toIntended = toPrev + amount;
|
|
461
|
+
// Bounded/monotonic constraints, if separately declared, apply during a
|
|
462
|
+
// transfer exactly as they do during a direct write -- "allow" here means
|
|
463
|
+
// only "the conserved-member ambiguity does not apply to this write",
|
|
464
|
+
// never "skip the rest of the family".
|
|
465
|
+
assertConstraintsAllow({
|
|
466
|
+
entityId: fromEntityId,
|
|
467
|
+
key,
|
|
468
|
+
previousValue: fromPrev,
|
|
469
|
+
intendedValue: fromIntended,
|
|
470
|
+
bounds: params.fromBounds,
|
|
471
|
+
conservedMemberWrite: "allow",
|
|
472
|
+
});
|
|
473
|
+
assertConstraintsAllow({
|
|
474
|
+
entityId: toEntityId,
|
|
475
|
+
key,
|
|
476
|
+
previousValue: toPrev,
|
|
477
|
+
intendedValue: toIntended,
|
|
478
|
+
bounds: params.toBounds,
|
|
479
|
+
conservedMemberWrite: "allow",
|
|
480
|
+
});
|
|
481
|
+
assertWithinBoundsForTransfer(fromEntityId, fromLabel, params.fromBounds, fromIntended, "source");
|
|
482
|
+
assertWithinBoundsForTransfer(toEntityId, toLabel, params.toBounds, toIntended, "destination");
|
|
483
|
+
const constraintId = fromConstraint.id;
|
|
484
|
+
const declaredTotal = fromConstraint.total ?? 0;
|
|
485
|
+
const memberIds = fromConstraint.resourceIds;
|
|
486
|
+
try {
|
|
487
|
+
return withTransaction(() => {
|
|
488
|
+
const from = applyLiveWrite({
|
|
489
|
+
entityId: fromEntityId,
|
|
490
|
+
gameId: fromResolved.gameId,
|
|
491
|
+
table: fromResolved.table,
|
|
492
|
+
key,
|
|
493
|
+
previousValue: fromPrev,
|
|
494
|
+
newValue: fromIntended,
|
|
495
|
+
reason,
|
|
496
|
+
});
|
|
497
|
+
const to = applyLiveWrite({
|
|
498
|
+
entityId: toEntityId,
|
|
499
|
+
gameId: toResolved.gameId,
|
|
500
|
+
table: toResolved.table,
|
|
501
|
+
key,
|
|
502
|
+
previousValue: toPrev,
|
|
503
|
+
newValue: toIntended,
|
|
504
|
+
reason,
|
|
505
|
+
});
|
|
506
|
+
// Defense in depth: re-read every member of the set (inside this same
|
|
507
|
+
// transaction, so this sees the writes above) generically -- through
|
|
508
|
+
// resolveProjection's table resolution, never through a src/tools/
|
|
509
|
+
// resource accessor -- and assert it still sums to the declared total.
|
|
510
|
+
// The primary guarantee is structural (an equal and opposite delta,
|
|
511
|
+
// above); this turns any future bug in this function, or a schema
|
|
512
|
+
// change that opens another write path around it, into a loud rollback
|
|
513
|
+
// instead of a silently wrong total.
|
|
514
|
+
const currentSum = memberIds.reduce((sum, id) => {
|
|
515
|
+
const memberResolved = resolveProjection(id, key);
|
|
516
|
+
const row = db.prepare(`SELECT ${key} AS value FROM ${memberResolved.table} WHERE id = ?`).get(id);
|
|
517
|
+
return sum + (row?.value ?? 0);
|
|
518
|
+
}, 0);
|
|
519
|
+
if (Math.abs(currentSum - declaredTotal) > CONSERVED_SUM_EPSILON) {
|
|
520
|
+
throw new Error(`Invariant check failed after transfer: conserved constraint '${constraintId}' members now sum to ` +
|
|
521
|
+
`${currentSum}, expected ${declaredTotal}. Rolling back.`);
|
|
522
|
+
}
|
|
523
|
+
return { from, to };
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
translateIrreversibleFailure(db, [
|
|
528
|
+
{ entityId: fromEntityId, key, table: fromResolved.table, attemptedValue: fromIntended },
|
|
529
|
+
{ entityId: toEntityId, key, table: toResolved.table, attemptedValue: toIntended },
|
|
530
|
+
], err);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* A5: the payoff -- `valueHistory` is built ENTIRELY from the timeline
|
|
535
|
+
* (`facts` and `events`), with no `resource_history` in sight, because by
|
|
536
|
+
* this point in the merge there is no `resource_history` writer left to
|
|
537
|
+
* read from.
|
|
538
|
+
*
|
|
539
|
+
* Two sources, both scoped to `(entityId, key)`:
|
|
540
|
+
*
|
|
541
|
+
* 1. every FACT TRANSITION: consecutive facts, ordered `(valid_from_t,
|
|
542
|
+
* rowid)`, paired so each fact after the first supplies a
|
|
543
|
+
* previousValue/newValue/delta. The first fact is the value's
|
|
544
|
+
* CREATION, not a change -- `createResource` writes no history row
|
|
545
|
+
* today, and this function must not invent one, so the pairing loop
|
|
546
|
+
* starts at index 1, never 0.
|
|
547
|
+
* 2. every `"value.changed"` annotation event whose `causes.$.fact_id` is
|
|
548
|
+
* JSON null -- a constrained write that changed nothing. A no-op write
|
|
549
|
+
* opens no fact (see `applyLiveWrite`), so its annotation event is the
|
|
550
|
+
* ONLY record of it; without this second source, a zero-amount
|
|
551
|
+
* transfer or a zero-delta update would silently vanish from history,
|
|
552
|
+
* which is exactly what conserved.test.ts's "logged even though
|
|
553
|
+
* nothing moved" assertions were written against.
|
|
554
|
+
*
|
|
555
|
+
* Fact transitions are joined to their annotation (if any) by
|
|
556
|
+
* `json_extract(causes, '$.fact_id') = facts.id` -- an EXACT, unique link,
|
|
557
|
+
* because `applyLiveWrite` recorded the fact id at write time. This is
|
|
558
|
+
* deliberately stronger than irreversible.ts's `findOpenedByEventId`, which
|
|
559
|
+
* has to approximate the same relationship via `(at_t, row_id)` because the
|
|
560
|
+
* projection triggers that write `row_id` have no fact id to record at the
|
|
561
|
+
* point they fire (issue #2 predates this module). Here, recording the real
|
|
562
|
+
* id costs nothing extra and removes the approximation entirely.
|
|
563
|
+
*
|
|
564
|
+
* `json_valid(causes)` guards every extraction, matching irreversible.ts's
|
|
565
|
+
* `CASE WHEN json_valid(causes) THEN causes END` idiom for the same reason
|
|
566
|
+
* given there: `events.causes` has no CHECK constraint, timeline import
|
|
567
|
+
* (export.ts) carries it through verbatim by design, and SQLite's
|
|
568
|
+
* `json_extract` raises for the WHOLE query -- not just the offending row --
|
|
569
|
+
* when it meets a value that isn't JSON. A provenance hop must never be able
|
|
570
|
+
* to fail the query it annotates.
|
|
571
|
+
*
|
|
572
|
+
* Rows with no matching annotation (a direct column write, a bounds
|
|
573
|
+
* re-clamp, a startup reconciliation) come back with `reason: null`,
|
|
574
|
+
* `eventId: null`, `at: null` -- MORE history than `resource_history` ever
|
|
575
|
+
* held, because that table only ever got a row when
|
|
576
|
+
* `updateResourceValue`/`transferResourceValue` themselves wrote one.
|
|
577
|
+
*
|
|
578
|
+
* Ordered newest-first by `(t, rowid)` descending, matching
|
|
579
|
+
* `resource_history`'s old `ORDER BY timestamp DESC` in spirit -- but by the
|
|
580
|
+
* timeline's own axis, `t`, not by wall-clock time, because an unannotated
|
|
581
|
+
* transition has no wall-clock stamp to sort by and `t` is the one ordering
|
|
582
|
+
* this whole codebase agrees on (t.ts). `rowid` is the tiebreak for the rare
|
|
583
|
+
* case two rows share a `t`, mirroring `changes.ts`'s own tiebreak
|
|
584
|
+
* discipline. `limit` applies after ordering, never before.
|
|
585
|
+
*/
|
|
586
|
+
export function valueHistory(entityId, key, limit) {
|
|
587
|
+
const db = getDatabase();
|
|
588
|
+
const factRows = db
|
|
589
|
+
.prepare(`SELECT
|
|
590
|
+
f.id AS factId,
|
|
591
|
+
f.value AS value,
|
|
592
|
+
f.valid_from_t AS t,
|
|
593
|
+
f.rowid AS rid,
|
|
594
|
+
e.id AS eventId,
|
|
595
|
+
e.description AS reason,
|
|
596
|
+
json_extract(CASE WHEN json_valid(e.causes) THEN e.causes END, '$.at') AS at
|
|
597
|
+
FROM facts f
|
|
598
|
+
LEFT JOIN events e
|
|
599
|
+
ON e.kind = 'value.changed'
|
|
600
|
+
AND json_extract(CASE WHEN json_valid(e.causes) THEN e.causes END, '$.fact_id') = f.id
|
|
601
|
+
WHERE f.entity_id = ? AND f.key = ?
|
|
602
|
+
ORDER BY f.valid_from_t ASC, f.rowid ASC`)
|
|
603
|
+
.all(entityId, key);
|
|
604
|
+
const ranked = [];
|
|
605
|
+
for (let i = 1; i < factRows.length; i++) {
|
|
606
|
+
const prev = factRows[i - 1];
|
|
607
|
+
const cur = factRows[i];
|
|
608
|
+
assertT(cur.t);
|
|
609
|
+
const previousValue = Number(prev.value);
|
|
610
|
+
const newValue = Number(cur.value);
|
|
611
|
+
ranked.push({
|
|
612
|
+
t: cur.t,
|
|
613
|
+
rid: cur.rid,
|
|
614
|
+
transition: {
|
|
615
|
+
entityId,
|
|
616
|
+
key,
|
|
617
|
+
previousValue,
|
|
618
|
+
newValue,
|
|
619
|
+
delta: newValue - previousValue,
|
|
620
|
+
reason: cur.reason,
|
|
621
|
+
t: cur.t,
|
|
622
|
+
factId: cur.factId,
|
|
623
|
+
eventId: cur.eventId,
|
|
624
|
+
at: cur.at,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const noOpRows = db
|
|
629
|
+
.prepare(`SELECT
|
|
630
|
+
id AS eventId,
|
|
631
|
+
description AS reason,
|
|
632
|
+
at_t AS t,
|
|
633
|
+
json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.previous_value') AS previousValue,
|
|
634
|
+
json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.new_value') AS newValue,
|
|
635
|
+
json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.delta') AS delta,
|
|
636
|
+
json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.at') AS at,
|
|
637
|
+
rowid AS rid
|
|
638
|
+
FROM events
|
|
639
|
+
WHERE kind = 'value.changed'
|
|
640
|
+
AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.entity_id') = ?
|
|
641
|
+
AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.key') = ?
|
|
642
|
+
AND json_extract(CASE WHEN json_valid(causes) THEN causes END, '$.fact_id') IS NULL`)
|
|
643
|
+
.all(entityId, key);
|
|
644
|
+
for (const row of noOpRows) {
|
|
645
|
+
assertT(row.t);
|
|
646
|
+
ranked.push({
|
|
647
|
+
t: row.t,
|
|
648
|
+
rid: row.rid,
|
|
649
|
+
transition: {
|
|
650
|
+
entityId,
|
|
651
|
+
key,
|
|
652
|
+
previousValue: row.previousValue,
|
|
653
|
+
newValue: row.newValue,
|
|
654
|
+
delta: row.delta,
|
|
655
|
+
reason: row.reason,
|
|
656
|
+
t: row.t,
|
|
657
|
+
factId: null,
|
|
658
|
+
eventId: row.eventId,
|
|
659
|
+
at: row.at,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
ranked.sort((a, b) => {
|
|
664
|
+
const byT = -compareT(a.t, b.t);
|
|
665
|
+
if (byT !== 0)
|
|
666
|
+
return byT;
|
|
667
|
+
return b.rid - a.rid;
|
|
668
|
+
});
|
|
669
|
+
const limited = limit !== undefined ? ranked.slice(0, limit) : ranked;
|
|
670
|
+
return limited.map((r) => r.transition);
|
|
671
|
+
}
|