@loomwork/plugin-content 0.0.0-stage → 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +92 -2
  4. package/dist/CachingContentStore.d.ts +54 -0
  5. package/dist/CachingContentStore.d.ts.map +1 -0
  6. package/dist/CachingContentStore.js +171 -0
  7. package/dist/CachingContentStore.js.map +1 -0
  8. package/dist/ContentResolvingEventStore.d.ts +70 -0
  9. package/dist/ContentResolvingEventStore.d.ts.map +1 -0
  10. package/dist/ContentResolvingEventStore.js +245 -0
  11. package/dist/ContentResolvingEventStore.js.map +1 -0
  12. package/dist/ContentStore.d.ts +186 -0
  13. package/dist/ContentStore.d.ts.map +1 -0
  14. package/dist/ContentStore.js +177 -0
  15. package/dist/ContentStore.js.map +1 -0
  16. package/dist/ContentTouchingSnapshotStore.d.ts +56 -0
  17. package/dist/ContentTouchingSnapshotStore.d.ts.map +1 -0
  18. package/dist/ContentTouchingSnapshotStore.js +85 -0
  19. package/dist/ContentTouchingSnapshotStore.js.map +1 -0
  20. package/dist/citations.d.ts +40 -0
  21. package/dist/citations.d.ts.map +1 -0
  22. package/dist/citations.js +118 -0
  23. package/dist/citations.js.map +1 -0
  24. package/dist/content.bench.d.ts +2 -0
  25. package/dist/content.bench.d.ts.map +1 -0
  26. package/dist/content.bench.js +75 -0
  27. package/dist/content.bench.js.map +1 -0
  28. package/dist/content.d.ts +40 -0
  29. package/dist/content.d.ts.map +1 -0
  30. package/dist/content.js +177 -0
  31. package/dist/content.js.map +1 -0
  32. package/dist/derive.d.ts +9 -0
  33. package/dist/derive.d.ts.map +1 -0
  34. package/dist/derive.js +40 -0
  35. package/dist/derive.js.map +1 -0
  36. package/dist/errors.d.ts +6 -0
  37. package/dist/errors.d.ts.map +1 -0
  38. package/dist/errors.js +20 -0
  39. package/dist/errors.js.map +1 -0
  40. package/dist/executionEnd.d.ts +22 -0
  41. package/dist/executionEnd.d.ts.map +1 -0
  42. package/dist/executionEnd.js +66 -0
  43. package/dist/executionEnd.js.map +1 -0
  44. package/dist/gc.d.ts +470 -0
  45. package/dist/gc.d.ts.map +1 -0
  46. package/dist/gc.js +819 -0
  47. package/dist/gc.js.map +1 -0
  48. package/dist/index.d.ts +10 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +42 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/touchAround.d.ts +46 -0
  53. package/dist/touchAround.d.ts.map +1 -0
  54. package/dist/touchAround.js +98 -0
  55. package/dist/touchAround.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/CachingContentStore.ts +184 -0
  58. package/src/ContentResolvingEventStore.ts +325 -0
  59. package/src/ContentStore.ts +317 -0
  60. package/src/ContentTouchingSnapshotStore.ts +109 -0
  61. package/src/citations.ts +117 -0
  62. package/src/content.bench.ts +84 -0
  63. package/src/content.ts +191 -0
  64. package/src/derive.ts +40 -0
  65. package/src/errors.ts +19 -0
  66. package/src/executionEnd.ts +64 -0
  67. package/src/gc.ts +1213 -0
  68. package/src/index.ts +54 -0
  69. package/src/touchAround.ts +131 -0
