rcs-context 0.0.0-stage → 0.1.39
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/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
import {
|
|
3
|
+
canonicalJson,
|
|
4
|
+
INJECTABLE_VERIFICATIONS,
|
|
5
|
+
projectLedger,
|
|
6
|
+
projectionHash,
|
|
7
|
+
factsAt as selectFactsAt,
|
|
8
|
+
registryEntry,
|
|
9
|
+
renderFacts,
|
|
10
|
+
routeRowValid,
|
|
11
|
+
validateAssertionInput,
|
|
12
|
+
} from './memory-ledger-core.mjs'
|
|
13
|
+
|
|
14
|
+
const REDUCER_VERSION = 'r2'
|
|
15
|
+
const DEFAULT_MAX_PROJECTION_EVENTS = 5000
|
|
16
|
+
const DEFAULT_MAX_PROJECTION_MS = 30000
|
|
17
|
+
|
|
18
|
+
function jsonColumn(value) {
|
|
19
|
+
return value === undefined ? null : canonicalJson(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decodeRow(row) {
|
|
23
|
+
return JSON.parse(row.fact_json)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Archive reads are read-only by runtime enforcement, not convention: a caller
|
|
27
|
+
// holding the DTO cannot reach the archived row through a nested object either.
|
|
28
|
+
function deepFreeze(value) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
|
|
30
|
+
Object.freeze(value)
|
|
31
|
+
for (const key of Object.keys(value)) deepFreeze(value[key])
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nonEmptyString(value) {
|
|
36
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class MemoryLedger {
|
|
40
|
+
constructor(filename, {
|
|
41
|
+
reducer = projectLedger,
|
|
42
|
+
maxProjectionEvents = DEFAULT_MAX_PROJECTION_EVENTS,
|
|
43
|
+
maxProjectionMs = DEFAULT_MAX_PROJECTION_MS,
|
|
44
|
+
} = {}) {
|
|
45
|
+
if (typeof reducer !== 'function') throw new TypeError('reducer must be a function')
|
|
46
|
+
if (!Number.isInteger(maxProjectionEvents) || maxProjectionEvents <= 0) {
|
|
47
|
+
throw new TypeError('maxProjectionEvents must be a positive integer')
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isFinite(maxProjectionMs) || maxProjectionMs <= 0) {
|
|
50
|
+
throw new TypeError('maxProjectionMs must be a positive number')
|
|
51
|
+
}
|
|
52
|
+
this.db = new DatabaseSync(filename)
|
|
53
|
+
this.reducer = reducer
|
|
54
|
+
this.maxProjectionEvents = maxProjectionEvents
|
|
55
|
+
this.maxProjectionMs = maxProjectionMs
|
|
56
|
+
this.db.exec('PRAGMA foreign_keys = ON')
|
|
57
|
+
this.db.exec(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS ledger_observations (
|
|
59
|
+
event_key TEXT PRIMARY KEY,
|
|
60
|
+
scope TEXT NOT NULL,
|
|
61
|
+
sid TEXT NOT NULL,
|
|
62
|
+
seq INTEGER NOT NULL,
|
|
63
|
+
valid_at REAL NOT NULL,
|
|
64
|
+
recorded_at REAL NOT NULL,
|
|
65
|
+
op TEXT NOT NULL,
|
|
66
|
+
outcome TEXT NOT NULL,
|
|
67
|
+
subject_entity_id TEXT NOT NULL,
|
|
68
|
+
target_entity_id TEXT,
|
|
69
|
+
value_json TEXT,
|
|
70
|
+
generation_hints_json TEXT,
|
|
71
|
+
raw_ref TEXT,
|
|
72
|
+
raw_payload_json TEXT,
|
|
73
|
+
source_json TEXT,
|
|
74
|
+
extractor_version TEXT,
|
|
75
|
+
projection_state TEXT NOT NULL CHECK (projection_state IN ('pending', 'applied', 'dead')),
|
|
76
|
+
projection_error TEXT,
|
|
77
|
+
full_input_json TEXT NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS ledger_observations_scope
|
|
80
|
+
ON ledger_observations(scope, valid_at, sid, seq, event_key);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS ledger_assertions (
|
|
83
|
+
assertion_id TEXT PRIMARY KEY,
|
|
84
|
+
scope TEXT NOT NULL,
|
|
85
|
+
subject_entity_id TEXT NOT NULL,
|
|
86
|
+
sid TEXT NOT NULL,
|
|
87
|
+
seq INTEGER NOT NULL,
|
|
88
|
+
op TEXT NOT NULL,
|
|
89
|
+
target_assertion_id TEXT,
|
|
90
|
+
predicate TEXT NOT NULL,
|
|
91
|
+
value_json TEXT,
|
|
92
|
+
qualifiers_json TEXT,
|
|
93
|
+
valid_at REAL NOT NULL,
|
|
94
|
+
recorded_at REAL NOT NULL,
|
|
95
|
+
source_kind TEXT NOT NULL,
|
|
96
|
+
source_authority TEXT,
|
|
97
|
+
evidence_json TEXT,
|
|
98
|
+
verification TEXT NOT NULL,
|
|
99
|
+
reducer_version TEXT NOT NULL,
|
|
100
|
+
projection_state TEXT NOT NULL CHECK (projection_state IN ('pending', 'applied', 'dead')),
|
|
101
|
+
projection_error TEXT,
|
|
102
|
+
full_input_json TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS ledger_assertions_scope
|
|
105
|
+
ON ledger_assertions(scope, valid_at, sid, seq, assertion_id);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS ledger_entities (
|
|
108
|
+
scope TEXT NOT NULL,
|
|
109
|
+
entity_id TEXT NOT NULL,
|
|
110
|
+
generation INTEGER NOT NULL,
|
|
111
|
+
presence INTEGER NOT NULL,
|
|
112
|
+
opened_by_json TEXT,
|
|
113
|
+
closed_by_json TEXT,
|
|
114
|
+
last_valid_at REAL,
|
|
115
|
+
last_recorded_at REAL,
|
|
116
|
+
PRIMARY KEY (scope, entity_id)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
CREATE TABLE IF NOT EXISTS ledger_fact_versions (
|
|
120
|
+
fact_key TEXT NOT NULL,
|
|
121
|
+
scope TEXT NOT NULL,
|
|
122
|
+
subject_entity_id TEXT NOT NULL,
|
|
123
|
+
subject_display TEXT,
|
|
124
|
+
generation INTEGER,
|
|
125
|
+
predicate TEXT NOT NULL,
|
|
126
|
+
value_json TEXT,
|
|
127
|
+
qualifiers_json TEXT,
|
|
128
|
+
assertion_id TEXT NOT NULL,
|
|
129
|
+
evidence_json TEXT,
|
|
130
|
+
causal_order TEXT NOT NULL,
|
|
131
|
+
valid_from REAL NOT NULL,
|
|
132
|
+
valid_to REAL,
|
|
133
|
+
recorded_at REAL NOT NULL,
|
|
134
|
+
retired_at REAL,
|
|
135
|
+
status TEXT NOT NULL,
|
|
136
|
+
verification TEXT,
|
|
137
|
+
conflict_json TEXT,
|
|
138
|
+
op TEXT,
|
|
139
|
+
target_assertion_id TEXT,
|
|
140
|
+
sid TEXT,
|
|
141
|
+
seq INTEGER,
|
|
142
|
+
reducer_version TEXT,
|
|
143
|
+
source_kind TEXT,
|
|
144
|
+
source_authority TEXT,
|
|
145
|
+
fact_json TEXT NOT NULL
|
|
146
|
+
);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS ledger_fact_versions_current
|
|
148
|
+
ON ledger_fact_versions(scope, subject_entity_id, generation, retired_at, valid_to, status);
|
|
149
|
+
CREATE INDEX IF NOT EXISTS ledger_fact_versions_history
|
|
150
|
+
ON ledger_fact_versions(scope, valid_from, valid_to, recorded_at, retired_at);
|
|
151
|
+
|
|
152
|
+
CREATE TABLE IF NOT EXISTS ledger_observation_placements (
|
|
153
|
+
event_key TEXT NOT NULL,
|
|
154
|
+
scope TEXT NOT NULL,
|
|
155
|
+
entity_id TEXT NOT NULL,
|
|
156
|
+
role TEXT NOT NULL,
|
|
157
|
+
generation INTEGER,
|
|
158
|
+
unplaced INTEGER NOT NULL,
|
|
159
|
+
reason TEXT,
|
|
160
|
+
causal_order TEXT NOT NULL
|
|
161
|
+
);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS ledger_observation_placements_scope
|
|
163
|
+
ON ledger_observation_placements(scope, event_key);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE IF NOT EXISTS ledger_assertion_placements (
|
|
166
|
+
assertion_id TEXT NOT NULL,
|
|
167
|
+
scope TEXT NOT NULL,
|
|
168
|
+
entity_id TEXT NOT NULL,
|
|
169
|
+
generation INTEGER,
|
|
170
|
+
unplaced INTEGER NOT NULL,
|
|
171
|
+
reason TEXT,
|
|
172
|
+
causal_order TEXT NOT NULL
|
|
173
|
+
);
|
|
174
|
+
CREATE INDEX IF NOT EXISTS ledger_assertion_placements_scope
|
|
175
|
+
ON ledger_assertion_placements(scope, assertion_id);
|
|
176
|
+
|
|
177
|
+
CREATE TABLE IF NOT EXISTS ledger_meta (
|
|
178
|
+
scope TEXT PRIMARY KEY,
|
|
179
|
+
reducer_version TEXT NOT NULL,
|
|
180
|
+
projection_hash TEXT,
|
|
181
|
+
status TEXT NOT NULL DEFAULT 'applied',
|
|
182
|
+
projection_error TEXT,
|
|
183
|
+
failing_record TEXT
|
|
184
|
+
);
|
|
185
|
+
`)
|
|
186
|
+
this._ensureMetaColumns()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_ensureMetaColumns() {
|
|
190
|
+
const columns = new Set(this.db.prepare('PRAGMA table_info(ledger_meta)').all()
|
|
191
|
+
.map((column) => column.name))
|
|
192
|
+
for (const [name, definition] of [
|
|
193
|
+
['status', "TEXT NOT NULL DEFAULT 'applied'"],
|
|
194
|
+
['projection_error', 'TEXT'],
|
|
195
|
+
['failing_record', 'TEXT'],
|
|
196
|
+
]) {
|
|
197
|
+
if (!columns.has(name)) {
|
|
198
|
+
this.db.exec(`ALTER TABLE ledger_meta ADD COLUMN ${name} ${definition}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_transaction(write) {
|
|
204
|
+
this.db.exec('BEGIN IMMEDIATE')
|
|
205
|
+
try {
|
|
206
|
+
const result = write()
|
|
207
|
+
this.db.exec('COMMIT')
|
|
208
|
+
return result
|
|
209
|
+
} catch (error) {
|
|
210
|
+
this.db.exec('ROLLBACK')
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_storedHash(scope) {
|
|
216
|
+
const hash = this.db.prepare(
|
|
217
|
+
'SELECT projection_hash FROM ledger_meta WHERE scope = ?',
|
|
218
|
+
).get(scope)?.projection_hash ?? null
|
|
219
|
+
return hash === '' ? null : hash
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
appendObservation(input) {
|
|
223
|
+
const hashes = this._appendBatch({ observations: [input] })
|
|
224
|
+
return hashes[input?.scope] ?? null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
appendAssertion(input) {
|
|
228
|
+
const hashes = this._appendBatch({ assertions: [input] })
|
|
229
|
+
return hashes[input?.scope] ?? null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Batch archive (one transaction) followed by exactly one projection per affected
|
|
233
|
+
// scope. This is the audit path: 158 individual rebuilds collapsed to one pass.
|
|
234
|
+
appendObservations(inputs) {
|
|
235
|
+
return this._appendBatch({ observations: inputs })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
appendAssertions(inputs) {
|
|
239
|
+
return this._appendBatch({ assertions: inputs })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Mixed batch used when a proposal run needs to open its project entity and
|
|
243
|
+
// append its accepted assertions under exactly one projection.
|
|
244
|
+
appendEvents({ observations = [], assertions = [] } = {}) {
|
|
245
|
+
if (!Array.isArray(observations) || !Array.isArray(assertions)) {
|
|
246
|
+
throw new TypeError('appendEvents requires observation and assertion arrays')
|
|
247
|
+
}
|
|
248
|
+
return this._appendBatch({ observations, assertions })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Read-only archive lookup. Each call re-parses the canonical archive row into a
|
|
252
|
+
// frozen copy, so a caller mutating the returned object can never change what was
|
|
253
|
+
// archived. An optional `scope` turns a foreign record into the same `null` a
|
|
254
|
+
// missing record yields: callers cannot probe other scopes.
|
|
255
|
+
getObservation(eventKey, { scope } = {}) {
|
|
256
|
+
if (typeof eventKey !== 'string' || eventKey.trim() === '') {
|
|
257
|
+
throw new TypeError('getObservation requires a non-empty event key')
|
|
258
|
+
}
|
|
259
|
+
if (scope !== undefined && !nonEmptyString(scope)) {
|
|
260
|
+
throw new TypeError('getObservation scope must be a non-empty string when provided')
|
|
261
|
+
}
|
|
262
|
+
const row = this.db.prepare(
|
|
263
|
+
'SELECT full_input_json, projection_state, projection_error FROM ledger_observations WHERE event_key = ?',
|
|
264
|
+
).get(eventKey)
|
|
265
|
+
if (!row) return null
|
|
266
|
+
const archived = JSON.parse(row.full_input_json)
|
|
267
|
+
if (scope !== undefined && archived.scope !== scope) return null
|
|
268
|
+
return deepFreeze({
|
|
269
|
+
...archived,
|
|
270
|
+
eventKey,
|
|
271
|
+
projectionState: row.projection_state,
|
|
272
|
+
projectionError: row.projection_error ?? null,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Read-only assertion lookup, used by application replay to decide whether an
|
|
277
|
+
// accepted proposal really reached the archive. Like getObservation, a foreign
|
|
278
|
+
// record is indistinguishable from a missing one when a scope is supplied.
|
|
279
|
+
getAssertion(assertionId, { scope } = {}) {
|
|
280
|
+
if (typeof assertionId !== 'string' || assertionId.trim() === '') {
|
|
281
|
+
throw new TypeError('getAssertion requires a non-empty assertion id')
|
|
282
|
+
}
|
|
283
|
+
if (scope !== undefined && !nonEmptyString(scope)) {
|
|
284
|
+
throw new TypeError('getAssertion scope must be a non-empty string when provided')
|
|
285
|
+
}
|
|
286
|
+
const row = this.db.prepare(
|
|
287
|
+
'SELECT full_input_json, projection_state, projection_error FROM ledger_assertions WHERE assertion_id = ?',
|
|
288
|
+
).get(assertionId)
|
|
289
|
+
if (!row) return null
|
|
290
|
+
const archived = JSON.parse(row.full_input_json)
|
|
291
|
+
if (scope !== undefined && archived.scope !== scope) return null
|
|
292
|
+
return deepFreeze({
|
|
293
|
+
...archived,
|
|
294
|
+
assertionId,
|
|
295
|
+
projectionState: row.projection_state,
|
|
296
|
+
projectionError: row.projection_error ?? null,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_insertObservation(input, fullInput) {
|
|
301
|
+
this.db.prepare(`
|
|
302
|
+
INSERT INTO ledger_observations (
|
|
303
|
+
event_key, scope, sid, seq, valid_at, recorded_at, op, outcome,
|
|
304
|
+
subject_entity_id, target_entity_id, value_json, generation_hints_json,
|
|
305
|
+
raw_ref, raw_payload_json, source_json, extractor_version,
|
|
306
|
+
projection_state, projection_error, full_input_json
|
|
307
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)
|
|
308
|
+
`).run(
|
|
309
|
+
input.eventKey, input.scope, input.sid, input.seq, input.validAt, input.recordedAt,
|
|
310
|
+
input.op, input.outcome, input.subjectEntityId, input.targetEntityId ?? null,
|
|
311
|
+
jsonColumn(input.value), jsonColumn(input.generationHints), input.rawRef ?? null,
|
|
312
|
+
jsonColumn(input.rawPayload), jsonColumn(input.source), input.extractorVersion ?? null,
|
|
313
|
+
fullInput,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_insertAssertion(input, fullInput) {
|
|
318
|
+
this.db.prepare(`
|
|
319
|
+
INSERT INTO ledger_assertions (
|
|
320
|
+
assertion_id, scope, subject_entity_id, sid, seq, op, target_assertion_id,
|
|
321
|
+
predicate, value_json, qualifiers_json, valid_at, recorded_at,
|
|
322
|
+
source_kind, source_authority, evidence_json, verification, reducer_version,
|
|
323
|
+
projection_state, projection_error, full_input_json
|
|
324
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)
|
|
325
|
+
`).run(
|
|
326
|
+
input.assertionId, input.scope, input.subjectEntityId, input.sid, input.seq,
|
|
327
|
+
input.op, input.targetAssertionId ?? null, input.predicate, jsonColumn(input.value),
|
|
328
|
+
jsonColumn(input.qualifiers), input.validAt, input.recordedAt, input.sourceKind,
|
|
329
|
+
input.sourceAuthority ?? null, jsonColumn(input.evidence), input.verification,
|
|
330
|
+
REDUCER_VERSION, fullInput,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_appendBatch({ observations = [], assertions = [] } = {}) {
|
|
335
|
+
if (!Array.isArray(observations) || !Array.isArray(assertions)) {
|
|
336
|
+
throw new TypeError('batch append requires arrays')
|
|
337
|
+
}
|
|
338
|
+
const scopes = new Set()
|
|
339
|
+
const newScopes = new Set()
|
|
340
|
+
const pendingObservations = []
|
|
341
|
+
const pendingAssertions = []
|
|
342
|
+
const seenObservations = new Map()
|
|
343
|
+
const seenAssertions = new Map()
|
|
344
|
+
|
|
345
|
+
for (const input of observations) {
|
|
346
|
+
if (typeof input?.eventKey !== 'string' || input.eventKey === '' ||
|
|
347
|
+
typeof input?.scope !== 'string' || input.scope === '') {
|
|
348
|
+
throw new TypeError('observation requires a non-empty eventKey and scope')
|
|
349
|
+
}
|
|
350
|
+
const fullInput = canonicalJson(input)
|
|
351
|
+
if (seenObservations.has(input.eventKey)) {
|
|
352
|
+
if (seenObservations.get(input.eventKey) !== fullInput) {
|
|
353
|
+
throw new Error(`eventKey appears twice with different input: ${input.eventKey}`)
|
|
354
|
+
}
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
seenObservations.set(input.eventKey, fullInput)
|
|
358
|
+
const existing = this.db.prepare(
|
|
359
|
+
'SELECT scope, full_input_json FROM ledger_observations WHERE event_key = ?',
|
|
360
|
+
).get(input.eventKey)
|
|
361
|
+
if (existing) {
|
|
362
|
+
if (existing.full_input_json !== fullInput) {
|
|
363
|
+
throw new Error(`eventKey already archived with different input: ${input.eventKey}`)
|
|
364
|
+
}
|
|
365
|
+
scopes.add(existing.scope)
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
pendingObservations.push({ input, fullInput })
|
|
369
|
+
scopes.add(input.scope)
|
|
370
|
+
newScopes.add(input.scope)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const input of assertions) {
|
|
374
|
+
const validation = validateAssertionInput(input)
|
|
375
|
+
if (!validation.valid) {
|
|
376
|
+
throw new TypeError(
|
|
377
|
+
`invalid Assertion input (${input?.assertionId ?? 'unknown'}): ${validation.errors.join(',')}`,
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
const fullInput = canonicalJson(input)
|
|
381
|
+
if (seenAssertions.has(input.assertionId)) {
|
|
382
|
+
if (seenAssertions.get(input.assertionId) !== fullInput) {
|
|
383
|
+
throw new Error(`assertionId appears twice with different input: ${input.assertionId}`)
|
|
384
|
+
}
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
seenAssertions.set(input.assertionId, fullInput)
|
|
388
|
+
const existing = this.db.prepare(
|
|
389
|
+
'SELECT scope, full_input_json FROM ledger_assertions WHERE assertion_id = ?',
|
|
390
|
+
).get(input.assertionId)
|
|
391
|
+
if (existing) {
|
|
392
|
+
if (existing.full_input_json !== fullInput) {
|
|
393
|
+
throw new Error(`assertionId already archived with different input: ${input.assertionId}`)
|
|
394
|
+
}
|
|
395
|
+
scopes.add(existing.scope)
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
pendingAssertions.push({ input, fullInput })
|
|
399
|
+
scopes.add(input.scope)
|
|
400
|
+
newScopes.add(input.scope)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (pendingObservations.length > 0 || pendingAssertions.length > 0) {
|
|
404
|
+
this._transaction(() => {
|
|
405
|
+
for (const entry of pendingObservations) {
|
|
406
|
+
this._insertObservation(entry.input, entry.fullInput)
|
|
407
|
+
}
|
|
408
|
+
for (const entry of pendingAssertions) {
|
|
409
|
+
this._insertAssertion(entry.input, entry.fullInput)
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const hashes = {}
|
|
415
|
+
for (const scope of scopes) hashes[scope] = this._storedHash(scope)
|
|
416
|
+
for (const scope of newScopes) hashes[scope] = this.rebuild(scope)
|
|
417
|
+
return hashes
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
_archive(scope) {
|
|
421
|
+
const observations = this.db.prepare(`
|
|
422
|
+
SELECT full_input_json FROM ledger_observations WHERE scope = ?
|
|
423
|
+
ORDER BY valid_at, sid, seq, event_key
|
|
424
|
+
`).all(scope).map((row) => JSON.parse(row.full_input_json))
|
|
425
|
+
const assertions = this.db.prepare(`
|
|
426
|
+
SELECT full_input_json FROM ledger_assertions WHERE scope = ?
|
|
427
|
+
ORDER BY valid_at, sid, seq, assertion_id
|
|
428
|
+
`).all(scope).map((row) => JSON.parse(row.full_input_json))
|
|
429
|
+
return { observations, assertions }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_clearDerived(scope) {
|
|
433
|
+
this.db.prepare('DELETE FROM ledger_fact_versions WHERE scope = ?').run(scope)
|
|
434
|
+
this.db.prepare('DELETE FROM ledger_observation_placements WHERE scope = ?').run(scope)
|
|
435
|
+
this.db.prepare('DELETE FROM ledger_assertion_placements WHERE scope = ?').run(scope)
|
|
436
|
+
this.db.prepare('DELETE FROM ledger_entities WHERE scope = ?').run(scope)
|
|
437
|
+
this.db.prepare('DELETE FROM ledger_meta WHERE scope = ?').run(scope)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_projectionFailed(scope, error) {
|
|
441
|
+
const text = String(error?.stack ?? error?.message ?? error)
|
|
442
|
+
this._transaction(() => {
|
|
443
|
+
const pendingObservation = this.db.prepare(`
|
|
444
|
+
SELECT event_key FROM ledger_observations
|
|
445
|
+
WHERE scope = ? AND projection_state = 'pending'
|
|
446
|
+
ORDER BY recorded_at, event_key LIMIT 1
|
|
447
|
+
`).get(scope)
|
|
448
|
+
const pendingAssertion = this.db.prepare(`
|
|
449
|
+
SELECT assertion_id FROM ledger_assertions
|
|
450
|
+
WHERE scope = ? AND projection_state = 'pending'
|
|
451
|
+
ORDER BY recorded_at, assertion_id LIMIT 1
|
|
452
|
+
`).get(scope)
|
|
453
|
+
const latestObservation = this.db.prepare(`
|
|
454
|
+
SELECT event_key FROM ledger_observations
|
|
455
|
+
WHERE scope = ? ORDER BY recorded_at DESC, event_key DESC LIMIT 1
|
|
456
|
+
`).get(scope)
|
|
457
|
+
const latestAssertion = this.db.prepare(`
|
|
458
|
+
SELECT assertion_id FROM ledger_assertions
|
|
459
|
+
WHERE scope = ? ORDER BY recorded_at DESC, assertion_id DESC LIMIT 1
|
|
460
|
+
`).get(scope)
|
|
461
|
+
const failingRecord = pendingObservation?.event_key ?? pendingAssertion?.assertion_id ??
|
|
462
|
+
latestObservation?.event_key ?? latestAssertion?.assertion_id ?? null
|
|
463
|
+
|
|
464
|
+
// Only unprojected input becomes dead. Every previously derived row, fact
|
|
465
|
+
// version, and the last good projection hash stay intact so a single poison
|
|
466
|
+
// record can never blank an otherwise valid scope.
|
|
467
|
+
this.db.prepare(`
|
|
468
|
+
UPDATE ledger_observations SET projection_state = 'dead', projection_error = ?
|
|
469
|
+
WHERE scope = ? AND projection_state = 'pending'
|
|
470
|
+
`).run(text, scope)
|
|
471
|
+
this.db.prepare(`
|
|
472
|
+
UPDATE ledger_assertions SET projection_state = 'dead', projection_error = ?
|
|
473
|
+
WHERE scope = ? AND projection_state = 'pending'
|
|
474
|
+
`).run(text, scope)
|
|
475
|
+
|
|
476
|
+
this.db.prepare(`
|
|
477
|
+
INSERT INTO ledger_meta (
|
|
478
|
+
scope, reducer_version, projection_hash, status, projection_error, failing_record
|
|
479
|
+
) VALUES (?, ?, ?, 'failed', ?, ?)
|
|
480
|
+
ON CONFLICT(scope) DO UPDATE SET
|
|
481
|
+
reducer_version = excluded.reducer_version,
|
|
482
|
+
status = 'failed',
|
|
483
|
+
projection_error = excluded.projection_error,
|
|
484
|
+
failing_record = excluded.failing_record
|
|
485
|
+
`).run(scope, REDUCER_VERSION, this._storedHash(scope) ?? '', text, failingRecord)
|
|
486
|
+
})
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
projectionStatus(scope) {
|
|
491
|
+
const row = this.db.prepare(`
|
|
492
|
+
SELECT reducer_version, projection_hash, status, projection_error, failing_record
|
|
493
|
+
FROM ledger_meta WHERE scope = ?
|
|
494
|
+
`).get(scope)
|
|
495
|
+
if (!row) {
|
|
496
|
+
return {
|
|
497
|
+
status: 'empty', reducerVersion: null, projectionHash: null,
|
|
498
|
+
reason: null, failingRecord: null,
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
status: row.status ?? 'applied',
|
|
503
|
+
reducerVersion: row.reducer_version,
|
|
504
|
+
projectionHash: row.projection_hash === '' ? null : (row.projection_hash ?? null),
|
|
505
|
+
reason: row.projection_error ?? null,
|
|
506
|
+
failingRecord: row.failing_record ?? null,
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
rebuild(scope) {
|
|
511
|
+
const { observations, assertions } = this._archive(scope)
|
|
512
|
+
const eventCount = observations.length + assertions.length
|
|
513
|
+
if (eventCount > this.maxProjectionEvents) {
|
|
514
|
+
return this._projectionFailed(scope, new Error(
|
|
515
|
+
`projection-event-limit-exceeded: ${eventCount} > ${this.maxProjectionEvents}`,
|
|
516
|
+
))
|
|
517
|
+
}
|
|
518
|
+
const started = process.hrtime.bigint()
|
|
519
|
+
let projection
|
|
520
|
+
let hash
|
|
521
|
+
let prepared
|
|
522
|
+
try {
|
|
523
|
+
// Reduction and canonical serialization are pure and happen before BEGIN IMMEDIATE.
|
|
524
|
+
projection = this.reducer({ observations, assertions, reducerVersion: REDUCER_VERSION })
|
|
525
|
+
hash = projectionHash(projection)
|
|
526
|
+
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6
|
|
527
|
+
if (elapsedMs > this.maxProjectionMs) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`projection-time-limit-exceeded: ${elapsedMs.toFixed(0)}ms > ${this.maxProjectionMs}ms`,
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
prepared = {
|
|
533
|
+
entities: projection.entities.map((entity) => [
|
|
534
|
+
entity.scope, entity.entityId, entity.generation, entity.presence ? 1 : 0,
|
|
535
|
+
jsonColumn(entity.openedBy), jsonColumn(entity.closedBy),
|
|
536
|
+
entity.lastValidAt, entity.lastRecordedAt,
|
|
537
|
+
]),
|
|
538
|
+
observationPlacements: projection.observationPlacements.map((placement) => [
|
|
539
|
+
placement.eventKey, placement.scope, placement.entityId, placement.role,
|
|
540
|
+
placement.generation, placement.unplaced ? 1 : 0, placement.reason,
|
|
541
|
+
canonicalJson(placement.causalOrder),
|
|
542
|
+
]),
|
|
543
|
+
assertionPlacements: projection.assertionPlacements.map((placement) => [
|
|
544
|
+
placement.assertionId, placement.scope, placement.entityId, placement.generation,
|
|
545
|
+
placement.unplaced ? 1 : 0, placement.reason, canonicalJson(placement.causalOrder),
|
|
546
|
+
]),
|
|
547
|
+
facts: projection.factVersions.map((fact) => [
|
|
548
|
+
fact.factKey, fact.scope, fact.subjectEntityId, fact.subjectDisplay ?? null,
|
|
549
|
+
fact.generation, fact.predicate, jsonColumn(fact.value), jsonColumn(fact.qualifiers),
|
|
550
|
+
fact.assertionId, jsonColumn(fact.evidence), canonicalJson(fact.causalOrder),
|
|
551
|
+
fact.validFrom, fact.validTo, fact.recordedAt, fact.retiredAt, fact.status,
|
|
552
|
+
fact.verification ?? null, jsonColumn(fact.conflict), fact.op ?? null,
|
|
553
|
+
fact.targetAssertionId ?? null, fact.sid ?? null, fact.seq ?? null,
|
|
554
|
+
fact.reducerVersion ?? null, fact.sourceKind ?? null, fact.sourceAuthority ?? null,
|
|
555
|
+
canonicalJson(fact),
|
|
556
|
+
]),
|
|
557
|
+
}
|
|
558
|
+
} catch (error) {
|
|
559
|
+
return this._projectionFailed(scope, error)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return this._transaction(() => {
|
|
563
|
+
this._clearDerived(scope)
|
|
564
|
+
const insertEntity = this.db.prepare(`
|
|
565
|
+
INSERT INTO ledger_entities (
|
|
566
|
+
scope, entity_id, generation, presence, opened_by_json, closed_by_json,
|
|
567
|
+
last_valid_at, last_recorded_at
|
|
568
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
569
|
+
`)
|
|
570
|
+
for (const values of prepared.entities) insertEntity.run(...values)
|
|
571
|
+
|
|
572
|
+
const insertObservationPlacement = this.db.prepare(`
|
|
573
|
+
INSERT INTO ledger_observation_placements (
|
|
574
|
+
event_key, scope, entity_id, role, generation, unplaced, reason, causal_order
|
|
575
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
576
|
+
`)
|
|
577
|
+
for (const values of prepared.observationPlacements) insertObservationPlacement.run(...values)
|
|
578
|
+
|
|
579
|
+
const insertAssertionPlacement = this.db.prepare(`
|
|
580
|
+
INSERT INTO ledger_assertion_placements (
|
|
581
|
+
assertion_id, scope, entity_id, generation, unplaced, reason, causal_order
|
|
582
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
583
|
+
`)
|
|
584
|
+
for (const values of prepared.assertionPlacements) insertAssertionPlacement.run(...values)
|
|
585
|
+
|
|
586
|
+
const insertFact = this.db.prepare(`
|
|
587
|
+
INSERT INTO ledger_fact_versions (
|
|
588
|
+
fact_key, scope, subject_entity_id, subject_display, generation, predicate,
|
|
589
|
+
value_json, qualifiers_json, assertion_id, evidence_json, causal_order,
|
|
590
|
+
valid_from, valid_to, recorded_at, retired_at, status, verification,
|
|
591
|
+
conflict_json, op, target_assertion_id, sid, seq, reducer_version,
|
|
592
|
+
source_kind, source_authority, fact_json
|
|
593
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
594
|
+
`)
|
|
595
|
+
for (const values of prepared.facts) insertFact.run(...values)
|
|
596
|
+
|
|
597
|
+
this.db.prepare(`
|
|
598
|
+
INSERT INTO ledger_meta (
|
|
599
|
+
scope, reducer_version, projection_hash, status, projection_error, failing_record
|
|
600
|
+
) VALUES (?, ?, ?, 'applied', NULL, NULL)
|
|
601
|
+
ON CONFLICT(scope) DO UPDATE SET
|
|
602
|
+
reducer_version = excluded.reducer_version,
|
|
603
|
+
projection_hash = excluded.projection_hash,
|
|
604
|
+
status = 'applied',
|
|
605
|
+
projection_error = NULL,
|
|
606
|
+
failing_record = NULL
|
|
607
|
+
`).run(scope, REDUCER_VERSION, hash)
|
|
608
|
+
this.db.prepare(`
|
|
609
|
+
UPDATE ledger_observations SET projection_state = 'applied', projection_error = NULL WHERE scope = ?
|
|
610
|
+
`).run(scope)
|
|
611
|
+
this.db.prepare(`
|
|
612
|
+
UPDATE ledger_assertions SET projection_state = 'applied', projection_error = NULL WHERE scope = ?
|
|
613
|
+
`).run(scope)
|
|
614
|
+
const markDead = this.db.prepare(`
|
|
615
|
+
UPDATE ledger_assertions SET projection_state = 'dead', projection_error = ? WHERE assertion_id = ?
|
|
616
|
+
`)
|
|
617
|
+
for (const entry of projection.dead) markDead.run(entry.reason, entry.assertionId)
|
|
618
|
+
return hash
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Injection is re-gated on every read, so revoking a route immediately stops an
|
|
623
|
+
// already accepted LLM fact from rendering. A missing route table (the journal was
|
|
624
|
+
// never opened) is the default-closed state, not an error. The stored counters are
|
|
625
|
+
// re-validated against their evaluation evidence; a hand-edited row cannot open.
|
|
626
|
+
_llmRouteEnabled(fact) {
|
|
627
|
+
if (fact.sourceKind !== 'llm') return true
|
|
628
|
+
const evidence = fact.evidence
|
|
629
|
+
const model = evidence?.model
|
|
630
|
+
const modelDigest = evidence?.modelDigest
|
|
631
|
+
const schemaVersion = evidence?.schemaVersion
|
|
632
|
+
if (!nonEmptyString(model) || !nonEmptyString(modelDigest) ||
|
|
633
|
+
!nonEmptyString(schemaVersion) ||
|
|
634
|
+
!nonEmptyString(fact.predicate) || !nonEmptyString(fact.scope)) {
|
|
635
|
+
return false
|
|
636
|
+
}
|
|
637
|
+
let row
|
|
638
|
+
try {
|
|
639
|
+
row = this.db.prepare(`
|
|
640
|
+
SELECT sample_count, precision, safety_failures, evidence_json, model_digest
|
|
641
|
+
FROM ledger_proposal_routes
|
|
642
|
+
WHERE scope = ? AND model = ? AND model_digest = ? AND predicate = ?
|
|
643
|
+
AND schema_version = ?
|
|
644
|
+
`).get(fact.scope, model, modelDigest, fact.predicate, schemaVersion)
|
|
645
|
+
} catch {
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
if (!row || row.model_digest !== modelDigest) return false
|
|
649
|
+
let routeEvidence
|
|
650
|
+
try {
|
|
651
|
+
routeEvidence = row.evidence_json === null ? null : JSON.parse(row.evidence_json)
|
|
652
|
+
} catch {
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
655
|
+
return routeRowValid({
|
|
656
|
+
sampleCount: row.sample_count,
|
|
657
|
+
precision: row.precision,
|
|
658
|
+
recall: routeEvidence?.recall,
|
|
659
|
+
safetyFailures: row.safety_failures,
|
|
660
|
+
evidence: routeEvidence,
|
|
661
|
+
modelDigest: row.model_digest,
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Read-time proposal/span resolution. An LLM fact renders only when:
|
|
666
|
+
// - its proposalId resolves to the same scope as an accepted proposal whose
|
|
667
|
+
// payload names this assertion, or to a candidate promoted to this assertion;
|
|
668
|
+
// - the run identity matches the evidence model/digest/schema version;
|
|
669
|
+
// - the quoted span still resolves exactly against the archived observation's
|
|
670
|
+
// rawPayload.text.
|
|
671
|
+
// A missing proposal table, row, run, or span suppresses the fact. Hand-written
|
|
672
|
+
// `sourceKind:'llm'` facts therefore remain audit-only even with an open route.
|
|
673
|
+
_llmProposalResolved(fact) {
|
|
674
|
+
if (fact.sourceKind !== 'llm') return true
|
|
675
|
+
const evidence = fact.evidence
|
|
676
|
+
if (!evidence || typeof evidence !== 'object') return false
|
|
677
|
+
const proposalId = evidence.proposalId
|
|
678
|
+
const eventKey = evidence.eventKey
|
|
679
|
+
const quote = evidence.quote
|
|
680
|
+
if (!nonEmptyString(proposalId) || !nonEmptyString(eventKey) ||
|
|
681
|
+
!nonEmptyString(quote) || !nonEmptyString(fact.assertionId) ||
|
|
682
|
+
!nonEmptyString(fact.scope)) {
|
|
683
|
+
return false
|
|
684
|
+
}
|
|
685
|
+
if (!Number.isInteger(evidence.start) || !Number.isInteger(evidence.end) ||
|
|
686
|
+
evidence.start < 0 || evidence.end <= evidence.start) {
|
|
687
|
+
return false
|
|
688
|
+
}
|
|
689
|
+
let proposal
|
|
690
|
+
try {
|
|
691
|
+
proposal = this.db.prepare(`
|
|
692
|
+
SELECT p.verdict, p.promoted_assertion_id, p.payload_json,
|
|
693
|
+
r.status AS run_status, r.model AS run_model,
|
|
694
|
+
r.model_digest AS run_digest, r.schema_version AS run_schema
|
|
695
|
+
FROM ledger_proposals AS p
|
|
696
|
+
JOIN ledger_proposal_runs AS r ON r.run_id = p.run_id
|
|
697
|
+
WHERE p.proposal_id = ? AND p.scope = ? AND r.scope = ?
|
|
698
|
+
`).get(proposalId, fact.scope, fact.scope)
|
|
699
|
+
} catch {
|
|
700
|
+
return false
|
|
701
|
+
}
|
|
702
|
+
if (!proposal || proposal.run_status !== 'succeeded' ||
|
|
703
|
+
proposal.run_model !== evidence.model ||
|
|
704
|
+
proposal.run_digest !== evidence.modelDigest ||
|
|
705
|
+
proposal.run_schema !== evidence.schemaVersion) {
|
|
706
|
+
return false
|
|
707
|
+
}
|
|
708
|
+
if (proposal.verdict === 'accepted') {
|
|
709
|
+
let payload
|
|
710
|
+
try {
|
|
711
|
+
payload = proposal.payload_json === null ? null : JSON.parse(proposal.payload_json)
|
|
712
|
+
} catch {
|
|
713
|
+
return false
|
|
714
|
+
}
|
|
715
|
+
if (!payload || payload.assertionId !== fact.assertionId) return false
|
|
716
|
+
} else if (proposal.verdict === 'candidate') {
|
|
717
|
+
if (proposal.promoted_assertion_id !== fact.assertionId) return false
|
|
718
|
+
} else {
|
|
719
|
+
return false
|
|
720
|
+
}
|
|
721
|
+
let observation
|
|
722
|
+
try {
|
|
723
|
+
observation = this.db.prepare(
|
|
724
|
+
'SELECT full_input_json FROM ledger_observations WHERE event_key = ?',
|
|
725
|
+
).get(eventKey)
|
|
726
|
+
} catch {
|
|
727
|
+
return false
|
|
728
|
+
}
|
|
729
|
+
if (!observation) return false
|
|
730
|
+
const archived = JSON.parse(observation.full_input_json)
|
|
731
|
+
if (archived.scope !== fact.scope || archived.op !== 'user_message' ||
|
|
732
|
+
archived.source?.kind !== 'user') {
|
|
733
|
+
return false
|
|
734
|
+
}
|
|
735
|
+
const text = archived.rawPayload?.text
|
|
736
|
+
if (typeof text !== 'string' || text === '') return false
|
|
737
|
+
return text.slice(evidence.start, evidence.end) === quote
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
currentFacts(scope, { injectableOnly = false } = {}) {
|
|
741
|
+
const rows = this.db.prepare(`
|
|
742
|
+
SELECT f.fact_json
|
|
743
|
+
FROM ledger_fact_versions AS f
|
|
744
|
+
JOIN ledger_entities AS e
|
|
745
|
+
ON e.scope = f.scope AND e.entity_id = f.subject_entity_id AND e.generation = f.generation
|
|
746
|
+
WHERE f.scope = ? AND f.valid_to IS NULL AND f.retired_at IS NULL
|
|
747
|
+
AND f.status IN ('active', 'conflicted')
|
|
748
|
+
ORDER BY f.fact_key, f.assertion_id
|
|
749
|
+
`).all(scope).map(decodeRow)
|
|
750
|
+
if (!injectableOnly) return rows
|
|
751
|
+
// `llm` is no longer suppressed by source kind: the verification gate admits
|
|
752
|
+
// only quote-derived evidence, the route gate re-checks the model build's
|
|
753
|
+
// current capability, and every LLM fact must resolve to its journaled proposal
|
|
754
|
+
// and archived span at read time.
|
|
755
|
+
return rows.filter((fact) => {
|
|
756
|
+
const predicate = registryEntry(fact.predicate)
|
|
757
|
+
return fact.status === 'active' && fact.conflict == null &&
|
|
758
|
+
predicate?.injectable === true &&
|
|
759
|
+
fact.sourceKind !== 'legacy' &&
|
|
760
|
+
INJECTABLE_VERIFICATIONS.has(fact.verification) &&
|
|
761
|
+
this._llmRouteEnabled(fact) &&
|
|
762
|
+
this._llmProposalResolved(fact)
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
factsAt(scope, query) {
|
|
767
|
+
if (!query || !Number.isFinite(query.validAt) || !Number.isFinite(query.recordedAt)) {
|
|
768
|
+
throw new TypeError('factsAt requires finite validAt and recordedAt')
|
|
769
|
+
}
|
|
770
|
+
const factVersions = this.db.prepare(`
|
|
771
|
+
SELECT fact_json FROM ledger_fact_versions WHERE scope = ?
|
|
772
|
+
ORDER BY valid_from, recorded_at, fact_key, assertion_id
|
|
773
|
+
`).all(scope).map(decodeRow)
|
|
774
|
+
return selectFactsAt({ factVersions }, { validAt: query.validAt, recordedAt: query.recordedAt })
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
renderCurrent(scope, budget) {
|
|
778
|
+
return renderFacts(this.currentFacts(scope, { injectableOnly: true }), budget)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
_withProjectionState(scope, state) {
|
|
782
|
+
const observations = this.db.prepare(`
|
|
783
|
+
SELECT full_input_json, projection_state, projection_error
|
|
784
|
+
FROM ledger_observations WHERE scope = ? AND projection_state = ?
|
|
785
|
+
`).all(scope, state)
|
|
786
|
+
const assertions = this.db.prepare(`
|
|
787
|
+
SELECT full_input_json, projection_state, projection_error
|
|
788
|
+
FROM ledger_assertions WHERE scope = ? AND projection_state = ?
|
|
789
|
+
`).all(scope, state)
|
|
790
|
+
return [...observations, ...assertions].map((row) => ({
|
|
791
|
+
...JSON.parse(row.full_input_json),
|
|
792
|
+
projectionState: row.projection_state,
|
|
793
|
+
projectionError: row.projection_error,
|
|
794
|
+
reason: row.projection_error,
|
|
795
|
+
})).sort((a, b) => {
|
|
796
|
+
const left = a.eventKey ?? a.assertionId
|
|
797
|
+
const right = b.eventKey ?? b.assertionId
|
|
798
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
pending(scope) {
|
|
803
|
+
return this._withProjectionState(scope, 'pending')
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
dead(scope) {
|
|
807
|
+
return this._withProjectionState(scope, 'dead')
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
retryDead(eventKey) {
|
|
811
|
+
const observation = this.db.prepare(
|
|
812
|
+
'SELECT scope, projection_state FROM ledger_observations WHERE event_key = ?',
|
|
813
|
+
).get(eventKey)
|
|
814
|
+
if (observation) {
|
|
815
|
+
if (observation.projection_state !== 'dead') return this._storedHash(observation.scope)
|
|
816
|
+
this.db.prepare(`
|
|
817
|
+
UPDATE ledger_observations SET projection_state = 'pending', projection_error = NULL WHERE event_key = ?
|
|
818
|
+
`).run(eventKey)
|
|
819
|
+
return this.rebuild(observation.scope)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const assertion = this.db.prepare(
|
|
823
|
+
'SELECT scope, projection_state FROM ledger_assertions WHERE assertion_id = ?',
|
|
824
|
+
).get(eventKey)
|
|
825
|
+
if (!assertion) throw new Error(`unknown archived event or assertion: ${eventKey}`)
|
|
826
|
+
if (assertion.projection_state !== 'dead') return this._storedHash(assertion.scope)
|
|
827
|
+
this.db.prepare(`
|
|
828
|
+
UPDATE ledger_assertions SET projection_state = 'pending', projection_error = NULL WHERE assertion_id = ?
|
|
829
|
+
`).run(eventKey)
|
|
830
|
+
return this.rebuild(assertion.scope)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
close() {
|
|
834
|
+
this.db.close()
|
|
835
|
+
}
|
|
836
|
+
}
|