@agent-custody/state 0.1.8 → 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 +26 -12
- package/dist/blast.d.ts +1 -1
- package/dist/blast.js +4 -4
- package/dist/cli.js +56 -10
- package/dist/evals-ledger.js +4 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/ledger.d.ts +36 -25
- package/dist/ledger.js +86 -69
- package/dist/pack.d.ts +49 -0
- package/dist/pack.js +148 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +62 -39
- package/dist/storage.d.ts +104 -16
- package/dist/storage.js +249 -23
- package/dist/stores.d.ts +30 -1
- package/dist/stores.js +20 -0
- package/package.json +15 -6
package/dist/server.js
CHANGED
|
@@ -119,11 +119,16 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
119
119
|
const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
|
|
120
120
|
return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
|
|
121
121
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Removes a fact from every store it was written to, then asks each store's search whether it is really gone.
|
|
124
|
+
* The outcome per store is what the receipt records: verified, stillIndexed, unverified (the store cannot say), or failed.
|
|
125
|
+
*/
|
|
126
|
+
async function removeFromStores(fact) {
|
|
125
127
|
const removedFrom = [];
|
|
126
128
|
const stillHeld = [];
|
|
129
|
+
const verification = {};
|
|
130
|
+
const attempts = Math.max(1, opts.verify?.attempts ?? 3);
|
|
131
|
+
const delayMs = opts.verify?.delayMs ?? 200;
|
|
127
132
|
for (const store of opts.stores ?? []) {
|
|
128
133
|
const id = fact?.external?.[store.name];
|
|
129
134
|
if (!id)
|
|
@@ -134,9 +139,36 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
134
139
|
}
|
|
135
140
|
catch (e) {
|
|
136
141
|
stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
142
|
+
verification[store.name] = "failed";
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!store.verifyRemoved) {
|
|
146
|
+
verification[store.name] = "unverified";
|
|
147
|
+
continue;
|
|
137
148
|
}
|
|
149
|
+
let gone = false;
|
|
150
|
+
let checked = true;
|
|
151
|
+
for (let i = 0; i < attempts && !gone; i++) {
|
|
152
|
+
if (i > 0)
|
|
153
|
+
await new Promise((r) => setTimeout(r, delayMs * 2 ** (i - 1)));
|
|
154
|
+
try {
|
|
155
|
+
gone = await store.verifyRemoved(id, fact);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// The store could not be asked; that is not the same as the value being gone.
|
|
159
|
+
checked = false;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
verification[store.name] = !checked ? "unverified" : gone ? "verified" : "stillIndexed";
|
|
138
164
|
}
|
|
139
|
-
return {
|
|
165
|
+
return { removedFrom, stillHeld, verification };
|
|
166
|
+
}
|
|
167
|
+
async function forgetOne(factId, reason, keepDigest, compact = true) {
|
|
168
|
+
const fact = await ledger.get(factId);
|
|
169
|
+
const ev = await ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt }, ...(keepDigest === undefined ? {} : { keepDigest }), compact });
|
|
170
|
+
const { removedFrom, stillHeld, verification } = await removeFromStores(fact);
|
|
171
|
+
return { factId: ev.factId, valueDigest: ev.valueDigest, digestKind: ev.digestKind, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld, verification };
|
|
140
172
|
}
|
|
141
173
|
let currentActor = "anonymous";
|
|
142
174
|
let currentReceipt = null;
|
|
@@ -169,38 +201,27 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
169
201
|
const input = { subject: a.subject, predicate: a.predicate, value: a.value ?? null, space: a.space, actor: actorFor(a.actor), source: { receiptId }, provenance: writeProvenance, ...(a.validFrom ? { validFrom: a.validFrom } : {}), ...(a.confidence !== undefined ? { confidence: a.confidence } : {}), ...(a.supersedes ? { supersedes: a.supersedes } : {}) };
|
|
170
202
|
// The stores are written first, so their ids can be recorded on the fact; the ledger's checks run beforehand
|
|
171
203
|
// so a write the ledger would refuse never reaches a store.
|
|
172
|
-
ledger.validateAssert(input);
|
|
204
|
+
await ledger.validateAssert(input);
|
|
173
205
|
const external = {};
|
|
174
206
|
const preview = { ...input, factId: "pending", validFrom: input.validFrom ?? new Date().toISOString(), validTo: null, confidence: input.confidence ?? null };
|
|
175
207
|
for (const store of opts.stores ?? [])
|
|
176
208
|
external[store.name] = await store.put(preview);
|
|
177
|
-
const ev = ledger.assert({ ...input, external });
|
|
209
|
+
const ev = await ledger.assert({ ...input, external });
|
|
178
210
|
return json({ fact: ev.fact, eventId: ev.eventId, txTime: ev.txTime, supersedes: ev.supersedes });
|
|
179
211
|
}
|
|
180
212
|
case "memory.read": {
|
|
181
213
|
const { includeClaimed, requireVerified, ...q } = Read.parse(args);
|
|
182
|
-
const facts = ledger.asOf({ ...q, include: requireVerified ? "verified" : includeClaimed ? "all" : "attested" });
|
|
214
|
+
const facts = await ledger.asOf({ ...q, include: requireVerified ? "verified" : includeClaimed ? "all" : "attested" });
|
|
183
215
|
return { ...json({ facts }), _meta: { [FACTS_META_KEY]: facts.map((f) => f.factId) } };
|
|
184
216
|
}
|
|
185
217
|
case "memory.retract": {
|
|
186
218
|
const a = Retract.parse(args);
|
|
187
|
-
const fact = ledger.
|
|
188
|
-
const ev = ledger.retract({ factId: a.factId, actor: actorFor(a.actor), reason: a.reason, source: { receiptId } });
|
|
219
|
+
const fact = await ledger.get(a.factId);
|
|
220
|
+
const ev = await ledger.retract({ factId: a.factId, actor: actorFor(a.actor), reason: a.reason, source: { receiptId } });
|
|
189
221
|
// The ledger is retracted first: custody must not depend on a store being up. A store that fails to remove
|
|
190
222
|
// is reported, so the caller knows recall may still serve the value.
|
|
191
|
-
const stillHeld =
|
|
192
|
-
|
|
193
|
-
const id = fact?.external?.[store.name];
|
|
194
|
-
if (!id)
|
|
195
|
-
continue;
|
|
196
|
-
try {
|
|
197
|
-
await store.remove(id, fact);
|
|
198
|
-
}
|
|
199
|
-
catch (e) {
|
|
200
|
-
stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
const out = { eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, removedFrom: (opts.stores ?? []).map((s) => s.name).filter((n) => fact?.external?.[n] && !stillHeld.some((h) => h.startsWith(n))) };
|
|
223
|
+
const { removedFrom, stillHeld, verification } = await removeFromStores(fact);
|
|
224
|
+
const out = { eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, removedFrom, verification };
|
|
204
225
|
if (stillHeld.length > 0)
|
|
205
226
|
return { isError: true, content: [{ type: "text", text: `retracted in the ledger, but still held by ${stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
|
|
206
227
|
return json(out);
|
|
@@ -209,7 +230,7 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
209
230
|
if (provenance !== "attested")
|
|
210
231
|
return fail("confirmation must come through the receipts gateway; a self-reported caller cannot lift a fact out of quarantine");
|
|
211
232
|
const a = Confirm.parse(args);
|
|
212
|
-
const ev = ledger.confirm({ factId: a.factId, actor: actorFor(undefined), source: { receiptId } });
|
|
233
|
+
const ev = await ledger.confirm({ factId: a.factId, actor: actorFor(undefined), source: { receiptId } });
|
|
213
234
|
return json({ eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, source: ev.source });
|
|
214
235
|
}
|
|
215
236
|
case "memory.forget": {
|
|
@@ -221,52 +242,54 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
221
242
|
}
|
|
222
243
|
case "memory.hold": {
|
|
223
244
|
const a = Hold.parse(args);
|
|
224
|
-
return json(ledger.hold({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } }));
|
|
245
|
+
return json(await ledger.hold({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } }));
|
|
225
246
|
}
|
|
226
247
|
case "memory.release": {
|
|
227
248
|
const a = Hold.parse(args);
|
|
228
|
-
return json(ledger.release({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } }));
|
|
249
|
+
return json(await ledger.release({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } }));
|
|
229
250
|
}
|
|
230
251
|
case "memory.sweep": {
|
|
231
252
|
const a = Sweep.parse(args);
|
|
232
253
|
if (!a.before && !opts.retention)
|
|
233
254
|
return fail("sweep needs `before`, or a server started with retention windows");
|
|
255
|
+
// One query per space, each answered by the store's index: the facts learned before that space's cutoff.
|
|
234
256
|
const now = new Date();
|
|
235
|
-
const
|
|
236
|
-
const targets =
|
|
237
|
-
const
|
|
238
|
-
const cutoff =
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
const spaces = a.space !== undefined ? [a.space] : a.before ? [undefined] : await ledger.spaces();
|
|
258
|
+
const targets = [];
|
|
259
|
+
for (const space of spaces) {
|
|
260
|
+
const cutoff = a.before ?? (space === undefined ? null : retentionCutoff(opts.retention ?? {}, space, now));
|
|
261
|
+
if (cutoff !== null)
|
|
262
|
+
targets.push(...(await ledger.learnedBefore(cutoff, space)));
|
|
263
|
+
}
|
|
241
264
|
const forgotten = [];
|
|
242
265
|
const held = [];
|
|
243
266
|
for (const f of targets) {
|
|
244
|
-
if (
|
|
245
|
-
continue;
|
|
246
|
-
if (ledger.held(f.factId)) {
|
|
267
|
+
if (await ledger.held(f.factId)) {
|
|
247
268
|
held.push(f.factId);
|
|
248
269
|
continue;
|
|
249
270
|
}
|
|
250
|
-
forgotten.push(await forgetOne(f.factId, a.reason, a.keepDigest));
|
|
271
|
+
forgotten.push(await forgetOne(f.factId, a.reason, a.keepDigest, false));
|
|
251
272
|
}
|
|
273
|
+
if (forgotten.length > 0)
|
|
274
|
+
await ledger.compact();
|
|
252
275
|
const stillHeld = forgotten.flatMap((o) => o.stillHeld);
|
|
253
|
-
const out = { before: a.before ?? null, retention: a.before ? null : (opts.retention ?? null), space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, digestKind: o.digestKind, removedFrom: o.removedFrom })), held, stillHeld };
|
|
276
|
+
const out = { before: a.before ?? null, retention: a.before ? null : (opts.retention ?? null), space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, digestKind: o.digestKind, removedFrom: o.removedFrom, verification: o.verification })), held, stillHeld };
|
|
254
277
|
if (stillHeld.length > 0)
|
|
255
278
|
return { isError: true, content: [{ type: "text", text: `swept the ledger, but some values are still held by stores: ${stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
|
|
256
279
|
return json(out);
|
|
257
280
|
}
|
|
258
281
|
case "memory.get": {
|
|
259
282
|
const a = Get.parse(args);
|
|
260
|
-
const f = ledger.
|
|
283
|
+
const f = await ledger.get(a.factId);
|
|
261
284
|
if (!f)
|
|
262
285
|
return fail(`unknown fact ${a.factId}`);
|
|
263
|
-
const retracted = ledger.history(a.factId).some((e) => e.kind === "retract");
|
|
286
|
+
const retracted = (await ledger.history(a.factId)).some((e) => e.kind === "retract");
|
|
264
287
|
// Cedar has no null: absent fields stay absent, so a policy tests them with `has`.
|
|
265
288
|
const clean = Object.fromEntries(Object.entries({ ...f, source: f.source.receiptId ?? undefined, retracted }).filter(([, v]) => v !== null && v !== undefined));
|
|
266
289
|
return json(clean);
|
|
267
290
|
}
|
|
268
291
|
case "memory.history":
|
|
269
|
-
return json({ events: ledger.history(History.parse(args).factId) });
|
|
292
|
+
return json({ events: await ledger.history(History.parse(args).factId) });
|
|
270
293
|
default:
|
|
271
294
|
return fail(`unknown tool ${name}`);
|
|
272
295
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,32 +1,120 @@
|
|
|
1
1
|
import type { AssertEvent, LedgerEvent } from "./ledger.ts";
|
|
2
|
+
/** Which facts a query is about. Every field narrows; an absent field means any. */
|
|
3
|
+
export interface EventQuery {
|
|
4
|
+
/** only events the ledger had recorded by this instant, inclusive; the transaction-time cut of a bitemporal read */
|
|
5
|
+
txAtMost?: string;
|
|
6
|
+
/** only facts the ledger learned of strictly before this instant; the cut of a retention sweep */
|
|
7
|
+
txBefore?: string;
|
|
8
|
+
space?: string;
|
|
9
|
+
subject?: string;
|
|
10
|
+
predicate?: string;
|
|
11
|
+
}
|
|
2
12
|
export interface EventStore {
|
|
3
|
-
readonly kind:
|
|
13
|
+
readonly kind: string;
|
|
14
|
+
/** a path or a connection description, for reports; never a secret */
|
|
4
15
|
readonly location: string;
|
|
5
16
|
/** every event, in order */
|
|
6
|
-
|
|
7
|
-
|
|
17
|
+
events(): Promise<LedgerEvent[]>;
|
|
18
|
+
count(): Promise<number>;
|
|
19
|
+
/**
|
|
20
|
+
* Every event about the facts that match the query, in order: their asserts, the asserts that supersede them, and
|
|
21
|
+
* every retraction, confirmation, forget, hold and release citing them. What a bitemporal read needs, and no more.
|
|
22
|
+
*/
|
|
23
|
+
eventsAbout(q: EventQuery): Promise<LedgerEvent[]>;
|
|
24
|
+
/** every event that touched one fact, in order: its assert, the asserts that supersede it, everything citing its id */
|
|
25
|
+
eventsFor(factId: string): Promise<LedgerEvent[]>;
|
|
26
|
+
/** every space with at least one fact */
|
|
27
|
+
spaces(): Promise<string[]>;
|
|
28
|
+
append(event: LedgerEvent): Promise<void>;
|
|
8
29
|
/** replaces one assert event in place, for forget: the value is gone from the store, not merely superseded */
|
|
9
|
-
replaceAssert(event: AssertEvent): void
|
|
10
|
-
|
|
30
|
+
replaceAssert(event: AssertEvent): Promise<void>;
|
|
31
|
+
/** after erasures: reclaims whatever the store may still hold of the erased values. A no-op where nothing lingers. */
|
|
32
|
+
compact(): Promise<void>;
|
|
33
|
+
close(): Promise<void>;
|
|
11
34
|
}
|
|
35
|
+
/** The reference semantics of eventsAbout, over an in-memory list. The SQL stores must answer identically; the ledger suite runs against all of them. */
|
|
36
|
+
export declare function selectAbout(events: LedgerEvent[], q: EventQuery): LedgerEvent[];
|
|
37
|
+
/** The reference semantics of eventsFor. */
|
|
38
|
+
export declare function selectFor(events: LedgerEvent[], factId: string): LedgerEvent[];
|
|
12
39
|
export declare class JsonlStore implements EventStore {
|
|
13
|
-
readonly kind
|
|
40
|
+
readonly kind = "jsonl";
|
|
14
41
|
readonly location: string;
|
|
42
|
+
private cache;
|
|
15
43
|
constructor(file: string);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
44
|
+
private all;
|
|
45
|
+
events(): Promise<LedgerEvent[]>;
|
|
46
|
+
count(): Promise<number>;
|
|
47
|
+
eventsAbout(q: EventQuery): Promise<LedgerEvent[]>;
|
|
48
|
+
eventsFor(factId: string): Promise<LedgerEvent[]>;
|
|
49
|
+
spaces(): Promise<string[]>;
|
|
50
|
+
append(event: LedgerEvent): Promise<void>;
|
|
51
|
+
replaceAssert(event: AssertEvent): Promise<void>;
|
|
52
|
+
compact(): Promise<void>;
|
|
53
|
+
close(): Promise<void>;
|
|
20
54
|
}
|
|
21
55
|
export declare class SqliteStore implements EventStore {
|
|
22
|
-
readonly kind
|
|
56
|
+
readonly kind = "sqlite";
|
|
23
57
|
readonly location: string;
|
|
24
58
|
private readonly db;
|
|
25
59
|
constructor(file: string);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
60
|
+
private rows;
|
|
61
|
+
events(): Promise<LedgerEvent[]>;
|
|
62
|
+
count(): Promise<number>;
|
|
63
|
+
eventsAbout(q: EventQuery): Promise<LedgerEvent[]>;
|
|
64
|
+
eventsFor(factId: string): Promise<LedgerEvent[]>;
|
|
65
|
+
spaces(): Promise<string[]>;
|
|
66
|
+
append(event: LedgerEvent): Promise<void>;
|
|
67
|
+
replaceAssert(event: AssertEvent): Promise<void>;
|
|
68
|
+
compact(): Promise<void>;
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What the Postgres store needs from a client: the query method of a `pg` Pool or Client, and of PGlite. Pass your
|
|
73
|
+
* own pool, with whatever TLS, credentials, and sizing you already use; the store never closes a client it was given.
|
|
74
|
+
*/
|
|
75
|
+
export interface PostgresLike {
|
|
76
|
+
query(text: string, values?: unknown[]): Promise<{
|
|
77
|
+
rows: unknown[];
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
80
|
+
export interface PostgresOptions {
|
|
81
|
+
/** the table, optionally schema-qualified; default "events". Created if missing. */
|
|
82
|
+
table?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Whether compact() runs VACUUM FULL on the table after erasures, so the old row image that MVCC keeps is not left
|
|
85
|
+
* in the table file. Default true. It takes an exclusive lock for the rewrite, so a very large ledger may prefer
|
|
86
|
+
* false and its own vacuum schedule; the forget certificate then rests on that schedule.
|
|
87
|
+
*/
|
|
88
|
+
vacuum?: boolean;
|
|
89
|
+
/** shown as the store's location in reports; default the table name */
|
|
90
|
+
location?: string;
|
|
91
|
+
}
|
|
92
|
+
export declare class PostgresStore implements EventStore {
|
|
93
|
+
readonly kind = "postgres";
|
|
94
|
+
readonly location: string;
|
|
95
|
+
private readonly client;
|
|
96
|
+
private readonly table;
|
|
97
|
+
private readonly vacuum;
|
|
98
|
+
private readonly owned;
|
|
99
|
+
private ready;
|
|
100
|
+
constructor(client: PostgresLike, opts?: PostgresOptions & {
|
|
101
|
+
owned?: boolean;
|
|
102
|
+
});
|
|
103
|
+
private init;
|
|
104
|
+
private rows;
|
|
105
|
+
events(): Promise<LedgerEvent[]>;
|
|
106
|
+
count(): Promise<number>;
|
|
107
|
+
eventsAbout(q: EventQuery): Promise<LedgerEvent[]>;
|
|
108
|
+
eventsFor(factId: string): Promise<LedgerEvent[]>;
|
|
109
|
+
spaces(): Promise<string[]>;
|
|
110
|
+
append(event: LedgerEvent): Promise<void>;
|
|
111
|
+
replaceAssert(event: AssertEvent): Promise<void>;
|
|
112
|
+
compact(): Promise<void>;
|
|
113
|
+
close(): Promise<void>;
|
|
30
114
|
}
|
|
31
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* A store from a location: a postgres:// or postgresql:// URL (needs the `pg` package installed beside this one;
|
|
117
|
+
* a `?table=` query parameter names the table, `?vacuum=false` skips VACUUM FULL after erasures), a path ending in
|
|
118
|
+
* .sqlite or .db, or any other path as JSONL.
|
|
119
|
+
*/
|
|
32
120
|
export declare function openStore(location: string): EventStore;
|
package/dist/storage.js
CHANGED
|
@@ -1,35 +1,129 @@
|
|
|
1
|
-
// Where the ledger's events live. The ledger
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Where the ledger's events live. The ledger asks the store questions and never assumes it holds every event, so a
|
|
2
|
+
// store may be a file on this machine or a database shared by many servers.
|
|
3
|
+
//
|
|
4
|
+
// JSONL is the default and the auditable artefact: one event per line, readable by anyone, copied for an audit. It
|
|
5
|
+
// keeps the events in memory and answers from there. SQLite is for durability on one machine: transactional writes,
|
|
6
|
+
// write-ahead logging, an in-place forget that leaves no copy of the value behind, and every query answered by an
|
|
7
|
+
// index. Postgres is for a shared ledger: several memory servers on one table, the database your security team has
|
|
8
|
+
// already approved, the same queries pushed down as SQL.
|
|
9
|
+
//
|
|
10
|
+
// Every store answers the same four questions: everything (for export and audits), everything about the facts that
|
|
11
|
+
// match a filter (for "what is believed"), everything that touched one fact (for its history and its checks), and
|
|
12
|
+
// the spaces it holds (for retention). A store that cannot answer one of these from an index is the wrong store.
|
|
6
13
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
14
|
import { createRequire } from "node:module";
|
|
8
15
|
import { dirname } from "node:path";
|
|
16
|
+
const factIdOf = (e) => (e.kind === "assert" ? e.fact.factId : e.factId);
|
|
17
|
+
/** The reference semantics of eventsAbout, over an in-memory list. The SQL stores must answer identically; the ledger suite runs against all of them. */
|
|
18
|
+
export function selectAbout(events, q) {
|
|
19
|
+
const ids = new Set();
|
|
20
|
+
for (const e of events) {
|
|
21
|
+
if (e.kind !== "assert")
|
|
22
|
+
continue;
|
|
23
|
+
if (q.txAtMost !== undefined && e.txTime > q.txAtMost)
|
|
24
|
+
continue;
|
|
25
|
+
if (q.txBefore !== undefined && e.txTime >= q.txBefore)
|
|
26
|
+
continue;
|
|
27
|
+
if (q.space !== undefined && e.fact.space !== q.space)
|
|
28
|
+
continue;
|
|
29
|
+
if (q.subject !== undefined && e.fact.subject !== q.subject)
|
|
30
|
+
continue;
|
|
31
|
+
if (q.predicate !== undefined && e.fact.predicate !== q.predicate)
|
|
32
|
+
continue;
|
|
33
|
+
ids.add(e.fact.factId);
|
|
34
|
+
}
|
|
35
|
+
for (const e of events)
|
|
36
|
+
if (e.kind === "assert" && e.supersedes && ids.has(e.supersedes))
|
|
37
|
+
ids.add(e.fact.factId);
|
|
38
|
+
return events.filter((e) => ids.has(factIdOf(e)) && (q.txAtMost === undefined || e.txTime <= q.txAtMost));
|
|
39
|
+
}
|
|
40
|
+
/** The reference semantics of eventsFor. */
|
|
41
|
+
export function selectFor(events, factId) {
|
|
42
|
+
return events.filter((e) => (e.kind === "assert" ? e.fact.factId === factId || e.supersedes === factId : e.factId === factId));
|
|
43
|
+
}
|
|
9
44
|
export class JsonlStore {
|
|
10
45
|
kind = "jsonl";
|
|
11
46
|
location;
|
|
47
|
+
cache = null;
|
|
12
48
|
constructor(file) {
|
|
13
49
|
this.location = file;
|
|
14
50
|
if (!existsSync(file))
|
|
15
51
|
mkdirSync(dirname(file), { recursive: true });
|
|
16
52
|
}
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
return
|
|
20
|
-
|
|
53
|
+
all() {
|
|
54
|
+
if (this.cache)
|
|
55
|
+
return this.cache;
|
|
56
|
+
this.cache = existsSync(this.location) ? readFileSync(this.location, "utf8").split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)) : [];
|
|
57
|
+
return this.cache;
|
|
58
|
+
}
|
|
59
|
+
async events() {
|
|
60
|
+
return [...this.all()];
|
|
61
|
+
}
|
|
62
|
+
async count() {
|
|
63
|
+
return this.all().length;
|
|
64
|
+
}
|
|
65
|
+
async eventsAbout(q) {
|
|
66
|
+
return selectAbout(this.all(), q);
|
|
67
|
+
}
|
|
68
|
+
async eventsFor(factId) {
|
|
69
|
+
return selectFor(this.all(), factId);
|
|
70
|
+
}
|
|
71
|
+
async spaces() {
|
|
72
|
+
return [...new Set(this.all().filter((e) => e.kind === "assert").map((e) => e.fact.space))].sort();
|
|
21
73
|
}
|
|
22
|
-
append(event) {
|
|
74
|
+
async append(event) {
|
|
75
|
+
const list = this.all();
|
|
23
76
|
appendFileSync(this.location, JSON.stringify(event) + "\n");
|
|
77
|
+
list.push(event);
|
|
24
78
|
}
|
|
25
|
-
replaceAssert(event) {
|
|
26
|
-
const all = this.
|
|
79
|
+
async replaceAssert(event) {
|
|
80
|
+
const all = this.all().map((e) => (e.kind === "assert" && e.eventId === event.eventId ? event : e));
|
|
27
81
|
const tmp = `${this.location}.tmp`;
|
|
28
82
|
writeFileSync(tmp, all.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
29
83
|
renameSync(tmp, this.location);
|
|
84
|
+
this.cache = all;
|
|
30
85
|
}
|
|
31
|
-
|
|
86
|
+
async compact() { }
|
|
87
|
+
async close() { }
|
|
32
88
|
}
|
|
89
|
+
/** The columns every SQL store keeps beside the event's JSON, so its queries are index lookups and never a scan of the JSON. */
|
|
90
|
+
function columns(event) {
|
|
91
|
+
return event.kind === "assert"
|
|
92
|
+
? { factId: event.fact.factId, space: event.fact.space, subject: event.fact.subject, predicate: event.fact.predicate, supersedes: event.supersedes }
|
|
93
|
+
: { factId: event.factId, space: null, subject: null, predicate: null, supersedes: null };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The query behind eventsAbout, built with only the clauses the query has, so each store's planner uses the matching
|
|
97
|
+
* index instead of a scan; `bind` turns a value into that store's placeholder.
|
|
98
|
+
*/
|
|
99
|
+
function aboutSql(q, table, bind) {
|
|
100
|
+
const conds = ["kind = 'assert'"];
|
|
101
|
+
if (q.txAtMost !== undefined)
|
|
102
|
+
conds.push(`tx_time <= ${bind(q.txAtMost)}`);
|
|
103
|
+
if (q.txBefore !== undefined)
|
|
104
|
+
conds.push(`tx_time < ${bind(q.txBefore)}`);
|
|
105
|
+
if (q.space !== undefined)
|
|
106
|
+
conds.push(`space = ${bind(q.space)}`);
|
|
107
|
+
if (q.subject !== undefined)
|
|
108
|
+
conds.push(`subject = ${bind(q.subject)}`);
|
|
109
|
+
if (q.predicate !== undefined)
|
|
110
|
+
conds.push(`predicate = ${bind(q.predicate)}`);
|
|
111
|
+
const outer = q.txAtMost !== undefined ? ` AND tx_time <= ${bind(q.txAtMost)}` : "";
|
|
112
|
+
return `WITH matched AS (SELECT fact_id FROM ${table} WHERE ${conds.join(" AND ")}),
|
|
113
|
+
ids AS (SELECT fact_id FROM matched UNION SELECT fact_id FROM ${table} WHERE kind = 'assert' AND supersedes IN (SELECT fact_id FROM matched))
|
|
114
|
+
SELECT json FROM ${table} WHERE fact_id IN (SELECT fact_id FROM ids)${outer} ORDER BY seq`;
|
|
115
|
+
}
|
|
116
|
+
/** Distinct spaces by walking the space index one key at a time, rather than reading every row; non-assert rows have no space. */
|
|
117
|
+
const spacesSql = (table) => `WITH RECURSIVE s(space) AS (SELECT MIN(space) FROM ${table} UNION ALL SELECT (SELECT MIN(space) FROM ${table} WHERE space > s.space) FROM s WHERE s.space IS NOT NULL) SELECT space FROM s WHERE space IS NOT NULL`;
|
|
118
|
+
const forSql = (table, ph) => `SELECT json FROM ${table} WHERE fact_id = ${ph} OR supersedes = ${ph} ORDER BY seq`;
|
|
119
|
+
/** One index per question the ledger asks: by fact, by subject (and predicate), by space (and time), by what a fact supersedes, by time. */
|
|
120
|
+
const INDEXES = [
|
|
121
|
+
["fact", "(fact_id)"],
|
|
122
|
+
["subject", "(subject, predicate)"],
|
|
123
|
+
["space", "(space, tx_time)"],
|
|
124
|
+
["supersedes", "(supersedes)"],
|
|
125
|
+
["tx", "(tx_time)"],
|
|
126
|
+
];
|
|
33
127
|
export class SqliteStore {
|
|
34
128
|
kind = "sqlite";
|
|
35
129
|
location;
|
|
@@ -42,26 +136,158 @@ export class SqliteStore {
|
|
|
42
136
|
this.db = new DatabaseSync(file);
|
|
43
137
|
// secure_delete overwrites removed content with zeros, so a forgotten value does not linger in freed page space.
|
|
44
138
|
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON;");
|
|
45
|
-
this.db.exec("CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, tx_time TEXT NOT NULL, fact_id TEXT NOT NULL, json TEXT NOT NULL)");
|
|
46
|
-
|
|
139
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, tx_time TEXT NOT NULL, fact_id TEXT NOT NULL, space TEXT, subject TEXT, predicate TEXT, supersedes TEXT, json TEXT NOT NULL)");
|
|
140
|
+
// A ledger written before the query columns existed gets them filled from its JSON, once.
|
|
141
|
+
const have = new Set(this.db.prepare("PRAGMA table_info(events)").all().map((c) => c.name));
|
|
142
|
+
if (!have.has("space")) {
|
|
143
|
+
for (const c of ["space", "subject", "predicate", "supersedes"])
|
|
144
|
+
this.db.exec(`ALTER TABLE events ADD COLUMN ${c} TEXT`);
|
|
145
|
+
this.db.exec("UPDATE events SET space = json_extract(json, '$.fact.space'), subject = json_extract(json, '$.fact.subject'), predicate = json_extract(json, '$.fact.predicate'), supersedes = json_extract(json, '$.supersedes') WHERE kind = 'assert'");
|
|
146
|
+
}
|
|
147
|
+
for (const [name, cols] of INDEXES)
|
|
148
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS events_${name} ON events ${cols}`);
|
|
149
|
+
// Without statistics the planner guesses which index to use and can pick a wide one; analyze once, then let
|
|
150
|
+
// close() refresh the statistics when the table has grown enough to matter.
|
|
151
|
+
const stats = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_stat1'").get() && this.db.prepare("SELECT 1 FROM sqlite_stat1 WHERE tbl = 'events' LIMIT 1").get();
|
|
152
|
+
if (!stats)
|
|
153
|
+
this.db.exec("ANALYZE");
|
|
154
|
+
}
|
|
155
|
+
rows(sql, params = []) {
|
|
156
|
+
return this.db.prepare(sql).all(...params).map((r) => JSON.parse(r.json));
|
|
157
|
+
}
|
|
158
|
+
async events() {
|
|
159
|
+
return this.rows("SELECT json FROM events ORDER BY seq");
|
|
47
160
|
}
|
|
48
|
-
|
|
49
|
-
return this.db.prepare("SELECT
|
|
161
|
+
async count() {
|
|
162
|
+
return this.db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
|
|
50
163
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
164
|
+
async eventsAbout(q) {
|
|
165
|
+
const params = [];
|
|
166
|
+
const sql = aboutSql(q, "events", (v) => {
|
|
167
|
+
params.push(v);
|
|
168
|
+
return "?";
|
|
169
|
+
});
|
|
170
|
+
return this.rows(sql, params);
|
|
54
171
|
}
|
|
55
|
-
|
|
172
|
+
async eventsFor(factId) {
|
|
173
|
+
return this.rows(forSql("events", "?"), [factId, factId]);
|
|
174
|
+
}
|
|
175
|
+
async spaces() {
|
|
176
|
+
return this.db.prepare(spacesSql("events")).all().map((r) => r.space);
|
|
177
|
+
}
|
|
178
|
+
async append(event) {
|
|
179
|
+
const c = columns(event);
|
|
180
|
+
this.db.prepare("INSERT INTO events (event_id, kind, tx_time, fact_id, space, subject, predicate, supersedes, json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(event.eventId, event.kind, event.txTime, c.factId, c.space, c.subject, c.predicate, c.supersedes, JSON.stringify(event));
|
|
181
|
+
}
|
|
182
|
+
async replaceAssert(event) {
|
|
56
183
|
this.db.prepare("UPDATE events SET json = ? WHERE event_id = ?").run(JSON.stringify(event), event.eventId);
|
|
184
|
+
}
|
|
185
|
+
async compact() {
|
|
57
186
|
// The old row image would otherwise linger in the write-ahead log; a truncating checkpoint removes it.
|
|
58
187
|
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
59
188
|
}
|
|
60
|
-
close() {
|
|
189
|
+
async close() {
|
|
190
|
+
this.db.exec("PRAGMA optimize");
|
|
61
191
|
this.db.close();
|
|
62
192
|
}
|
|
63
193
|
}
|
|
64
|
-
|
|
194
|
+
export class PostgresStore {
|
|
195
|
+
kind = "postgres";
|
|
196
|
+
location;
|
|
197
|
+
client;
|
|
198
|
+
table;
|
|
199
|
+
vacuum;
|
|
200
|
+
owned;
|
|
201
|
+
ready = null;
|
|
202
|
+
constructor(client, opts = {}) {
|
|
203
|
+
const table = opts.table ?? "events";
|
|
204
|
+
if (!/^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/.test(table))
|
|
205
|
+
throw new Error(`postgres: table name must be a plain identifier, optionally schema-qualified; got "${table}"`);
|
|
206
|
+
this.client = client;
|
|
207
|
+
this.table = table;
|
|
208
|
+
this.vacuum = opts.vacuum ?? true;
|
|
209
|
+
this.owned = opts.owned ?? false;
|
|
210
|
+
this.location = opts.location ?? `postgres table ${table}`;
|
|
211
|
+
}
|
|
212
|
+
init() {
|
|
213
|
+
if (!this.ready) {
|
|
214
|
+
const t = this.table;
|
|
215
|
+
const idx = t.replace(".", "_");
|
|
216
|
+
this.ready = (async () => {
|
|
217
|
+
await this.client.query(`CREATE TABLE IF NOT EXISTS ${t} (seq BIGSERIAL PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, tx_time TEXT NOT NULL, fact_id TEXT NOT NULL, space TEXT, subject TEXT, predicate TEXT, supersedes TEXT, json TEXT NOT NULL)`);
|
|
218
|
+
for (const [name, cols] of INDEXES)
|
|
219
|
+
await this.client.query(`CREATE INDEX IF NOT EXISTS ${idx}_${name} ON ${t} ${cols}`);
|
|
220
|
+
})();
|
|
221
|
+
}
|
|
222
|
+
return this.ready;
|
|
223
|
+
}
|
|
224
|
+
async rows(sql, values = []) {
|
|
225
|
+
await this.init();
|
|
226
|
+
return (await this.client.query(sql, values)).rows.map((r) => (typeof r.json === "string" ? JSON.parse(r.json) : r.json));
|
|
227
|
+
}
|
|
228
|
+
async events() {
|
|
229
|
+
return this.rows(`SELECT json FROM ${this.table} ORDER BY seq`);
|
|
230
|
+
}
|
|
231
|
+
async count() {
|
|
232
|
+
await this.init();
|
|
233
|
+
return Number((await this.client.query(`SELECT COUNT(*) AS n FROM ${this.table}`)).rows[0].n);
|
|
234
|
+
}
|
|
235
|
+
async eventsAbout(q) {
|
|
236
|
+
const params = [];
|
|
237
|
+
const sql = aboutSql(q, this.table, (v) => {
|
|
238
|
+
params.push(v);
|
|
239
|
+
return `$${params.length}`;
|
|
240
|
+
});
|
|
241
|
+
return this.rows(sql, params);
|
|
242
|
+
}
|
|
243
|
+
async eventsFor(factId) {
|
|
244
|
+
return this.rows(forSql(this.table, "$1"), [factId]);
|
|
245
|
+
}
|
|
246
|
+
async spaces() {
|
|
247
|
+
await this.init();
|
|
248
|
+
return (await this.client.query(spacesSql(this.table))).rows.map((r) => r.space);
|
|
249
|
+
}
|
|
250
|
+
async append(event) {
|
|
251
|
+
await this.init();
|
|
252
|
+
const c = columns(event);
|
|
253
|
+
await this.client.query(`INSERT INTO ${this.table} (event_id, kind, tx_time, fact_id, space, subject, predicate, supersedes, json) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [event.eventId, event.kind, event.txTime, c.factId, c.space, c.subject, c.predicate, c.supersedes, JSON.stringify(event)]);
|
|
254
|
+
}
|
|
255
|
+
async replaceAssert(event) {
|
|
256
|
+
await this.init();
|
|
257
|
+
await this.client.query(`UPDATE ${this.table} SET json = $1 WHERE event_id = $2`, [JSON.stringify(event), event.eventId]);
|
|
258
|
+
}
|
|
259
|
+
async compact() {
|
|
260
|
+
// An UPDATE leaves the old row image in the table until vacuum; VACUUM FULL rewrites the table without it.
|
|
261
|
+
// The write-ahead log, replicas and backups keep their own copies for as long as their retention says.
|
|
262
|
+
if (this.vacuum)
|
|
263
|
+
await this.client.query(`VACUUM FULL ${this.table}`);
|
|
264
|
+
}
|
|
265
|
+
async close() {
|
|
266
|
+
if (this.owned)
|
|
267
|
+
await this.client.end();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A store from a location: a postgres:// or postgresql:// URL (needs the `pg` package installed beside this one;
|
|
272
|
+
* a `?table=` query parameter names the table, `?vacuum=false` skips VACUUM FULL after erasures), a path ending in
|
|
273
|
+
* .sqlite or .db, or any other path as JSONL.
|
|
274
|
+
*/
|
|
65
275
|
export function openStore(location) {
|
|
276
|
+
if (/^postgres(ql)?:\/\//i.test(location)) {
|
|
277
|
+
let Pool;
|
|
278
|
+
try {
|
|
279
|
+
({ Pool } = createRequire(import.meta.url)("pg"));
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new Error("a postgres:// ledger needs the pg package: npm install pg");
|
|
283
|
+
}
|
|
284
|
+
const url = new URL(location);
|
|
285
|
+
const table = url.searchParams.get("table") ?? undefined;
|
|
286
|
+
const vacuum = url.searchParams.get("vacuum") !== "false";
|
|
287
|
+
url.searchParams.delete("table");
|
|
288
|
+
url.searchParams.delete("vacuum");
|
|
289
|
+
const shown = `${url.protocol}//${url.username ? `${url.username}@` : ""}${url.host}${url.pathname}`;
|
|
290
|
+
return new PostgresStore(new Pool({ connectionString: url.toString() }), { ...(table ? { table } : {}), vacuum, location: `${shown}${table ? ` table ${table}` : ""}`, owned: true });
|
|
291
|
+
}
|
|
66
292
|
return /\.(sqlite|db)$/i.test(location) ? new SqliteStore(location) : new JsonlStore(location);
|
|
67
293
|
}
|