@@ -0,0 +1,325 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import {
3
+ type DecoratorPlan,
4
+ type EventStore,
5
+ type OptionalEventStoreMethod,
6
+ installOptional,
7
+ readRangeOf,
8
+ } from "@loomwork/event-runtime";
9
+ import type { ContentStore } from "./ContentStore";
10
+ import {
11
+ type ContentRef,
12
+ collectContentRefs,
13
+ encodeValue,
14
+ hasContentRef,
15
+ mapPathsAsync,
16
+ substituteContentRefs,
17
+ } from "./content";
18
+ import { ContentMissingError } from "./errors";
19
+ import { type TouchErrorHook, touchAround } from "./touchAround";
20
+
21
+ export interface ContentResolvingEventStoreOptions {
22
+ /**
23
+ * Payload dot-paths to externalize per event type, e.g. `{ STEP_COMPLETED: ["output.doc"] }`.
24
+ * On append those fields are moved to the content store and replaced by a {@link ContentRef};
25
+ * on read they are resolved back. Unmarked events are stored verbatim (zero overhead, G2).
26
+ */
27
+ externalize: Record<string, readonly string[]>;
28
+ /**
29
+ * Observer for the SPI 1.4.0 `touch` this store makes around ref-carrying writes (ADR 0055 D6
30
+ * amendment 2): called when the touch AFTER a durable write threw (swallowed otherwise, since the
31
+ * write already landed), and when either touch reports a cited bare ref MISSING (the write cites
32
+ * a blob that is already gone). A failure of the touch BEFORE the write still rejects the write.
33
+ * Wire it to your logger or metrics. See {@link TouchErrorHook}.
34
+ */
35
+ onTouchError?: TouchErrorHook;
36
+ }
37
+
38
+ /**
39
+ * ADR 0046 — wraps any {@link EventStore} so marked payload fields are stored in a
40
+ * {@link ContentStore} (as content-addressed refs) instead of inline in the log. Reads resolve
41
+ * refs back transparently, so the fold, resume, and every consumer above the store layer see
42
+ * plain values — the authoritative log holds only references + structure.
43
+ *
44
+ * Mirrors `EncryptingEventStore`: a store decorator, optional capabilities forwarded so it
45
+ * composes with fork/fencing. `copyPrefix` copies refs verbatim — because refs are
46
+ * content-addressed, a fork *shares* the parent's blobs for free (natural dedup), with no
47
+ * re-wrap needed. A read of an execution whose externalized bytes were erased throws
48
+ * {@link ContentMissingError} — replay fails loudly, never silently diverges (G1).
49
+ *
50
+ * **Composing with `@loomwork/plugin-audit-chain`:** wrap this store INSIDE the hash chain —
51
+ * `hashChainedEventStore(new ContentResolvingEventStore(...))`. Chained the other way round
52
+ * the chain hashes only the ref string, so a rewritten blob verifies clean. See
53
+ * `hashChainedEventStore`'s note and `contentOrdering.test.ts`.
54
+ *
55
+ * **Claim-check envelopes are left alone.** An ADR 0054 envelope (`__loomClaimCheck`) is read
56
+ * back verbatim — its `ref` is resolved only by the claim-check plugin, it may be reclaimed on a
57
+ * TTL (ADR 0055 D4), and its bytes need not be JSON. A bare ref beside it still resolves.
58
+ *
59
+ * **Reclamation:** `deleteExecution`/`truncatePrefix` remove the log's events but do NOT
60
+ * delete the referenced blobs — forks share content, so a blob outlives any one log. Blobs are
61
+ * reclaimed by the separate mark-and-sweep `sweepUnpinnedContent` (ADR 0055, which closed the
62
+ * ADR 0046 "refcounted blob GC" follow-up); erase one explicitly via `contentStore.delete(ref)`.
63
+ */
64
+ /**
65
+ * Own-key lookup for a caller-controlled event type. Plain indexing would resolve
66
+ * `"__proto__"`/`"constructor"` up the prototype chain to a truthy non-config value, which then
67
+ * flows into code expecting an array (or a function). An unrecognised event type must be a
68
+ * no-op whatever it is named.
69
+ */
70
+ function pathsFor(
71
+ map: Record<string, readonly string[]>,
72
+ eventType: string
73
+ ): readonly string[] | undefined {
74
+ return Object.hasOwn(map, eventType) ? map[eventType] : undefined;
75
+ }
76
+
77
+ export class ContentResolvingEventStore implements EventStore {
78
+ private readonly externalize: Record<string, readonly string[]>;
79
+ private readonly onTouchError: TouchErrorHook | undefined;
80
+ readonly appendBatch?: NonNullable<EventStore["appendBatch"]>;
81
+ readonly head?: NonNullable<EventStore["head"]>;
82
+ readonly readRange?: NonNullable<EventStore["readRange"]>;
83
+ readonly acquireFence?: NonNullable<EventStore["acquireFence"]>;
84
+ readonly acquireFenceIf?: NonNullable<EventStore["acquireFenceIf"]>;
85
+ readonly appendIfUnchanged?: NonNullable<EventStore["appendIfUnchanged"]>;
86
+ readonly storeNow?: NonNullable<EventStore["storeNow"]>;
87
+ readonly sealingCapability?: NonNullable<EventStore["sealingCapability"]>;
88
+ readonly readAtRest?: NonNullable<EventStore["readAtRest"]>;
89
+ readonly copyPrefix?: NonNullable<EventStore["copyPrefix"]>;
90
+ readonly deleteExecution?: NonNullable<EventStore["deleteExecution"]>;
91
+ readonly deleteThrough?: NonNullable<EventStore["deleteThrough"]>;
92
+ readonly truncatePrefix?: NonNullable<EventStore["truncatePrefix"]>;
93
+ readonly truncatePrefixKeeping?: NonNullable<EventStore["truncatePrefixKeeping"]>;
94
+ readonly listExecutions?: NonNullable<EventStore["listExecutions"]>;
95
+ readonly listExecutionsPage?: NonNullable<EventStore["listExecutionsPage"]>;
96
+
97
+ constructor(
98
+ private readonly inner: EventStore,
99
+ private readonly content: ContentStore,
100
+ options: ContentResolvingEventStoreOptions
101
+ ) {
102
+ // ADR 0143: a sealing store needs the owning execution on every put/get, and ADR 0046 externalization
103
+ // resolves a read BATCH across executions without carrying it. Refuse rather than seal under a guess
104
+ // (the claim-check path, which has the execution, is the one that works over a sealing store).
105
+ if (content.sealsContent === true) {
106
+ throw new TypeError(
107
+ "ContentResolvingEventStore: this content store seals per subject (sealsContent: true), which ADR 0046 externalization cannot drive (it does not carry the owning execution into put/get). Use a plain store here, or offload through execution.claimCheck."
108
+ );
109
+ }
110
+ this.externalize = options.externalize;
111
+ this.onTouchError = options.onTouchError;
112
+ // Every optional method decided over the whole union (ADR 0074 Amendment), each
113
+ // installed only when the inner store has it.
114
+ const plan = {
115
+ // Externalize every envelope concurrently, then ONE round-trip. Puts are
116
+ // content-addressed and idempotent, so a blob orphaned by a failed batch is exactly
117
+ // what a failed `append` orphans today (and the sweep reclaims it). `Promise.all`
118
+ // keeps array order, the SPI's per-execution order.
119
+ appendBatch: async (envelopes) =>
120
+ this.touching(
121
+ envelopes.map((e) => e.payload),
122
+ { operation: "appendBatch", executionId: envelopes[0]?.executionId },
123
+ async () =>
124
+ inner.appendBatch!(await Promise.all(envelopes.map((e) => this.externalizeEnvelope(e))))
125
+ ),
126
+ // A tip is a sequence number — no payload to resolve, so it forwards unchanged.
127
+ head: "forward",
128
+ readRange: (executionId, from, to) =>
129
+ inner.readRange!(executionId, from, to).then((evs) => this.resolveAll(executionId, evs)),
130
+ acquireFence: "forward",
131
+ acquireFenceIf: "forward",
132
+ // The blob is written BEFORE the store can say no, and a content-addressed blob may be
133
+ // shared with other events (so deleting it on a refusal could break them). So the
134
+ // externalisation happens only after a lock-free observation says the condition still
135
+ // holds: a late write that already lost (the common case) stores no blob at all. What is
136
+ // left is the window between the observation and the write, whose orphan is reclaimed by
137
+ // `sweepUnpinnedContent` exactly like a plain `append` that failed after its `put`.
138
+ appendIfUnchanged: async (executionId, event, expect) => {
139
+ if (event === null) return inner.appendIfUnchanged!(executionId, null, expect);
140
+ const paths = pathsFor(this.externalize, event.eventType);
141
+ if (paths !== undefined && paths.length > 0 && expect !== undefined) {
142
+ const now = await inner.appendIfUnchanged!(executionId, null);
143
+ if (
144
+ !now.appended &&
145
+ (now.reason === "gone" ||
146
+ now.head !== expect.head ||
147
+ now.fence !== expect.fence ||
148
+ now.anchor !== expect.anchor ||
149
+ event.sequence !== expect.head + 1)
150
+ ) {
151
+ return now.reason === "gone"
152
+ ? now
153
+ : {
154
+ appended: false,
155
+ reason: "moved",
156
+ head: now.head,
157
+ fence: now.fence,
158
+ anchor: now.anchor,
159
+ };
160
+ }
161
+ }
162
+ return this.touching(
163
+ [event.payload],
164
+ { operation: "appendIfUnchanged", executionId },
165
+ async () =>
166
+ inner.appendIfUnchanged!(executionId, await this.externalizeEnvelope(event), expect)
167
+ );
168
+ },
169
+ storeNow: "forward", // a clock reading: no envelope to externalise
170
+ sealingCapability: "forward", // externalising refs seals nothing
171
+ readAtRest: "forward",
172
+ // Refs are content-addressed: copying them verbatim makes the fork share the parent's
173
+ // blobs (dedup for free) — no decrypt/re-wrap step (contrast the crypto plugin's keys).
174
+ // But a verbatim copy is a citation with NO `put`, invisible to the orphan sweep's
175
+ // conditional delete, so over a store with SPI 1.4.0 `touch` the copied range's refs are
176
+ // touched around the copy (ADR 0055 D6 amendment 2). The range is read raw (unresolved).
177
+ copyPrefix:
178
+ content.touch === undefined
179
+ ? "forward"
180
+ : async (fromExecutionId, toExecutionId, throughSequence, metadata) => {
181
+ const copied = await readRangeOf(inner, fromExecutionId, 0, throughSequence);
182
+ return this.touching(
183
+ copied.map((e) => e.payload),
184
+ { operation: "copyPrefix", executionId: toExecutionId },
185
+ () => inner.copyPrefix!(fromExecutionId, toExecutionId, throughSequence, metadata)
186
+ );
187
+ },
188
+ // Forwarded so a content-wrapped store keeps its retention/compaction capabilities.
189
+ // These reclaim EVENTS only; blob reclamation is separate (see the class note).
190
+ deleteExecution: "forward",
191
+ deleteThrough: "forward",
192
+ truncatePrefix: "forward",
193
+ truncatePrefixKeeping: "forward",
194
+ // Enumeration is over executionIds only — no payload, so no resolution needed.
195
+ listExecutions: "forward",
196
+ listExecutionsPage: "forward",
197
+ } satisfies DecoratorPlan<EventStore, OptionalEventStoreMethod>;
198
+ installOptional<EventStore, OptionalEventStoreMethod>(this, inner, plan);
199
+ }
200
+
201
+ private async externalizeEnvelope(envelope: EventEnvelope): Promise<EventEnvelope> {
202
+ const paths = pathsFor(this.externalize, envelope.eventType);
203
+ if (paths === undefined || paths.length === 0) return envelope; // unmarked → verbatim (G2)
204
+ const payload = await mapPathsAsync(envelope.payload, paths, (v) => this.externalizeValue(v));
205
+ return { ...envelope, payload };
206
+ }
207
+
208
+ async append(envelope: EventEnvelope): Promise<void> {
209
+ return this.touching(
210
+ [envelope.payload],
211
+ { operation: "append", executionId: envelope.executionId },
212
+ async () => this.inner.append(await this.externalizeEnvelope(envelope))
213
+ );
214
+ }
215
+
216
+ /**
217
+ * SPI 1.4.0 (ADR 0055 D6 amendment 2) — a ref that reaches a log WITHOUT a `put` (one an
218
+ * incoming payload already carries: a recalled memory value, a `scriptRef`, a ref handed
219
+ * between steps) is a citation the orphan sweep's conditional delete cannot see. So, when the
220
+ * content store can `touch`, the payloads' cited addresses are touched BEFORE the write (a
221
+ * failure aborts it, nothing landed) and again AFTER it (best-effort and reported through
222
+ * `onTouchError`: the write is durable, so its outcome must not be turned into a failure).
223
+ * The guarantee is D5's: the BEFORE touch protects a write whose touch-to-commit window is
224
+ * shorter than the sweep's margin (`touchClock() - cutoff`, which an erasing orphan pass
225
+ * requires to be at least 60 s); the AFTER touch only extends that for a slower write. See
226
+ * {@link touchAround}. Refs externalized by THIS write are not in the INPUT payloads (their
227
+ * `put` is the touch). Cost: with no `touch` on the store, nothing (the closure is the
228
+ * write); otherwise one iterative cycle-safe walk of the input payload (no recursion; the same
229
+ * walker the sweep's mark phase uses), and a round trip only when a ref was found.
230
+ */
231
+ private touching<T>(
232
+ payloads: readonly unknown[],
233
+ ctx: Parameters<typeof touchAround>[2],
234
+ write: () => Promise<T>
235
+ ): Promise<T> {
236
+ return touchAround(this.content, payloads, ctx, this.onTouchError, write);
237
+ }
238
+
239
+ private async externalizeValue(value: unknown): Promise<ContentRef> {
240
+ const ref = await this.content.put(encodeValue(value));
241
+ return { ...ref, codec: "json" };
242
+ }
243
+
244
+ async read(executionId: string): Promise<EventEnvelope[]> {
245
+ return this.resolveAll(executionId, await this.inner.read(executionId));
246
+ }
247
+
248
+ async readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
249
+ return this.resolveAll(executionId, await this.inner.readFrom(executionId, fromSequence));
250
+ }
251
+
252
+ // ADR 0062 — batch-scoped two-phase resolution. Phase 1 collects DISTINCT refs
253
+ // across the WHOLE read batch (one walk, dedupe by content address); the fetch
254
+ // is ONE `getMany` round-trip when the store supports it (coalesced parallel
255
+ // `get`s otherwise — still strictly better than the old per-ref sequential
256
+ // awaits); phase 2 is a pure synchronous substitute pass. O(payload nodes) +
257
+ // one round-trip replaces O(refs) serialized round-trips. Fast path: no event
258
+ // carries a ref → verbatim, no allocation.
259
+ private async resolveAll(executionId: string, events: EventEnvelope[]): Promise<EventEnvelope[]> {
260
+ const distinct = new Map<string, ContentRef>();
261
+ const refBearing = events.map((e) => collectContentRefs(e.payload, distinct));
262
+ if (distinct.size === 0) return events;
263
+ const bytesByAddress = await this.fetchBytes(distinct, executionId);
264
+ return events.map((e, i) =>
265
+ refBearing[i] ? { ...e, payload: substituteContentRefs(e.payload, bytesByAddress) } : e
266
+ );
267
+ }
268
+
269
+ /** Fetch all distinct refs; loudness contract unchanged (missing ⇒ ContentMissingError, G1). */
270
+ private async fetchBytes(
271
+ distinct: ReadonlyMap<string, ContentRef>,
272
+ executionId: string
273
+ ): Promise<Map<string, Uint8Array>> {
274
+ // Single pass over the map (audit nit): addresses and refs stay index-aligned.
275
+ const addresses = new Array<string>(distinct.size);
276
+ const refs = new Array<ContentRef>(distinct.size);
277
+ let n = 0;
278
+ for (const [address, ref] of distinct) {
279
+ addresses[n] = address;
280
+ refs[n] = ref;
281
+ n++;
282
+ }
283
+ const fetched =
284
+ this.content.getMany !== undefined
285
+ ? await this.content.getMany(refs)
286
+ : await Promise.all(refs.map((r) => this.content.get(r)));
287
+ const out = new Map<string, Uint8Array>();
288
+ for (let i = 0; i < addresses.length; i++) {
289
+ const bytes = fetched[i];
290
+ if (bytes === null || bytes === undefined) {
291
+ throw new ContentMissingError(addresses[i] as string, executionId);
292
+ }
293
+ out.set(addresses[i] as string, bytes);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ async subscribe(
299
+ executionId: string,
300
+ handler: (event: EventEnvelope) => void
301
+ ): Promise<() => void> {
302
+ // Live subscribers get resolved events too (best-effort: if resolution fails — e.g. a race
303
+ // with erasure — the raw ref-bearing event is delivered rather than dropping it).
304
+ // Audit PERF-A1 — the ref-free common case (heartbeats, STEP_STARTED, decisions)
305
+ // pays the zero-allocation `hasContentRef` walk only; the Map is allocated
306
+ // exclusively for ref-bearing events.
307
+ return this.inner.subscribe(executionId, (event: EventEnvelope) => {
308
+ if (!hasContentRef(event.payload)) {
309
+ handler(event);
310
+ return;
311
+ }
312
+ const distinct = new Map<string, ContentRef>();
313
+ collectContentRefs(event.payload, distinct);
314
+ void this.fetchBytes(distinct, executionId)
315
+ .then((bytes) =>
316
+ handler({ ...event, payload: substituteContentRefs(event.payload, bytes) })
317
+ )
318
+ .catch(() => handler(event));
319
+ });
320
+ }
321
+
322
+ async close(): Promise<void> {
323
+ return this.inner.close();
324
+ }
325
+ }
@@ -0,0 +1,317 @@
1
+ import { createHash } from "node:crypto";
2
+ import { type ContentRef, contentAddress } from "./content";
3
+
4
+ // ── ADR 0046 — the ContentStore SPI ───────────────────────────────────────────
5
+ //
6
+ // A key→blob store for externalized payload bytes, addressed by content hash. Back it with
7
+ // object storage / a filesystem / an encrypted blob store in production (as separate
8
+ // `@loomwork/plugin-content-*` backends); the reference implementation here is in-memory.
9
+
10
+ /**
11
+ * ContentStore SPI version (introduced retroactively by ADR 0062 — the SPI had
12
+ * shipped two revisions without a version anchor for conformance to pin).
13
+ * - 1.0.0: `put`/`get`/`delete` (ADR 0046).
14
+ * - 1.1.0: optional `putStream` (ST-1, ADR 0059).
15
+ * - 1.2.0: optional `getMany` (TS-10, ADR 0062 — batched interactive reads).
16
+ * - 1.3.0: optional `deleteIfUntouchedSince` + `listPage` + `touchClock`, implemented all
17
+ * three or none, and a per-blob touch time that every `put`/`putStream` bumps,
18
+ * monotonically (ADR 0055 D5/D6 — the race-safe, orphan-reclaiming sweep).
19
+ * - 1.4.0: optional `touch` — bump touch times WITHOUT a put, so a citation that enters a log
20
+ * without one (a fork's `copyPrefix`, a ref an `append` carries) is visible to the
21
+ * conditional delete; `reclaimOrphans` requires it (ADR 0055 D6 amendment 2).
22
+ * - 1.5.0: an optional {@link ContentAccess} argument on `put`/`putStream`/`get`/`getMany` (the execution a
23
+ * blob belongs to) and the optional `sealsContent` marker, so a store can seal each blob under its
24
+ * owner's subject key (ADR 0143). A plain store ignores the argument; nothing else changes.
25
+ */
26
+ export const CONTENT_STORE_SPI_VERSION = "1.5.0";
27
+
28
+ /**
29
+ * ADR 0143 - WHO a content operation is for: the execution that offloads (`put`) or reads (`get`) the blob.
30
+ * It is the same pair every event-store `subjectOf` takes, so a sealing store maps it to the erasure subject
31
+ * with the mapping the log already uses. A plain store ignores it. A store that seals
32
+ * ({@link ContentStore.sealsContent}) REFUSES a call without it (fail closed).
33
+ */
34
+ export interface ContentAccess {
35
+ readonly executionId: string;
36
+ readonly workflowId: string;
37
+ }
38
+
39
+ /** One blob as {@link ContentStore.listPage} reports it (SPI 1.3.0). */
40
+ export interface ContentPageEntry {
41
+ address: string;
42
+ /** Epoch ms of the blob's last `put`/`putStream`, by the STORE's clock. */
43
+ touchedAt: number;
44
+ }
45
+
46
+ /** Options for {@link ContentStore.listPage} (SPI 1.3.0). */
47
+ export interface ContentListPageOptions {
48
+ /** Resume after this address: the previous page's `nextCursor`. Absent = from the start. */
49
+ cursor?: string;
50
+ /**
51
+ * Page size. MUST be a positive integer (anything else rejects with a `RangeError`, never
52
+ * "no limit"); a store MAY return fewer. Default {@link CONTENT_LIST_PAGE_DEFAULT_LIMIT},
53
+ * clamped to {@link CONTENT_LIST_PAGE_MAX_LIMIT}.
54
+ */
55
+ limit?: number;
56
+ /** Only blobs whose touch time is strictly before this (epoch ms). */
57
+ touchedBefore?: number;
58
+ }
59
+
60
+ /** One page of {@link ContentStore.listPage}. `nextCursor` absent = the scan is complete. */
61
+ export interface ContentListPage {
62
+ entries: ContentPageEntry[];
63
+ nextCursor?: string;
64
+ }
65
+
66
+ export const CONTENT_LIST_PAGE_DEFAULT_LIMIT = 500;
67
+ export const CONTENT_LIST_PAGE_MAX_LIMIT = 10_000;
68
+
69
+ /**
70
+ * Validate and clamp a `listPage` limit (shared by the reference stores so a 3P backend has
71
+ * one rule to copy). Rejects rather than defaulting: SQLite reads `LIMIT -1` as NO limit.
72
+ */
73
+ export function contentListPageLimit(limit: number | undefined): number {
74
+ if (limit === undefined) return CONTENT_LIST_PAGE_DEFAULT_LIMIT;
75
+ if (!Number.isInteger(limit) || limit < 1) {
76
+ throw new RangeError(`listPage: limit must be a positive integer, got ${limit}`);
77
+ }
78
+ return Math.min(limit, CONTENT_LIST_PAGE_MAX_LIMIT);
79
+ }
80
+
81
+ export interface ContentStore {
82
+ /**
83
+ * ADR 0143 - `true` for a store that SEALS each blob under its owning subject's key (so a crypto-shred of
84
+ * the subject makes every blob it holds unreadable). `defineConfig` accepts `execution.claimCheck` with
85
+ * `security.crypto` only for such a store, and erasure treats the shred as erasing its blobs. Absent/false:
86
+ * a plain store whose bytes sit in the clear.
87
+ */
88
+ readonly sealsContent?: boolean;
89
+ /**
90
+ * OPTIONAL, for a store that {@link sealsContent} (SPI 1.5.0, ADR 0143 D5) - the OWNERSHIP PROOF: does the
91
+ * blob at `ref` authenticate under `access`'s subject key (decrypt and discard)? A citation in a log is not
92
+ * ownership; erasure and export ask before they delete or open a cited blob. `false` for an absent blob or
93
+ * one that does not open under this subject; REJECTS when it cannot decide (the key is destroyed or not on
94
+ * this node). A store that seals and lacks it makes erasure REFUSE a subject that cites blobs.
95
+ */
96
+ ownedBy?(ref: ContentRef, access: ContentAccess): Promise<boolean>;
97
+ /**
98
+ * Store bytes, returning their content-addressed ref. Idempotent by hash (stored once).
99
+ * `access` (SPI 1.5.0, ADR 0143) names the owning execution; a plain store ignores it.
100
+ */
101
+ put(bytes: Uint8Array, access?: ContentAccess): Promise<ContentRef>;
102
+ /**
103
+ * OPTIONAL (ST-1, ADR 0059) — store a byte STREAM without materializing it in
104
+ * the caller's memory. Contract: the returned ref MUST be the content address
105
+ * of the full streamed sequence, idempotent by that address, exactly as if
106
+ * `put(concat(chunks))` had been called. HOW the backend achieves it is its
107
+ * business — the reference approach hashes while spooling; an object-store
108
+ * backend that cannot address before completion may hash-while-upload and
109
+ * verify (or re-key) on finalize (resolves requirements §6.3). Node
110
+ * `Readable` streams satisfy `AsyncIterable<Uint8Array>` directly.
111
+ */
112
+ putStream?(stream: AsyncIterable<Uint8Array>, access?: ContentAccess): Promise<ContentRef>;
113
+ /** Resolve a ref to its bytes, or `null` if erased/absent — MUST NOT throw on absence. */
114
+ get(ref: ContentRef, access?: ContentAccess): Promise<Uint8Array | null>;
115
+ /**
116
+ * OPTIONAL (TS-10, ADR 0062) — batched get for interactive read paths (e.g.
117
+ * frozen-snapshot serving: many refs, one human waiting). Result is
118
+ * positionally aligned with `refs`; `null` per absent entry; MUST NOT throw
119
+ * on absence. Duplicate refs are legal and resolve identically. Semantics are
120
+ * exactly element-wise `get` — only the round-trip count may differ.
121
+ */
122
+ getMany?(refs: readonly ContentRef[], access?: ContentAccess): Promise<(Uint8Array | null)[]>;
123
+ /** Physically erase the bytes. Idempotent; referential integrity is the caller's concern. */
124
+ delete(ref: ContentRef): Promise<void>;
125
+ /**
126
+ * OPTIONAL (SPI 1.3.0, ADR 0055 D5) — erase the blob only if its touch time is strictly
127
+ * before `cutoffMs`, as ONE atomic operation (a single conditional `DELETE`, never a read
128
+ * followed by a delete). Resolves `true` iff it deleted; `false` for a touched or absent
129
+ * blob, never a throw.
130
+ *
131
+ * A store that implements this MUST bump the blob's touch time on EVERY `put`/`putStream`,
132
+ * including a put of bytes it already holds (`ON CONFLICT … DO UPDATE`, not
133
+ * `INSERT OR IGNORE`). A dedup'd put is a re-citation the log has not recorded yet; the
134
+ * touch is how the sweep learns of it.
135
+ */
136
+ deleteIfUntouchedSince?(ref: ContentRef, cutoffMs: number): Promise<boolean>;
137
+ /**
138
+ * OPTIONAL (SPI 1.3.0, ADR 0055 D6) — enumerate blobs, keyset-paged ascending by address,
139
+ * so a scan uses bounded memory and deleting entries mid-scan never skips a survivor.
140
+ * Every blob present for the whole scan is returned exactly once. Implement together
141
+ * with {@link deleteIfUntouchedSince}: the orphan pass requires both.
142
+ */
143
+ listPage?(opts: ContentListPageOptions): Promise<ContentListPage>;
144
+ /**
145
+ * OPTIONAL (SPI 1.3.0, ADR 0055 D5) — the current time on the clock this store stamps touch
146
+ * times with (a database server's `now()`, the process clock of an embedded store). The
147
+ * sweep derives its guard cutoff from it, so the guard never compares a log-time instant
148
+ * with a store-time touch.
149
+ *
150
+ * The three 1.3.0 methods come as a set: a store implements all of `deleteIfUntouchedSince`,
151
+ * `listPage` and `touchClock`, or none of them (the conformance kit fails a partial set).
152
+ * The touch time never moves backwards: a put under a clock that stepped back keeps the
153
+ * later time. `0` means UNKNOWN (a pre-1.3.0 writer's column default) and is never
154
+ * orphan-eligible.
155
+ */
156
+ touchClock?(): Promise<number>;
157
+ /**
158
+ * OPTIONAL (SPI 1.4.0, ADR 0055 D6 amendment 2) — bump the touch time of every listed blob
159
+ * to the store clock, as one batch, WITHOUT a `put`. It is how a citation that enters a log
160
+ * with no `put` (a fork copying refs verbatim, an `append` carrying a ref already in its
161
+ * payload) tells the conditional delete "this was re-cited now".
162
+ *
163
+ * Same semantics as a `put` of bytes the store holds: the time never moves backwards (max).
164
+ * An address the store does not hold is a NO-OP, never a throw and never an insert; the
165
+ * promise resolves with those missing addresses (empty when all were present) so a caller
166
+ * can notice a dangling ref early. Duplicates are legal. An empty list does nothing.
167
+ *
168
+ * Implement it with the 1.3.0 set; `sweepUnpinnedContent({ reclaimOrphans })` refuses to
169
+ * erase through a store that lacks it (the race it closes would otherwise be open).
170
+ */
171
+ touch?(addresses: readonly string[]): Promise<string[]>;
172
+ }
173
+
174
+ /** Options for {@link InMemoryContentStore}. */
175
+ export interface InMemoryContentStoreOptions {
176
+ /** Clock for touch times (epoch ms). Default `Date.now`. */
177
+ now?: () => number;
178
+ }
179
+
180
+ /**
181
+ * Reference in-memory {@link ContentStore} (tests / ephemeral apps). Content-addressed, so a
182
+ * repeated `put` of identical bytes stores one blob — dedup is visible via {@link size} — and
183
+ * bumps that blob's touch time (SPI 1.3.0).
184
+ */
185
+ export class InMemoryContentStore implements ContentStore {
186
+ private readonly blobs = new Map<string, { bytes: Uint8Array; touchedAt: number }>();
187
+ /**
188
+ * Addresses in ascending order for `listPage`'s keyset scan, rebuilt lazily: only a NEW
189
+ * address invalidates it (O(1) per put), a delete leaves a stale entry the scan skips, so a
190
+ * sweep deleting while it pages sorts once — O(n log n) per scan, O(log n + limit) per page.
191
+ */
192
+ private sorted: string[] = [];
193
+ private sortedStale = false;
194
+ private readonly now: () => number;
195
+
196
+ constructor(options: InMemoryContentStoreOptions = {}) {
197
+ this.now = options.now ?? Date.now;
198
+ }
199
+
200
+ async put(bytes: Uint8Array): Promise<ContentRef> {
201
+ const address = contentAddress(bytes);
202
+ this.store(address, bytes);
203
+ return { __loomContent: address, size: bytes.length };
204
+ }
205
+
206
+ async putStream(stream: AsyncIterable<Uint8Array>): Promise<ContentRef> {
207
+ // Hash incrementally while spooling (an in-memory store holds the bytes
208
+ // anyway — durable backends spool to their medium instead of an array).
209
+ const hash = createHash("sha256");
210
+ const chunks: Uint8Array[] = [];
211
+ let size = 0;
212
+ for await (const chunk of stream) {
213
+ hash.update(chunk);
214
+ chunks.push(chunk);
215
+ size += chunk.byteLength;
216
+ }
217
+ const address = `sha256:${hash.digest("hex")}`;
218
+ const existing = this.blobs.get(address);
219
+ if (existing !== undefined) {
220
+ existing.touchedAt = Math.max(existing.touchedAt, this.now()); // monotonic
221
+ } else {
222
+ const bytes = new Uint8Array(size);
223
+ let offset = 0;
224
+ for (const chunk of chunks) {
225
+ bytes.set(chunk, offset);
226
+ offset += chunk.byteLength;
227
+ }
228
+ this.store(address, bytes);
229
+ }
230
+ return { __loomContent: address, size };
231
+ }
232
+
233
+ async get(ref: ContentRef): Promise<Uint8Array | null> {
234
+ return this.blobs.get(ref.__loomContent)?.bytes ?? null;
235
+ }
236
+
237
+ async getMany(refs: readonly ContentRef[]): Promise<(Uint8Array | null)[]> {
238
+ return refs.map((ref) => this.blobs.get(ref.__loomContent)?.bytes ?? null);
239
+ }
240
+
241
+ async delete(ref: ContentRef): Promise<void> {
242
+ this.blobs.delete(ref.__loomContent);
243
+ }
244
+
245
+ async deleteIfUntouchedSince(ref: ContentRef, cutoffMs: number): Promise<boolean> {
246
+ // No await between the check and the delete: atomic on the event loop.
247
+ const blob = this.blobs.get(ref.__loomContent);
248
+ if (blob === undefined || !(blob.touchedAt < cutoffMs)) return false;
249
+ return this.blobs.delete(ref.__loomContent);
250
+ }
251
+
252
+ async touchClock(): Promise<number> {
253
+ return this.now();
254
+ }
255
+
256
+ async touch(addresses: readonly string[]): Promise<string[]> {
257
+ const touchedAt = this.now();
258
+ const missing: string[] = [];
259
+ for (const address of addresses) {
260
+ const blob = this.blobs.get(address);
261
+ if (blob === undefined) missing.push(address);
262
+ else blob.touchedAt = Math.max(blob.touchedAt, touchedAt); // monotonic, like put
263
+ }
264
+ return missing;
265
+ }
266
+
267
+ async listPage(opts: ContentListPageOptions = {}): Promise<ContentListPage> {
268
+ const limit = contentListPageLimit(opts.limit);
269
+ if (this.sortedStale || this.sorted.length > 2 * this.blobs.size + 64) {
270
+ this.sorted = [...this.blobs.keys()].sort();
271
+ this.sortedStale = false;
272
+ }
273
+ let i = opts.cursor === undefined ? 0 : upperBound(this.sorted, opts.cursor);
274
+ const entries: ContentPageEntry[] = [];
275
+ let last: string | undefined;
276
+ for (; i < this.sorted.length && entries.length < limit; i++) {
277
+ const address = this.sorted[i] as string;
278
+ last = address;
279
+ const blob = this.blobs.get(address);
280
+ if (blob === undefined) continue; // deleted since the last rebuild
281
+ if (opts.touchedBefore !== undefined && !(blob.touchedAt < opts.touchedBefore)) continue;
282
+ entries.push({ address, touchedAt: blob.touchedAt });
283
+ }
284
+ return i < this.sorted.length && last !== undefined
285
+ ? { entries, nextCursor: last }
286
+ : { entries };
287
+ }
288
+
289
+ /** Number of distinct blobs stored — makes content-addressed dedup observable in tests. */
290
+ get size(): number {
291
+ return this.blobs.size;
292
+ }
293
+
294
+ private store(address: string, bytes: Uint8Array): void {
295
+ const touchedAt = this.now();
296
+ const existing = this.blobs.get(address);
297
+ if (existing !== undefined) {
298
+ // A re-citation: bump (never backwards), keep the stored bytes.
299
+ existing.touchedAt = Math.max(existing.touchedAt, touchedAt);
300
+ return;
301
+ }
302
+ this.blobs.set(address, { bytes, touchedAt });
303
+ this.sortedStale = true;
304
+ }
305
+ }
306
+
307
+ /** First index whose element is strictly greater than `key` (binary search). */
308
+ function upperBound(sorted: readonly string[], key: string): number {
309
+ let lo = 0;
310
+ let hi = sorted.length;
311
+ while (lo < hi) {
312
+ const mid = (lo + hi) >>> 1;
313
+ if ((sorted[mid] as string) <= key) lo = mid + 1;
314
+ else hi = mid;
315
+ }
316
+ return lo;
317
+ }