@cruba/crubal-postgres 0.0.0-stage → 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.
@@ -0,0 +1,813 @@
1
+ // store.ts: one owner's store on Postgres (docs/design/store.md). An entry is one transaction: lock the
2
+ // chain, build the entry, apply it with the engine's applyEntry, persist the difference, append the log.
3
+ // The log is the truth; `rebuild` gives every other table back from it.
4
+ import { applyEntry, diffWrites, differs, MemoryRecords } from "@cruba/crubal";
5
+ import { Engine, serialize } from "@cruba/crubal";
6
+ import { CrubalError } from "@cruba/crubal";
7
+ import { Access } from "@cruba/crubal";
8
+ import { apply, pending } from "@cruba/crubal";
9
+ import { entryHash, verifyChain, writeHash } from "@cruba/crubal";
10
+ import { transaction } from "./db.js";
11
+ import { isObj, loadState, persist, wipeState, recordOf } from "./state.js";
12
+ import { readBundle, readBundleOnto, verifyBundle } from "@cruba/crubal";
13
+ import { dryRun, restamp } from "@cruba/crubal";
14
+ import { checkAll, definitionsOf, unitsExtensionOf, workingBundle } from "./definitions.js";
15
+ import { decideReview, openItems } from "@cruba/crubal";
16
+ import { land, receive } from "@cruba/crubal";
17
+ import { entriesSince, markStale, publish } from "./projector.js";
18
+ import { copyWorking, loadWhole, Needs, settle, workingState } from "./working.js";
19
+ /** A state's maps copied, so a draft can be changed without touching what was loaded. */
20
+ function draftOf(state) {
21
+ // A copy of the working state is itself a working state, so the draft notes what it lacks.
22
+ const s = copyWorking(state);
23
+ return { records: s.records, bindings: s.bindings, instructions: s.instructions, edges: s.edges, review: s.reviews };
24
+ }
25
+ /** What two definitions of one key must agree on (11.4): the type, the unit, how many, the list of values. */
26
+ function facetShape(f) {
27
+ const values = Array.isArray(f.values) ? f.values.map((v) => v.value).join("|") : "";
28
+ const pick = f.pick !== undefined && String(f.pick) !== "1" ? `pick ${String(f.pick)}` : "";
29
+ return [f.type, f.unit ?? "", pick, values].filter(Boolean).join(" ");
30
+ }
31
+ /** Is a record the package's own (10.4): of a kind it defines, or wearing a class it defines? */
32
+ function ownRecord(own, r) {
33
+ return own.dict.kinds.has(r.kind) || (r.wears ?? []).some((w) => own.dict.classes.has(w));
34
+ }
35
+ /** The recipes of a package that lie between two versions, oldest first (10.1): `from` at or after the installed version, `to` at or before the new one. */
36
+ function recipesBetween(recipes, pkg, installed, target) {
37
+ const version = (s) => s.slice(s.lastIndexOf("/") + 1);
38
+ return recipes
39
+ .filter((r) => r.from.startsWith(`${pkg} /`) && version(r.from) >= installed && version(r.to) <= target)
40
+ .sort((a, b) => (version(a.from) < version(b.from) ? -1 : version(a.from) > version(b.from) ? 1 : 0));
41
+ }
42
+ export class PgStore {
43
+ pool;
44
+ owner;
45
+ chain;
46
+ options;
47
+ constructor(pool, owner, options = {}) {
48
+ this.pool = pool;
49
+ this.owner = owner;
50
+ this.options = typeof options === "string" ? { chain: options } : options;
51
+ this.chain = this.options.chain ?? "main";
52
+ }
53
+ /**
54
+ * Publish the views whose rows a write made stale (12.6): every view, or the named one, for its own
55
+ * audience or the one given. Runs outside any write transaction. Returns the stream entries published.
56
+ */
57
+ async project(view, audience, only) {
58
+ const p = await this.projecting();
59
+ if (!p)
60
+ throw new Error("the store has no views to project");
61
+ const names = view ? [view] : [...p.views.views.values()].filter((v) => !only || only(v)).map((v) => v.name);
62
+ const out = [];
63
+ for (const name of names)
64
+ out.push(...(await transaction(this.pool, (client) => publish(client, this.owner, p, name, audience))));
65
+ return out;
66
+ }
67
+ /**
68
+ * Publish every view whole next time, with a reset on each stream (12.6.2): an index that lost rows, or
69
+ * kept rows nothing removed, is brought back to what the store holds. The named view alone when given.
70
+ */
71
+ async republish(view) {
72
+ const p = await this.projecting();
73
+ if (!p)
74
+ return;
75
+ for (const v of p.views.views.values()) {
76
+ if (view && v.name !== view)
77
+ continue;
78
+ await this.pool.query("insert into published (owner, view, audience, version) values ($1, $2, $3, '*') on conflict (owner, view, audience) do update set version = '*'", [this.owner, v.name, v.show]);
79
+ }
80
+ }
81
+ /** The head of a view's stream for an audience (12.6.2): the last sequence number published, 0 when none. */
82
+ async streamHead(view, audience) {
83
+ const p = await this.projecting();
84
+ const v = p?.views.views.get(view);
85
+ if (!v)
86
+ throw new Error(`no view ${view}`);
87
+ const { rows } = await this.pool.query("select coalesce(max(seq), 0) as seq from projection_stream where owner = $1 and view = $2 and audience = $3 and version = $4", [this.owner, view, audience ?? v.show, v.version ?? ""]);
88
+ return Number(rows[0].seq);
89
+ }
90
+ /** The entries of a view's stream after `seq` (12.6.2). */
91
+ async entriesSince(view, seq, audience) {
92
+ const p = await this.projecting();
93
+ if (!p)
94
+ throw new Error("the store has no views");
95
+ const client = await this.pool.connect();
96
+ try {
97
+ return await entriesSince(client, this.owner, p, view, seq, audience);
98
+ }
99
+ finally {
100
+ client.release();
101
+ }
102
+ }
103
+ /** After a persist: mark what the entry made stale, inside the same transaction. */
104
+ async afterPersist(client, before, after, seq, parsed) {
105
+ const p = this.options.projecting ?? (parsed ? projectingOf(parsed) : await this.projectingFrom(client));
106
+ if (p?.views.views.size)
107
+ await markStale(client, this.owner, p, before, after, seq);
108
+ }
109
+ async freshen() {
110
+ // Views with `fresh: write` publish now (12.6); a view with a delay waits for the loop.
111
+ if (this.options.fresh === "write" && (await this.projecting())?.views.views.size)
112
+ await this.project(undefined, undefined, (v) => v.fresh === "write");
113
+ }
114
+ // ─── Definitions from the store (docs/design/publishing.md) ────────────────
115
+ defsCache;
116
+ /** The definitions in force: installed bundles in order, then the owner's define writes. Cached per chain head. */
117
+ async definitions(fresh = false) {
118
+ const client = await this.pool.connect();
119
+ try {
120
+ return await this.definitionsFrom(client, fresh);
121
+ }
122
+ finally {
123
+ client.release();
124
+ }
125
+ }
126
+ /** `fresh` builds a copy to try a change on (a trial reads onto it and must not touch the cache). */
127
+ async definitionsFrom(client, fresh = false) {
128
+ if (fresh)
129
+ return definitionsOf(client, this.owner);
130
+ const { rows } = await client.query("select head_seq from chains where owner = $1 and chain = $2", [this.owner, this.chain]);
131
+ const seq = rows.length ? Number(rows[0].head_seq) : 0;
132
+ if (this.defsCache && this.defsCache.seq === seq)
133
+ return this.defsCache.parsed;
134
+ const parsed = await definitionsOf(client, this.owner);
135
+ this.defsCache = { seq, parsed };
136
+ return parsed;
137
+ }
138
+ /** What the store projects with: handed in, else built from its definitions. */
139
+ async projecting() {
140
+ if (this.options.projecting)
141
+ return this.options.projecting;
142
+ const client = await this.pool.connect();
143
+ try {
144
+ return await this.projectingFrom(client);
145
+ }
146
+ finally {
147
+ client.release();
148
+ }
149
+ }
150
+ async projectingFrom(client) {
151
+ if (this.options.projecting)
152
+ return this.options.projecting;
153
+ return projectingOf(await this.definitionsFrom(client));
154
+ }
155
+ /**
156
+ * Install a published version (docs/design/publishing.md): verify the hash and signature, require the
157
+ * language version at or below ours and every extended package installed at its pinned version, write
158
+ * the install entry and keep the bundle, restamp through the recipes when an earlier version was
159
+ * installed, and check the definitions that result. Refused whole on any failure.
160
+ */
161
+ async install(bundle, keys, at) {
162
+ if (!bundle.sig && !this.options.allowUnsigned)
163
+ throw new Error(`${bundle.id}: unsigned bundles are refused; a store may allow them for development`);
164
+ const v = verifyBundle(bundle, bundle.sig ? keys : undefined);
165
+ if (!v.valid)
166
+ throw new Error(`${bundle.id}: the bundle doesn't verify (${v.reason})`);
167
+ if (Number(bundle.crubal ?? 2) > 2)
168
+ throw new Error(`${bundle.id} is written in CRUBAL ${bundle.crubal}, above this store's`);
169
+ const installed = (await this.state()).installed;
170
+ const have = (pkg) => installed.filter((i) => i.package === pkg).map((i) => String(i.version));
171
+ for (const e of bundle.extends ?? []) {
172
+ // Named by short name within the bundle's own publisher, or by id across publishers.
173
+ const dep = String(e.package).includes("/") ? String(e.package) : `${bundle.owner}/${e.package}`;
174
+ if (!have(dep).includes(String(e.version)))
175
+ throw new Error(`${bundle.id} extends ${e.package} /${e.version}, which isn't installed`);
176
+ }
177
+ const previous = have(String(bundle.id)).at(-1);
178
+ if (previous === bundle.version)
179
+ throw new Error(`${bundle.id} /${bundle.version} is already installed`);
180
+ // The definitions that would result must pass every publish check before anything is written.
181
+ const current = await this.definitions(true);
182
+ const next = readBundleOnto(bundle, current);
183
+ checkAll(next);
184
+ // One dictionary (11.4): a facet another installed package defines with a different type is a collision,
185
+ // refused here rather than letting the later definition silently win.
186
+ const own = readBundle(bundle);
187
+ {
188
+ // Against each other package's version in force; a key this package's installed version already
189
+ // defined the same way is no new collision (so two packages can unwind one, each in its turn).
190
+ const { rows: others } = await this.pool.query("select distinct on (package) package, json from bundles where owner = $1 and package <> $2 order by package, version desc", [this.owner, bundle.id]);
191
+ const { rows: mine } = previous ? await this.pool.query("select json from bundles where owner = $1 and package = $2 and version = $3", [this.owner, bundle.id, previous]) : { rows: [] };
192
+ const before = mine[0] ? readBundle(mine[0].json).dict.facets : new Map();
193
+ for (const o of others) {
194
+ const theirs = readBundle(o.json).dict.facets;
195
+ for (const [key, f] of own.dict.facets) {
196
+ const t = theirs.get(key);
197
+ if (!t)
198
+ continue;
199
+ const mine = facetShape(f);
200
+ if (mine !== facetShape(t) && (!before.get(key) || facetShape(before.get(key)) !== mine))
201
+ throw new Error(`${bundle.id} /${bundle.version} defines ${key} as ${mine}, but ${o.package} defines it as ${facetShape(t)}; one dictionary, one key`);
202
+ }
203
+ }
204
+ }
205
+ // A version may not take away a preset or item that live edges still grant (14.12): lapse or regrant first.
206
+ if (previous) {
207
+ const { rows: prevRows } = await this.pool.query("select json from bundles where owner = $1 and package = $2 and version = $3", [this.owner, bundle.id, previous]);
208
+ const was = prevRows[0] ? readBundle(prevRows[0].json).perms : current.perms;
209
+ const own = readBundle(bundle).perms;
210
+ const gone = new Set([...was.presets.keys(), ...was.menu.keys()].filter((n) => !own.presets.has(n) && !own.menu.has(n)));
211
+ if (gone.size) {
212
+ const now = at ?? new Date().toISOString();
213
+ const holding = [...installed.length ? (await this.state()).edges.values() : []].filter((e) => (!e.until || String(e.until) > now) && e.grants?.some((g) => gone.has(g)));
214
+ if (holding.length)
215
+ throw new Error(`${bundle.id} /${bundle.version} drops ${[...gone].join(", ")}, which ${holding.map((e) => String(e.edge)).join(", ")} still grant; lapse or regrant them first`);
216
+ }
217
+ }
218
+ // The bundle goes in first: the entry's own transaction rebuilds definitions from it. A refused install takes it out again.
219
+ await this.pool.query("insert into bundles (owner, package, version, hash, json) values ($1, $2, $3, $4, $5) on conflict (owner, package, version) do update set hash = excluded.hash, json = excluded.json", [this.owner, bundle.id, bundle.version, bundle.hash, bundle]);
220
+ let result;
221
+ try {
222
+ result = await this.commitFrom((draft) => {
223
+ const extra = [{ install: { package: String(bundle.id), version: String(bundle.version), hash: String(bundle.hash) } }];
224
+ // A pin move restamps through every recipe between the installed version and this one (10.1, 10.4),
225
+ // in order: a store two versions behind takes both steps, never only the first.
226
+ if (previous) {
227
+ const parsedRecipes = recipesBetween(next.recipes, String(bundle.package), previous, String(bundle.version));
228
+ for (const recipe of parsedRecipes) {
229
+ // A recipe restamps what the store holds for the package (10.4): records of its kinds and records wearing its classes.
230
+ const records = draft.records.all().filter((r) => ownRecord(own, r));
231
+ const { records: after, review } = restamp(records, recipe, next.dict);
232
+ for (const r of after)
233
+ draft.records.set(r.id, r);
234
+ for (const item of review)
235
+ draft.review.push({ ...item, step: item.step, migration: `${recipe.from} -> ${recipe.to}` });
236
+ }
237
+ }
238
+ // The version's instruction blocks (10.7) are issued here; drain applies them record by record.
239
+ for (const block of bundle.instructions ?? []) {
240
+ const issuer = String(block.issuer);
241
+ const list = block.instructions ?? [];
242
+ if (list.length)
243
+ draft.instructions.set(issuer, [...(draft.instructions.get(issuer) ?? []), ...list.map((i) => ({ ...i, issuer }))]);
244
+ }
245
+ const by = previous ? { migration: `${bundle.package} /${previous} -> ${bundle.package} /${bundle.version}` } : { person: "owner" };
246
+ return { by, extra, out: null };
247
+ }, at);
248
+ }
249
+ catch (e) {
250
+ await this.pool.query("delete from bundles where owner = $1 and package = $2 and version = $3", [this.owner, bundle.id, bundle.version]);
251
+ throw e;
252
+ }
253
+ if (!result.entry)
254
+ throw new Error("the install wrote nothing");
255
+ this.defsCache = undefined;
256
+ if (previous)
257
+ await this.staleAll((sql, params) => this.pool.query(sql, params), await this.projecting());
258
+ return result.entry;
259
+ }
260
+ /**
261
+ * Mark every row of every view stale: after a reinstall or a rebuild the next publish rebuilds and
262
+ * compares every row and announces what left, and the stream keeps its numbering. (Wiping the stored
263
+ * rows instead, as before 0.1.16, made the next publish a first one, which announces no removals, so an
264
+ * index kept rows nothing ever removed: ME's index held three versions of its shop.)
265
+ */
266
+ async staleAll(run, p) {
267
+ if (!p)
268
+ return;
269
+ for (const v of p.views.views.values()) {
270
+ await run(`insert into stale (owner, view, record, since) select $1, $2, id, 0 from records where owner = $1${v.level ? " and level = $3" : ""} on conflict do nothing`, v.level ? [this.owner, v.name, v.level] : [this.owner, v.name]);
271
+ await run("insert into stale (owner, view, record, since) select owner, view, record, 0 from projections where owner = $1 and view = $2 on conflict do nothing", [this.owner, v.name]);
272
+ }
273
+ }
274
+ /** Well-formed access (14.12) on what an entry changed: ownership, wearing, within, and every edge written. */
275
+ /**
276
+ * What the rules produce for the records an operation changed, and for their ancestors (6.4): a plain
277
+ * write runs the productions as a border run does, so a rollup over units follows a unit written by the
278
+ * owner's own commit. Derived values land in the draft with their rule as source, so the entry's writes
279
+ * carry them like any other value; a person's value is never overwritten.
280
+ */
281
+ /** Records a `rederive` asks for, derived whether or not the operation changed them. */
282
+ alsoDerive;
283
+ /**
284
+ * Run the productions over records as they stand (6.4), for records written before a rule existed: one
285
+ * entry per call, holding only the derived values that changed. The ids are loaded into the working set
286
+ * and derived with their ancestors; nothing else is written.
287
+ */
288
+ async rederive(ids, by = { platform: "rederive" }, at) {
289
+ if (!ids.length)
290
+ return null;
291
+ this.alsoDerive = new Set(ids);
292
+ try {
293
+ return (await this.commitFrom((d) => { for (const id of ids)
294
+ d.records.get(id); return { by, extra: [], out: null }; }, at)).entry;
295
+ }
296
+ finally {
297
+ this.alsoDerive = undefined;
298
+ }
299
+ }
300
+ derive(p, before, draft) {
301
+ if (!p.rules.some((r) => r.produces))
302
+ return;
303
+ const changed = [];
304
+ for (const [id, r] of draft.records) {
305
+ const was = before.records.get(id);
306
+ if (!was || serialize(was.values) !== serialize(r.values) || serialize(was.status ?? {}) !== serialize(r.status ?? {}))
307
+ changed.push(id);
308
+ }
309
+ for (const id of this.alsoDerive ?? [])
310
+ if (!changed.includes(id))
311
+ changed.push(id);
312
+ if (!changed.length)
313
+ return;
314
+ const engine = new Engine(p.dict, p.rules, () => draft.records);
315
+ for (const [, r] of draft.records)
316
+ engine.put(r);
317
+ const land = (id) => {
318
+ const rec = draft.records.get(id);
319
+ if (!rec || rec.status?.state === "archived")
320
+ return false;
321
+ // What the record reads from above (1.7) must be in the engine: the chain up along `of`, asked for by
322
+ // id so a working set loads just those, never the catalog.
323
+ const seen = new Set([id]);
324
+ for (let up = rec.of ? draft.records.get(rec.of) : undefined; up && !seen.has(up.id); up = up.of ? draft.records.get(up.of) : undefined) {
325
+ seen.add(up.id);
326
+ engine.put(up);
327
+ }
328
+ engine.put(rec);
329
+ const derived = engine.reproduce(id).filter((x) => (x.result === "derived" || x.result === "filled") && x.address && x.value !== undefined && x.rule);
330
+ let moved = false;
331
+ const values = { ...rec.values };
332
+ const sources = { ...(rec.sources ?? {}) };
333
+ for (const x of derived) {
334
+ const address = x.address;
335
+ if (serialize(values[address] ?? "∅") === serialize(x.value))
336
+ continue;
337
+ values[address] = x.value;
338
+ sources[address] = { origin: "derived", via: x.rule };
339
+ moved = true;
340
+ }
341
+ if (!moved)
342
+ return false;
343
+ const next = { ...rec, values, sources };
344
+ draft.records.set(id, next);
345
+ engine.put(next);
346
+ return true;
347
+ };
348
+ for (const id of changed) {
349
+ land(id);
350
+ if (engine.readsLowerLevels)
351
+ for (const up of engine.ancestorsOf(id))
352
+ land(up);
353
+ }
354
+ }
355
+ checkWrites(p, entry, after) {
356
+ const writes = (Array.isArray(entry.writes) ? entry.writes : []);
357
+ const touched = new Set(writes.map((w) => (typeof w.record === "string" ? w.record : "")).filter(Boolean));
358
+ const edges = writes.filter((w) => isObj(w.edge)).map((w) => w.edge);
359
+ if (!touched.size && !edges.length)
360
+ return;
361
+ const access = new Access(p.dict, p.perms, after.records, [...after.edges.values()], this.owner);
362
+ for (const id of touched) {
363
+ const r = after.records.get(id);
364
+ if (!r)
365
+ continue;
366
+ access.checkOwnership(r);
367
+ access.checkWears(r);
368
+ // Within: the chain up from this record only, never a scan of the catalog.
369
+ const seen = new Set([id]);
370
+ let cur = r.within;
371
+ while (cur) {
372
+ if (seen.has(cur))
373
+ throw new CrubalError(`${id} is within itself through ${cur}`);
374
+ seen.add(cur);
375
+ const up = after.records.get(cur);
376
+ if (up && up.owner !== r.owner)
377
+ throw new CrubalError(`${id} is within ${cur}, which belongs to ${up.owner}: within never crosses owners`);
378
+ cur = up?.within;
379
+ }
380
+ }
381
+ // An emergency edge is checked by the host against the platform's published terms (14.13), not here.
382
+ for (const e of edges)
383
+ if (e.relation !== "emergency")
384
+ access.checkEdge(e);
385
+ }
386
+ /** One record by id, from its row alone (no whole state): undefined when there is none. */
387
+ async get(id) {
388
+ const { rows } = await this.pool.query("select * from records where owner = $1 and id = $2", [this.owner, id]);
389
+ return rows[0] ? recordOf(rows[0]) : undefined;
390
+ }
391
+ /** The review items still open (9.5). */
392
+ async review() {
393
+ return openItems((await this.state()).reviews);
394
+ }
395
+ /**
396
+ * A review decision (9.5, 11.9, 14.7, runs.md): one entry holding the review write and what it causes;
397
+ * then the intakes the decision affects are replayed (9.6), each its own entry.
398
+ */
399
+ async decide(d, by = { person: "owner" }, at) {
400
+ const p = await this.definitions(true);
401
+ // The operation may run more than once while the working set loads (working.ts): each run changes a copy.
402
+ const fresh = () => ({ dict: { ...p.dict, facets: new Map(p.dict.facets) }, contracts: new Map(p.contracts.map((c) => [c.name, c])), units: [...(p.units ?? [])] });
403
+ const result = await this.commitFrom((draft) => {
404
+ const extra = decideReview(d, draft, fresh());
405
+ return { by, extra, out: null };
406
+ }, at);
407
+ this.defsCache = undefined;
408
+ const replayed = await this.replayFor(d, at);
409
+ return { entry: result.entry, replayed };
410
+ }
411
+ /** Replay the kept intakes a decision reaches: by facet, by contract field, or by unit spelling (9.6). */
412
+ async replayFor(d, at) {
413
+ const key = d.key;
414
+ const reaches = (o) => (key.startsWith("facet:") && o.to === key.slice(6) && o.outcome === "new") ||
415
+ (key.startsWith("contract:") && o.from === d.field && o.outcome === "unknown") ||
416
+ (key.startsWith("unit:") && isObj(o.cites) && o.cites.unit === key.slice(5));
417
+ if (!key.startsWith("facet:") && !key.startsWith("contract:") && !key.startsWith("unit:"))
418
+ return [];
419
+ if (d.decision === "reject" || d.decision === "ignore" || d.decision === "not a unit")
420
+ return [];
421
+ const p = await this.definitions(true);
422
+ const { rows } = await this.pool.query("select intake, contract, \"for\", raw, json from intake where owner = $1 order by received", [this.owner]);
423
+ const out = [];
424
+ for (const row of rows) {
425
+ const outcomes = row.json.outcomes ?? [];
426
+ if (!outcomes.some(reaches) || !row.for)
427
+ continue;
428
+ const c = p.contracts.find((x) => x.name === row.contract);
429
+ if (!c)
430
+ continue;
431
+ const body = Buffer.from(row.raw).toString("utf8");
432
+ const again = receive(c, body, row.for, p.dict, row.intake);
433
+ const recordId = row.for;
434
+ const earlier = `L-${row.intake.slice(2)}`;
435
+ const entry = await this.commitFrom((draft) => {
436
+ const r = draft.records.get(recordId);
437
+ if (!r)
438
+ return { by: { migration: "replay" }, out: null };
439
+ // What this intake landed before is replaced by its replay, never left to a tie with itself.
440
+ const values = { ...r.values };
441
+ const sources = { ...(r.sources ?? {}) };
442
+ for (const [k, s] of Object.entries(sources))
443
+ if (s.via === earlier || s.via === row.intake) {
444
+ delete values[k];
445
+ delete sources[k];
446
+ }
447
+ const cleared = { ...r, values, sources };
448
+ draft.records.set(recordId, land(cleared, { outcomes: again.outcomes, contract: c.name }, { origin: c.origin || "reported", via: row.intake }, p.dict.trust, p.dict.facetTrust, transitionsOf(p.dict)));
449
+ return { by: { migration: "replay" }, extra: [], out: null };
450
+ }, at);
451
+ await this.pool.query("update intake set json = $3 where owner = $1 and intake = $2", [this.owner, row.intake, { ...row.json, replays: [...(row.json.replays ?? []), { outcomes: again.outcomes, contract: c.name, ...(entry.entry ? { entry: String(entry.entry.log) } : {}) }] }]);
452
+ out.push(row.intake);
453
+ }
454
+ return out;
455
+ }
456
+ /**
457
+ * Drain (docs/design/store.md, completion.md P): apply pending instructions to the records that have
458
+ * them, at most `rate` records per call, each its own entry `by: migration: drain`; the ids done and
459
+ * how many still wait.
460
+ */
461
+ async drain(rate = 100, at) {
462
+ const dict = (await this.definitions()).dict;
463
+ const s = await this.state();
464
+ const store = { records: s.records, bindings: s.bindings, instructions: s.instructions, review: s.reviews, edges: s.edges };
465
+ const waiting = s.records.all().filter((r) => r.status?.state !== "archived" && pending(store, r).length > 0).map((r) => r.id);
466
+ const applied = [];
467
+ for (const id of waiting.slice(0, rate)) {
468
+ const entry = await this.commit({ migration: "drain" }, (d) => { apply(d, id, dict); }, at);
469
+ if (entry)
470
+ applied.push(id);
471
+ }
472
+ return { applied, remaining: Math.max(0, waiting.length - applied.length) };
473
+ }
474
+ /** What installing a version would restamp (10.4): per recipe and step, how many values change and how many go to review. */
475
+ async dryRun(bundle) {
476
+ const installed = (await this.state()).installed;
477
+ const previous = installed.filter((i) => i.package === bundle.id).map((i) => String(i.version)).at(-1);
478
+ if (!previous)
479
+ return { recipes: [], records: 0 };
480
+ const next = readBundleOnto(bundle, await this.definitions(true));
481
+ const own = readBundle(bundle);
482
+ const records = (await this.state()).records.all().filter((r) => ownRecord(own, r));
483
+ const recipes = recipesBetween(next.recipes, String(bundle.package), previous, String(bundle.version)).map((r) => { const d = dryRun(records, r, next.dict); return { from: r.from, to: r.to, steps: d.steps, review: d.review }; });
484
+ return { recipes, records: records.length };
485
+ }
486
+ /** A define write (8.5, 11.4): one member of the working package as it now stands, checked with the rest first. */
487
+ async define(member, by = { person: "owner" }, at) {
488
+ // The trial is the package as it would stand with this write (a redefinition replacing, a retired member
489
+ // gone), checked whole; only then does the write land.
490
+ const client = await this.pool.connect();
491
+ try {
492
+ checkAll(await definitionsOf(client, this.owner, [member]));
493
+ }
494
+ finally {
495
+ client.release();
496
+ }
497
+ const e = (await this.commitFrom(() => ({ by, extra: [{ define: member }], out: null }), at)).entry;
498
+ this.defsCache = undefined;
499
+ return e;
500
+ }
501
+ /**
502
+ * The owner's state as the tables hold it, kept per chain head: the same head answers from memory; a
503
+ * head a little further on replays the entries since onto a copy (the engine's own replay), so a write
504
+ * never costs a reload of every record; a gap over 200 entries, or an entry the replay can't take,
505
+ * reads the tables whole. A commit works on a copy of the loaded state, so a kept state is never shared
506
+ * with a draft. (G6: Cashierfu carried this around the store until 0.1.15.)
507
+ */
508
+ async state() {
509
+ const client = await this.pool.connect();
510
+ try {
511
+ const { rows } = await client.query("select head_seq from chains where owner = $1 and chain = $2", [this.owner, this.chain]);
512
+ const seq = rows.length ? Number(rows[0].head_seq) : -1;
513
+ // A hosted person's writes go to their own chain (14.1) and move no head of ours, so the key the
514
+ // state is kept under takes every hosted head along: one of theirs moving reads the tables whole.
515
+ const hostedRows = await client.query("select coalesce(sum(head_seq), 0)::text as total, count(*)::text as n from chains where owner like $1 and chain = $2", [`${this.owner}:%`, this.chain]);
516
+ const hosted = `${hostedRows.rows[0].n}/${hostedRows.rows[0].total}`;
517
+ const kept = this.keptState;
518
+ if (kept && kept.seq === seq && kept.hosted === hosted)
519
+ return kept.state;
520
+ if (kept && kept.hosted === hosted && seq > kept.seq && seq - kept.seq <= 200) {
521
+ const since = await client.query("select seq, entry from log where owner = $1 and chain = $2 and seq > $3 order by seq", [this.owner, this.chain, kept.seq]);
522
+ const entries = since.rows;
523
+ const contiguous = entries.length === seq - kept.seq && entries.every((e, i) => Number(e.seq) === kept.seq + i + 1);
524
+ if (contiguous) {
525
+ const next = copyState(kept.state);
526
+ try {
527
+ for (const e of entries)
528
+ applyEntry(next, e.entry);
529
+ this.keptState = { seq, hosted, state: next };
530
+ return next;
531
+ }
532
+ catch {
533
+ // an entry the replay can't take: the tables, below
534
+ }
535
+ }
536
+ }
537
+ const state = await loadState(client, this.owner);
538
+ this.keptState = { seq, hosted, state };
539
+ return state;
540
+ }
541
+ finally {
542
+ client.release();
543
+ }
544
+ }
545
+ keptState;
546
+ /** Forget the kept state: after a rewrite of history that moves no head (an erase, a rebuild). */
547
+ forgetState() {
548
+ this.keptState = undefined;
549
+ }
550
+ /** The owner's entries, in chain order. */
551
+ async entries() {
552
+ const { rows } = await this.pool.query("select entry from log where owner = $1 and chain = $2 order by seq", [this.owner, this.chain]);
553
+ return rows.map((r) => r.entry);
554
+ }
555
+ /** Lock the chain for this transaction and return its head. Writers to one chain serialize here. */
556
+ async lockChain(client) {
557
+ await client.query("insert into chains (owner, chain) values ($1, $2) on conflict do nothing", [this.owner, this.chain]);
558
+ const { rows } = await client.query("select head_seq, head_hash from chains where owner = $1 and chain = $2 for update", [this.owner, this.chain]);
559
+ const head = rows[0];
560
+ return { seq: Number(head.head_seq), hash: head.head_hash };
561
+ }
562
+ /** Link an entry to the chain's head, insert it, and move the head. The entry gets its `prev`. */
563
+ async link(client, entry, head, owner = this.owner, signer = this.options.signer) {
564
+ const linked = { ...entry };
565
+ if (head.hash)
566
+ linked.prev = `sha256:${head.hash}`;
567
+ else
568
+ delete linked.prev;
569
+ const hash = entryHash(linked);
570
+ if (signer)
571
+ linked.sig = signer(hash);
572
+ const seq = head.seq + 1;
573
+ await client.query("insert into log (owner, chain, seq, log_id, prev, hash, at, witnessed, entry) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)", [owner, this.chain, seq, String(linked.log), linked.prev ?? null, hash, typeof linked.at === "string" ? linked.at : null, linked.witnessed === true, linked]);
574
+ await client.query("update chains set head_seq = $3, head_hash = $4 where owner = $1 and chain = $2", [owner, this.chain, seq, hash]);
575
+ return linked;
576
+ }
577
+ /**
578
+ * A hosted person's records live in their own chain (14.1): the writes to records owned `<owner>:<id>`
579
+ * leave the owner's entry and become one entry per person, linked and signed in the same transaction.
580
+ * The chains lock in a fixed order (the owner's first, then the persons' by name).
581
+ */
582
+ async linkSplit(client, entry, head, after) {
583
+ const prefix = `${this.owner}:`;
584
+ const byOwner = new Map();
585
+ const own = [];
586
+ for (const w of (Array.isArray(entry.writes) ? entry.writes : [])) {
587
+ const rec = typeof w.record === "string" ? after.records.get(w.record) : undefined;
588
+ const o = rec?.owner;
589
+ if (o && o.startsWith(prefix))
590
+ byOwner.set(o, [...(byOwner.get(o) ?? []), w]);
591
+ else
592
+ own.push(w);
593
+ }
594
+ const hosted = [];
595
+ for (const person of [...byOwner.keys()].sort()) {
596
+ await client.query("insert into chains (owner, chain) values ($1, $2) on conflict do nothing", [person, this.chain]);
597
+ const { rows } = await client.query("select head_seq, head_hash from chains where owner = $1 and chain = $2 for update", [person, this.chain]);
598
+ const h = rows[0];
599
+ const ph = { seq: Number(h.head_seq), hash: h.head_hash };
600
+ const signer = this.options.hostedSigner ? await this.options.hostedSigner(person) : undefined;
601
+ const e = { ...entry, log: `L-${ph.seq + 1}`, owner: person, writes: byOwner.get(person) };
602
+ hosted.push(await this.link(client, e, ph, person, signer));
603
+ }
604
+ if (!own.length)
605
+ return { entry: null, hosted };
606
+ return { entry: await this.link(client, { ...entry, writes: own }, head), hosted };
607
+ }
608
+ /**
609
+ * Append an entry made elsewhere (a rebuilt log, a hosted person's chain the host carries): it is
610
+ * linked to this chain's head, applied with the engine's applyEntry, and its effects persisted.
611
+ */
612
+ async append(entry) {
613
+ return transaction(this.pool, async (client) => {
614
+ const head = await this.lockChain(client);
615
+ const base = await this.base(client);
616
+ const linked = await this.link(client, entry, head);
617
+ const { state: after } = await settle(client, this.owner, base, (s) => applyEntry(s, linked));
618
+ await persist(client, this.owner, base, after);
619
+ await this.afterPersist(client, base, after, head.seq + 1);
620
+ return linked;
621
+ }).then(async (e) => {
622
+ await this.freshen();
623
+ return e;
624
+ });
625
+ }
626
+ /** An empty working state with the whole small tables in it; records, bindings and runs load as needed. */
627
+ async base(client) {
628
+ const needs = new Needs();
629
+ this.trace = needs;
630
+ const base = workingState(needs);
631
+ await loadWhole(client, this.owner, base);
632
+ return base;
633
+ }
634
+ /** What the last operation loaded, for tests and for watching a store's cost. */
635
+ trace;
636
+ /**
637
+ * One operation, one entry (8.5): the engine runs on a draft of the state, what it changed becomes the
638
+ * entry's writes (plus `extra` writes the diff can't see), and the entry is applied and persisted in the
639
+ * same transaction. Nothing happens when nothing changed; a throw inside `mutate` rolls everything back.
640
+ */
641
+ async commit(by, mutate, at, extra = []) {
642
+ return (await this.commitFrom((d) => { mutate(d); return { by, extra, out: undefined }; }, at)).entry;
643
+ }
644
+ /**
645
+ * The same, for an operation that decides its own cause and extra writes as it runs (a border run): it
646
+ * returns `by`, `extra` and an outcome, and the outcome comes back beside the entry.
647
+ */
648
+ async commitFrom(run, at, mark = {}) {
649
+ return transaction(this.pool, async (client) => {
650
+ const head = await this.lockChain(client);
651
+ const before = await this.base(client);
652
+ const needs = before.records;
653
+ // The operation runs until the working set holds everything it asked for (working.ts); so does the
654
+ // entry's application. Both run on copies of the same base, so the draft and the applied state
655
+ // hold the same records and `differs` compares like with like.
656
+ let draft;
657
+ let after;
658
+ let entry;
659
+ let result;
660
+ const p = await this.definitionsFrom(client, false);
661
+ for (;;) {
662
+ const had = needs.needs.loaded.size;
663
+ ({ draft, result } = (await settle(client, this.owner, before, (s) => { const d = draftOf(s); const r = run(d); this.derive(p, before, d); return { draft: d, result: r }; })).out);
664
+ const writes = [...diffWrites(before, draft), ...(result.extra ?? [])];
665
+ if (!writes.length)
666
+ return { entry: null, out: result.out };
667
+ entry = { log: `L-${head.seq + 1}`, owner: this.owner, at: at ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), by: result.by, writes, ...(mark.witnessed ? { witnessed: true } : {}) };
668
+ const e = entry;
669
+ // The entry applied, then checked in the same run (14.12, 5.5, 14.1), so what the check reads loads too.
670
+ after = (await settle(client, this.owner, before, (s) => { applyEntry(s, e); this.checkWrites(p, e, s); })).state;
671
+ if (needs.needs.loaded.size === had)
672
+ break;
673
+ }
674
+ const { entry: linked, hosted } = await this.linkSplit(client, entry, head, after);
675
+ const problem = differs(after, draft);
676
+ if (problem)
677
+ throw new Error(`the log doesn't describe the change: ${problem}`);
678
+ await persist(client, this.owner, before, after);
679
+ await this.afterPersist(client, before, after, head.seq + (linked ? 1 : 0), p);
680
+ // The definitions in force are unchanged by an entry with no define write (an install clears the
681
+ // cache itself), so the cache follows the head: parsing them again cost every commit, and the first
682
+ // read after it, ~110 ms on ME.
683
+ if (linked && this.defsCache?.seq === head.seq && !(Array.isArray(entry.writes) ? entry.writes : []).some((w) => isObj(w) && isObj(w.define)))
684
+ this.defsCache = { seq: head.seq + 1, parsed: this.defsCache.parsed };
685
+ return { entry: linked ?? hosted[0], out: result.out };
686
+ }).then(async (r) => {
687
+ if (r.entry)
688
+ await this.freshen();
689
+ return r;
690
+ });
691
+ }
692
+ /**
693
+ * Erase a record's values from the log (8.5.2): every value write to it, and every observation on it, in
694
+ * every entry, is redacted in place; creations, envelopes and everything else stay. The state is rebuilt.
695
+ */
696
+ async erase(recordId) {
697
+ const witnessed = [];
698
+ let redacted = 0;
699
+ await transaction(this.pool, async (client) => {
700
+ await this.lockChain(client);
701
+ const { rows } = await client.query("select owner, log_id, entry from log where (owner = $1 or owner like $1 || ':%') and chain = $2 and not (entry ? 'redacted') order by ord for update", [this.owner, this.chain]);
702
+ for (const row of rows) {
703
+ const writes = Array.isArray(row.entry.writes) ? row.entry.writes : [];
704
+ const hits = writes.map((w, i) => (isObj(w) && ((w.record === recordId && typeof w.address === "string") || (isObj(w.observation) && w.observation.on === recordId)) ? i : -1)).filter((i) => i >= 0);
705
+ if (!hits.length)
706
+ continue;
707
+ if (row.entry.witnessed === true) {
708
+ witnessed.push(row.log_id);
709
+ continue;
710
+ }
711
+ const next = [...writes];
712
+ for (const i of hits)
713
+ next[i] = { redacted: `sha256:${writeHash(writes[i])}` };
714
+ await client.query("update log set entry = $3 where owner = $1 and log_id = $2", [row.owner, row.log_id, { ...row.entry, writes: next }]);
715
+ redacted += hits.length;
716
+ }
717
+ });
718
+ // What is kept beside the log for the record goes too: the payloads sent about it.
719
+ await this.pool.query("delete from outtake where owner = $1 and record = $2", [this.owner, recordId]);
720
+ if (redacted)
721
+ await this.rebuild();
722
+ return { redacted, witnessed };
723
+ }
724
+ /** Check the chain's links and, with the owner's key, its signatures (8.5). */
725
+ async verify(keys) {
726
+ return verifyChain((await this.entries()), keys);
727
+ }
728
+ /** Empty the owner's state tables and apply the log again, in order (8.5.3). */
729
+ async rebuild() {
730
+ return transaction(this.pool, async (client) => {
731
+ await this.lockChain(client);
732
+ await wipeState(client, this.owner);
733
+ // The owner's chain and the chains of the people it hosts (14.1), in the order they were written.
734
+ const { rows } = await client.query("select entry from log where (owner = $1 or owner like $1 || ':%') and chain = $2 order by ord", [this.owner, this.chain]);
735
+ for (const row of rows) {
736
+ const base = await this.base(client);
737
+ const { state } = await settle(client, this.owner, base, (s) => applyEntry(s, row.entry));
738
+ await persist(client, this.owner, base, state);
739
+ }
740
+ // Projections follow: every row of every view is marked stale, so the next publish rebuilds and
741
+ // compares them all and announces what left.
742
+ await this.staleAll((sql, params) => client.query(sql, params), await this.projectingFrom(client));
743
+ return loadState(client, this.owner);
744
+ });
745
+ }
746
+ /**
747
+ * Redact one write of an entry, or the whole entry (8.5.2): the log's one update, which the trigger
748
+ * admits and nothing else. The state is then rebuilt, so the cache forgets what the log forgot.
749
+ */
750
+ async redact(logId, writeIndex) {
751
+ await transaction(this.pool, async (client) => {
752
+ await this.lockChain(client);
753
+ const { rows } = await client.query("select entry from log where owner = $1 and log_id = $2 for update", [this.owner, logId]);
754
+ const entry = rows[0]?.entry;
755
+ if (!entry)
756
+ throw new Error(`no entry ${logId}`);
757
+ if (entry.witnessed === true)
758
+ throw new Error("a witnessed entry is never redacted");
759
+ let next;
760
+ if (writeIndex === undefined) {
761
+ const writes = Array.isArray(entry.writes) ? entry.writes : [];
762
+ for (const w of writes)
763
+ if (isObj(w) && !(typeof w.record === "string" && typeof w.address === "string") && !isObj(w.observation) && typeof w.read !== "string" && typeof w.redacted !== "string")
764
+ throw new Error("an entry holding a write that is never redacted is redacted write by write, never whole");
765
+ next = { log: String(entry.log), redacted: `sha256:${entryHash(entry)}` };
766
+ if (entry.prev !== undefined)
767
+ next.prev = entry.prev;
768
+ if (entry.chain !== undefined)
769
+ next.chain = entry.chain;
770
+ if (entry.sig !== undefined)
771
+ next.sig = entry.sig;
772
+ }
773
+ else {
774
+ const writes = Array.isArray(entry.writes) ? [...entry.writes] : [];
775
+ const w = writes[writeIndex];
776
+ if (!isObj(w))
777
+ throw new Error(`no write ${writeIndex} in ${logId}`);
778
+ if (!(typeof w.record === "string" && typeof w.address === "string") && !isObj(w.observation) && typeof w.read !== "string")
779
+ throw new Error("a write that created a record, changed its envelope or a definition, or wrote an edge, binding, review, instruction, run or install is never redacted");
780
+ writes[writeIndex] = { redacted: `sha256:${writeHash(w)}` };
781
+ next = { ...entry, writes };
782
+ }
783
+ await client.query("update log set entry = $3 where owner = $1 and log_id = $2", [this.owner, logId, next]);
784
+ });
785
+ await this.rebuild();
786
+ }
787
+ }
788
+ /** The facets a dictionary declares `transitions: yes` (4.2). */
789
+ function transitionsOf(dict) {
790
+ return new Set([...dict.facets.values()].filter((f) => f.transitions).map((f) => f.key));
791
+ }
792
+ /** A state copied one level down, so replayed entries apply to a fresh object while readers keep theirs. */
793
+ function copyState(st) {
794
+ return {
795
+ records: new MemoryRecords(st.records),
796
+ edges: new Map(st.edges),
797
+ bindings: new Map(st.bindings),
798
+ instructions: new Map([...st.instructions].map(([k, v]) => [k, [...v]])),
799
+ observations: [...st.observations],
800
+ reviews: [...st.reviews],
801
+ runs: new Map(st.runs),
802
+ installed: [...st.installed],
803
+ reads: [...st.reads],
804
+ definitions: new Map(st.definitions),
805
+ };
806
+ }
807
+ /** What a store projects with, from the definitions in force: nothing when there is nothing to project. */
808
+ function projectingOf(p) {
809
+ if (!p.views.views.size && !p.dict.facets.size)
810
+ return undefined;
811
+ return { dict: p.dict, views: p.views, perms: p.perms };
812
+ }
813
+ //# sourceMappingURL=store.js.map