@loomwork/plugin-content 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +92 -2
- package/dist/CachingContentStore.d.ts +54 -0
- package/dist/CachingContentStore.d.ts.map +1 -0
- package/dist/CachingContentStore.js +171 -0
- package/dist/CachingContentStore.js.map +1 -0
- package/dist/ContentResolvingEventStore.d.ts +70 -0
- package/dist/ContentResolvingEventStore.d.ts.map +1 -0
- package/dist/ContentResolvingEventStore.js +245 -0
- package/dist/ContentResolvingEventStore.js.map +1 -0
- package/dist/ContentStore.d.ts +186 -0
- package/dist/ContentStore.d.ts.map +1 -0
- package/dist/ContentStore.js +177 -0
- package/dist/ContentStore.js.map +1 -0
- package/dist/ContentTouchingSnapshotStore.d.ts +56 -0
- package/dist/ContentTouchingSnapshotStore.d.ts.map +1 -0
- package/dist/ContentTouchingSnapshotStore.js +85 -0
- package/dist/ContentTouchingSnapshotStore.js.map +1 -0
- package/dist/citations.d.ts +40 -0
- package/dist/citations.d.ts.map +1 -0
- package/dist/citations.js +118 -0
- package/dist/citations.js.map +1 -0
- package/dist/content.bench.d.ts +2 -0
- package/dist/content.bench.d.ts.map +1 -0
- package/dist/content.bench.js +75 -0
- package/dist/content.bench.js.map +1 -0
- package/dist/content.d.ts +40 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +177 -0
- package/dist/content.js.map +1 -0
- package/dist/derive.d.ts +9 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +40 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +20 -0
- package/dist/errors.js.map +1 -0
- package/dist/executionEnd.d.ts +22 -0
- package/dist/executionEnd.d.ts.map +1 -0
- package/dist/executionEnd.js +66 -0
- package/dist/executionEnd.js.map +1 -0
- package/dist/gc.d.ts +470 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +819 -0
- package/dist/gc.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/touchAround.d.ts +46 -0
- package/dist/touchAround.d.ts.map +1 -0
- package/dist/touchAround.js +98 -0
- package/dist/touchAround.js.map +1 -0
- package/package.json +58 -3
- package/src/CachingContentStore.ts +184 -0
- package/src/ContentResolvingEventStore.ts +325 -0
- package/src/ContentStore.ts +317 -0
- package/src/ContentTouchingSnapshotStore.ts +109 -0
- package/src/citations.ts +117 -0
- package/src/content.bench.ts +84 -0
- package/src/content.ts +191 -0
- package/src/derive.ts +40 -0
- package/src/errors.ts +19 -0
- package/src/executionEnd.ts +64 -0
- package/src/gc.ts +1213 -0
- package/src/index.ts +54 -0
- 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
|
+
}
|