@loomwork/ui-lineage 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.
Files changed (88) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +471 -2
  4. package/dist/conformance.d.ts +4 -0
  5. package/dist/conformance.d.ts.map +1 -0
  6. package/dist/conformance.js +143 -0
  7. package/dist/conformance.js.map +1 -0
  8. package/dist/derive.d.ts +15 -0
  9. package/dist/derive.d.ts.map +1 -0
  10. package/dist/derive.js +35 -0
  11. package/dist/derive.js.map +1 -0
  12. package/dist/esm/conformance.js +140 -0
  13. package/dist/esm/conformance.js.map +1 -0
  14. package/dist/esm/derive.js +29 -0
  15. package/dist/esm/derive.js.map +1 -0
  16. package/dist/esm/events.js +101 -0
  17. package/dist/esm/events.js.map +1 -0
  18. package/dist/esm/index.js +12 -0
  19. package/dist/esm/index.js.map +1 -0
  20. package/dist/esm/lineage.js +1376 -0
  21. package/dist/esm/lineage.js.map +1 -0
  22. package/dist/esm/retention.js +73 -0
  23. package/dist/esm/retention.js.map +1 -0
  24. package/dist/esm/rootsFor.js +49 -0
  25. package/dist/esm/rootsFor.js.map +1 -0
  26. package/dist/esm/seen.js +733 -0
  27. package/dist/esm/seen.js.map +1 -0
  28. package/dist/esm/seenCrypto.js +197 -0
  29. package/dist/esm/seenCrypto.js.map +1 -0
  30. package/dist/esm/seenGc.js +122 -0
  31. package/dist/esm/seenGc.js.map +1 -0
  32. package/dist/esm/state.js +185 -0
  33. package/dist/esm/state.js.map +1 -0
  34. package/dist/esm/store.js +172 -0
  35. package/dist/esm/store.js.map +1 -0
  36. package/dist/events.d.ts +781 -0
  37. package/dist/events.d.ts.map +1 -0
  38. package/dist/events.js +106 -0
  39. package/dist/events.js.map +1 -0
  40. package/dist/index.d.ts +23 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +60 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/lineage.d.ts +394 -0
  45. package/dist/lineage.d.ts.map +1 -0
  46. package/dist/lineage.js +1383 -0
  47. package/dist/lineage.js.map +1 -0
  48. package/dist/retention.d.ts +45 -0
  49. package/dist/retention.d.ts.map +1 -0
  50. package/dist/retention.js +78 -0
  51. package/dist/retention.js.map +1 -0
  52. package/dist/rootsFor.d.ts +33 -0
  53. package/dist/rootsFor.d.ts.map +1 -0
  54. package/dist/rootsFor.js +52 -0
  55. package/dist/rootsFor.js.map +1 -0
  56. package/dist/seen.d.ts +375 -0
  57. package/dist/seen.d.ts.map +1 -0
  58. package/dist/seen.js +739 -0
  59. package/dist/seen.js.map +1 -0
  60. package/dist/seenCrypto.d.ts +72 -0
  61. package/dist/seenCrypto.d.ts.map +1 -0
  62. package/dist/seenCrypto.js +210 -0
  63. package/dist/seenCrypto.js.map +1 -0
  64. package/dist/seenGc.d.ts +73 -0
  65. package/dist/seenGc.d.ts.map +1 -0
  66. package/dist/seenGc.js +126 -0
  67. package/dist/seenGc.js.map +1 -0
  68. package/dist/state.d.ts +66 -0
  69. package/dist/state.d.ts.map +1 -0
  70. package/dist/state.js +190 -0
  71. package/dist/state.js.map +1 -0
  72. package/dist/store.d.ts +93 -0
  73. package/dist/store.d.ts.map +1 -0
  74. package/dist/store.js +177 -0
  75. package/dist/store.js.map +1 -0
  76. package/package.json +60 -3
  77. package/src/conformance.ts +148 -0
  78. package/src/derive.ts +57 -0
  79. package/src/events.ts +125 -0
  80. package/src/index.ts +79 -0
  81. package/src/lineage.ts +1655 -0
  82. package/src/retention.ts +118 -0
  83. package/src/rootsFor.ts +75 -0
  84. package/src/seen.ts +965 -0
  85. package/src/seenCrypto.ts +271 -0
  86. package/src/seenGc.ts +233 -0
  87. package/src/state.ts +268 -0
  88. package/src/store.ts +274 -0
package/src/state.ts ADDED
@@ -0,0 +1,268 @@
1
+ import {
2
+ type Finding,
3
+ MAX_TREE_DEPTH,
4
+ type Provenance,
5
+ type RecordedOperation,
6
+ boundSummary,
7
+ treeDepth,
8
+ } from "@loomwork/ui-core";
9
+ import {
10
+ type BranchedPayload,
11
+ type ComposedPayload,
12
+ EXPERIENCE_BRANCHED,
13
+ EXPERIENCE_COMPOSED,
14
+ EXPERIENCE_PROMOTED,
15
+ EXPERIENCE_REVERTED,
16
+ PAYLOAD_SCHEMAS,
17
+ type RevertedPayload,
18
+ type SummaryClass,
19
+ isLineageEventType,
20
+ } from "./events";
21
+ import type { StoredLineageRecord } from "./store";
22
+
23
+ // ── Folding a lineage's events into its state ─────────────────────────────────
24
+ // The fold is pure and total (a malformed log is a finding, never a throw) and it is where the
25
+ // log's STRUCTURE is checked: dense sequences, every version's `parents` naming the head it was
26
+ // appended on, one version per content address. What a version's artifact IS, and whether its ops
27
+ // reproduce it, needs the content store and is `Lineage.verify`'s job.
28
+
29
+ export type VersionKind = "composed" | "reverted" | "branched";
30
+
31
+ export interface VersionRecord {
32
+ readonly experienceId: string;
33
+ readonly versionHash: string;
34
+ readonly ref: { readonly __loomContent: string };
35
+ readonly parents: readonly string[];
36
+ readonly kind: VersionKind;
37
+ /** Position in the line's event log. */
38
+ readonly sequence: number;
39
+ readonly at: string;
40
+ readonly by: Provenance;
41
+ /** The ops folded onto the parent (`composed` only; `[]` otherwise and for a genesis). */
42
+ readonly ops: readonly RecordedOperation[];
43
+ /** `composed`: what the author said about the change; untrusted text, already bounded. */
44
+ readonly summary?: string;
45
+ /** `composed`: the class of what the summary was written from (host-stamped); see `ComposedPayload`. */
46
+ readonly summaryClass?: SummaryClass;
47
+ /** `reverted`: the version whose content this restores. */
48
+ readonly revertedTo?: string;
49
+ /** `branched`: the version of another line this forked, and the source head classes came from. */
50
+ readonly from?: { readonly experienceId: string; readonly versionHash: string };
51
+ readonly carriedFrom?: string;
52
+ }
53
+
54
+ export interface LineageState {
55
+ readonly experienceId: string;
56
+ /** The event log's tip (0 = empty): the compare-and-set token of the STORE. */
57
+ readonly sequence: number;
58
+ readonly versions: ReadonlyMap<string, VersionRecord>;
59
+ /** Version hashes in append order. */
60
+ readonly order: readonly string[];
61
+ /** The latest version (the CAS token of `advance`); null = the line has no version yet. */
62
+ readonly head: string | null;
63
+ /** The version the app chose to show; null = none promoted. */
64
+ readonly promoted: string | null;
65
+ }
66
+
67
+ /** Most ops one version may record. A longer array is a finding before the schema ever walks it. */
68
+ export const MAX_OPS_PER_VERSION = 10_000;
69
+ /** How deep `prompt` ops may nest their `materialized` ops while being checked. */
70
+ export const MAX_OP_NESTING = 16;
71
+
72
+ /**
73
+ * Why a stored ops array must not reach the (recursive) zod `Operation` schema, or undefined when
74
+ * it may. The schema recurses through `insertNode.node` and `prompt.materialized`, so a payload
75
+ * nested past the caps would blow the stack inside `safeParse` instead of returning an issue;
76
+ * a log is untrusted input (anyone with append access to the stream wrote it).
77
+ */
78
+ function opsRefusal(ops: unknown): string | undefined {
79
+ if (!Array.isArray(ops)) return undefined; // the schema reports the wrong shape
80
+ if (ops.length > MAX_OPS_PER_VERSION)
81
+ return `records ${ops.length} ops, over the ${MAX_OPS_PER_VERSION} limit`;
82
+ const stack: Array<{ op: unknown; nest: number }> = [];
83
+ for (const r of ops) stack.push({ op: (r as { op?: unknown } | null)?.op, nest: 1 });
84
+ let seen = 0;
85
+ for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
86
+ const { op, nest } = top;
87
+ if (typeof op !== "object" || op === null) continue;
88
+ if (++seen > MAX_OPS_PER_VERSION * 2) return "nests too many ops";
89
+ const node = (op as { node?: unknown }).node;
90
+ if (node !== undefined) {
91
+ const depth = treeDepth(node as never);
92
+ if (depth > MAX_TREE_DEPTH)
93
+ return `an op's node nests ${depth} levels, over the ${MAX_TREE_DEPTH} limit`;
94
+ }
95
+ const inner = (op as { materialized?: unknown }).materialized;
96
+ if (Array.isArray(inner)) {
97
+ if (nest >= MAX_OP_NESTING) return `prompt ops nest over ${MAX_OP_NESTING} levels`;
98
+ if (inner.length > MAX_OPS_PER_VERSION) return "a prompt op materializes too many ops";
99
+ for (const i of inner) stack.push({ op: i, nest: nest + 1 });
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * The WRITE-SIDE twin of the fold's payload check: why `payload` would make the line unreadable
107
+ * (`lineage-event-invalid` on every later read), or undefined when it folds. A write that fails this
108
+ * must be refused BEFORE the append — the fold stays strict, the log has no delete, and one bad event
109
+ * would brick the line for everyone. `by` marks a provenance problem (the caller gives it its own code).
110
+ */
111
+ export function payloadRefusal(
112
+ type: string,
113
+ payload: unknown
114
+ ):
115
+ | { readonly code: "provenance-invalid" | "event-payload-invalid"; readonly message: string }
116
+ | undefined {
117
+ if (!isLineageEventType(type))
118
+ return { code: "event-payload-invalid", message: `unknown event type ${String(type)}` };
119
+ try {
120
+ const tooBig = opsRefusal((payload as { ops?: unknown } | null)?.ops);
121
+ if (tooBig !== undefined) return { code: "event-payload-invalid", message: tooBig };
122
+ const parsed = PAYLOAD_SCHEMAS[type].safeParse(payload);
123
+ if (parsed.success) return undefined;
124
+ const issue = parsed.error.issues[0];
125
+ const inBy = parsed.error.issues.some((i) => i.path.includes("by"));
126
+ return {
127
+ code: inBy ? "provenance-invalid" : "event-payload-invalid",
128
+ message: `${inBy ? "a provenance (`by`) is invalid" : "the event payload is malformed"}: ${issue?.message ?? "invalid"}${issue ? ` at ${issue.path.join(".")}` : ""}`,
129
+ };
130
+ } catch (err) {
131
+ return { code: "event-payload-invalid", message: errMessage(err) };
132
+ }
133
+ }
134
+
135
+ export type Fold = { ok: true; state: LineageState } | { ok: false; finding: Finding };
136
+
137
+ const bad = (code: string, message: string): Fold => ({
138
+ ok: false,
139
+ finding: { code, severity: "error", message },
140
+ });
141
+
142
+ export function foldLineage(experienceId: string, records: readonly StoredLineageRecord[]): Fold {
143
+ try {
144
+ return foldChecked(experienceId, records);
145
+ } catch (err) {
146
+ // Total by construction: whatever a hostile record does (a throwing getter, a shape the
147
+ // checks above did not anticipate) is a finding, never an exception out of every read.
148
+ return bad("lineage-event-invalid", `lineage "${experienceId}": ${errMessage(err)}`);
149
+ }
150
+ }
151
+
152
+ const errMessage = (err: unknown): string => {
153
+ try {
154
+ return err instanceof Error ? err.message : String(err);
155
+ } catch {
156
+ return "unreadable record";
157
+ }
158
+ };
159
+
160
+ function foldChecked(experienceId: string, records: readonly StoredLineageRecord[]): Fold {
161
+ const versions = new Map<string, VersionRecord>();
162
+ const order: string[] = [];
163
+ let head: string | null = null;
164
+ let promoted: string | null = null;
165
+ let expectedSequence = 0;
166
+ for (const rec of records) {
167
+ expectedSequence += 1;
168
+ if (typeof rec !== "object" || rec === null)
169
+ return bad(
170
+ "lineage-event-invalid",
171
+ `lineage "${experienceId}" event #${expectedSequence}: the record is not an object`
172
+ );
173
+ const where = `lineage "${experienceId}" event #${rec.sequence} (${rec.type})`;
174
+ if (rec.sequence !== expectedSequence)
175
+ return bad(
176
+ "lineage-sequence-gap",
177
+ `${where}: expected sequence ${expectedSequence} — the log has a hole or a reorder`
178
+ );
179
+ if (!isLineageEventType(rec.type))
180
+ return bad("lineage-event-unknown", `${where}: unknown type`);
181
+ const tooBig = opsRefusal((rec.payload as { ops?: unknown } | null)?.ops);
182
+ if (tooBig !== undefined) return bad("lineage-event-invalid", `${where}: ${tooBig}`);
183
+ const parsed = PAYLOAD_SCHEMAS[rec.type].safeParse(rec.payload);
184
+ if (!parsed.success)
185
+ return bad(
186
+ "lineage-event-invalid",
187
+ `${where}: ${parsed.error.issues[0]?.message ?? "payload is malformed"}`
188
+ );
189
+ const p = parsed.data;
190
+ if (p.experienceId !== experienceId)
191
+ return bad("lineage-event-foreign", `${where}: names experience "${p.experienceId}"`);
192
+
193
+ if (rec.type === EXPERIENCE_PROMOTED) {
194
+ if (!versions.has(p.versionHash))
195
+ return bad(
196
+ "lineage-promote-unknown",
197
+ `${where}: promotes unknown version ${p.versionHash}`
198
+ );
199
+ promoted = p.versionHash;
200
+ continue;
201
+ }
202
+
203
+ const v = p as ComposedPayload | RevertedPayload | BranchedPayload;
204
+ if (versions.has(v.versionHash))
205
+ return bad("lineage-version-duplicate", `${where}: version ${v.versionHash} already exists`);
206
+ if (rec.type === EXPERIENCE_BRANCHED) {
207
+ if (head !== null)
208
+ return bad("lineage-branch-midline", `${where}: only a line's first event`);
209
+ } else if (head === null) {
210
+ if (rec.type !== EXPERIENCE_COMPOSED || v.parents.length !== 0)
211
+ return bad(
212
+ "lineage-genesis-invalid",
213
+ `${where}: a line starts with a parentless composition`
214
+ );
215
+ } else if (v.parents.length !== 1 || v.parents[0] !== head) {
216
+ return bad(
217
+ "lineage-parent-mismatch",
218
+ `${where}: parents ${JSON.stringify(v.parents)} are not the head ${head} it was appended on`
219
+ );
220
+ }
221
+ const base = {
222
+ experienceId,
223
+ versionHash: v.versionHash,
224
+ ref: v.ref,
225
+ parents: v.parents,
226
+ sequence: rec.sequence,
227
+ at: rec.at,
228
+ by: v.by,
229
+ };
230
+ const record: VersionRecord =
231
+ rec.type === EXPERIENCE_COMPOSED
232
+ ? {
233
+ ...base,
234
+ kind: "composed",
235
+ ops: (v as ComposedPayload).ops as RecordedOperation[],
236
+ ...summaryOf((v as ComposedPayload).summary),
237
+ ...((v as ComposedPayload).summaryClass !== undefined && {
238
+ summaryClass: (v as ComposedPayload).summaryClass as SummaryClass,
239
+ }),
240
+ }
241
+ : rec.type === EXPERIENCE_REVERTED
242
+ ? {
243
+ ...base,
244
+ kind: "reverted",
245
+ ops: [],
246
+ revertedTo: (v as RevertedPayload).revertedTo,
247
+ }
248
+ : {
249
+ ...base,
250
+ kind: "branched",
251
+ ops: [],
252
+ from: (v as BranchedPayload).from,
253
+ carriedFrom: (v as BranchedPayload).carriedFrom,
254
+ };
255
+ versions.set(v.versionHash, record);
256
+ order.push(v.versionHash);
257
+ head = v.versionHash;
258
+ }
259
+ return {
260
+ ok: true,
261
+ state: { experienceId, sequence: expectedSequence, versions, order, head, promoted },
262
+ };
263
+ }
264
+
265
+ const summaryOf = (raw: string | undefined): { summary?: string } => {
266
+ const summary = boundSummary(raw);
267
+ return summary === undefined ? {} : { summary };
268
+ };
package/src/store.ts ADDED
@@ -0,0 +1,274 @@
1
+ import {
2
+ DuplicateEnvelopeIdError,
3
+ DuplicateSequenceError,
4
+ type EventStore,
5
+ headOf,
6
+ } from "@loomwork/event-runtime";
7
+
8
+ // ── The store seam ────────────────────────────────────────────────────────────
9
+ // What a lineage needs from storage is exactly one thing loom's event log already is: an
10
+ // append-only, per-stream, SEQUENCED log whose append refuses a taken slot. The compare-and-set
11
+ // is the sequence: "append as event n+1 iff the tip is n". `LineageStore` is that, narrowed to
12
+ // what a lineage uses, so a backend that is not an `EventStore` (a table, a KV with a conditional
13
+ // put) can serve it, and `eventStoreLineageStore` adapts the loom SPI that is.
14
+
15
+ export interface LineageRecord {
16
+ readonly type: string;
17
+ readonly payload: unknown;
18
+ /** ISO-8601 time the event was recorded. */
19
+ readonly at: string;
20
+ }
21
+
22
+ export interface StoredLineageRecord extends LineageRecord {
23
+ /** 1-based position in this line. Dense: 1..n with no gaps. */
24
+ readonly sequence: number;
25
+ }
26
+
27
+ export type AppendOutcome =
28
+ | { readonly ok: true; readonly sequence: number }
29
+ /** The tip was not `expectedSequence`; `tip` is what it is now. Nothing was written. */
30
+ | { readonly ok: false; readonly tip: number };
31
+
32
+ export interface LineageStore {
33
+ /**
34
+ * Append `record` as sequence `expectedSequence + 1` iff the line's tip is EXACTLY
35
+ * `expectedSequence` (0 = the line is empty). Atomic: of N concurrent appends at the same
36
+ * `expectedSequence` exactly one wins and the rest get `{ ok: false }`. NEVER overwrites,
37
+ * never skips a sequence, and a refusal is a returned outcome, not a throw (a throw is an
38
+ * infrastructure failure and leaves the line untouched).
39
+ */
40
+ append(
41
+ experienceId: string,
42
+ expectedSequence: number,
43
+ record: LineageRecord
44
+ ): Promise<AppendOutcome>;
45
+ /** Every record of the line in sequence order; `[]` for an unknown line. The caller owns the result. */
46
+ read(experienceId: string): Promise<readonly StoredLineageRecord[]>;
47
+ /**
48
+ * OPTIONAL — the newest `limit` records of the line, in sequence order (`[]` for an unknown line).
49
+ * Lets a long-lived per-viewer log answer "what is the tip?" without reading its whole history.
50
+ * Implement it together with {@link readAfter}; absent, callers fall back to `read` (O(log length)).
51
+ */
52
+ readTail?(experienceId: string, limit: number): Promise<readonly StoredLineageRecord[]>;
53
+ /**
54
+ * OPTIONAL — the records with `sequence > afterSequence`, in sequence order, at most `limit` of
55
+ * them (default: all). The retention window of a per-viewer log is read this way, so a read costs
56
+ * the size of the window, not the age of the log.
57
+ */
58
+ readAfter?(
59
+ experienceId: string,
60
+ afterSequence: number,
61
+ limit?: number
62
+ ): Promise<readonly StoredLineageRecord[]>;
63
+ /**
64
+ * OPTIONAL — enumerate the ids of every line this store holds, in bounded pages. A collector
65
+ * (`SeenLog.collectGarbage`) needs it to find every root; a store that omits it cannot be
66
+ * garbage-collected (the collector refuses rather than guess a root set). Ids are exactly what
67
+ * `read` takes. Concurrent creation during a scan may be missed (a maintenance pass, not a snapshot).
68
+ */
69
+ listStreams?(): AsyncIterable<string>;
70
+ }
71
+
72
+ /**
73
+ * A lost slot, by class OR name (a store decorator or a second copy of the package throws an error
74
+ * that is not `instanceof` ours); mirrors the kernel's `isWriteConflict`.
75
+ */
76
+ function isWriteConflict(err: unknown): boolean {
77
+ if (err instanceof DuplicateSequenceError || err instanceof DuplicateEnvelopeIdError) return true;
78
+ const name = (err as { name?: unknown } | null)?.name;
79
+ return name === "DuplicateSequenceError" || name === "DuplicateEnvelopeIdError";
80
+ }
81
+
82
+ type EventEnvelope = Awaited<ReturnType<EventStore["read"]>>[number];
83
+
84
+ const clone = <T>(v: T): T => structuredClone(v);
85
+
86
+ /** Reference in-memory store. Copies on the way in and out, so no caller can mutate the log. */
87
+ export class InMemoryLineageStore implements LineageStore {
88
+ private readonly lines = new Map<string, StoredLineageRecord[]>();
89
+
90
+ async append(
91
+ experienceId: string,
92
+ expectedSequence: number,
93
+ record: LineageRecord
94
+ ): Promise<AppendOutcome> {
95
+ const line = this.lines.get(experienceId) ?? [];
96
+ if (line.length !== expectedSequence) return { ok: false, tip: line.length };
97
+ const sequence = line.length + 1;
98
+ line.push(clone({ ...record, sequence }));
99
+ this.lines.set(experienceId, line);
100
+ return { ok: true, sequence };
101
+ }
102
+
103
+ async read(experienceId: string): Promise<readonly StoredLineageRecord[]> {
104
+ return clone(this.lines.get(experienceId) ?? []);
105
+ }
106
+
107
+ async readTail(experienceId: string, limit: number): Promise<readonly StoredLineageRecord[]> {
108
+ const line = this.lines.get(experienceId) ?? [];
109
+ return clone(limit >= line.length ? line : line.slice(line.length - Math.max(0, limit)));
110
+ }
111
+
112
+ async readAfter(
113
+ experienceId: string,
114
+ afterSequence: number,
115
+ limit = Number.POSITIVE_INFINITY
116
+ ): Promise<readonly StoredLineageRecord[]> {
117
+ // dense 1..n: the records after `a` start at index `a`
118
+ const line = this.lines.get(experienceId) ?? [];
119
+ const from = Math.max(0, afterSequence);
120
+ return clone(line.slice(from, Number.isFinite(limit) ? from + Math.max(0, limit) : undefined));
121
+ }
122
+
123
+ async *listStreams(): AsyncGenerator<string, void, void> {
124
+ for (const id of [...this.lines.keys()]) yield id;
125
+ }
126
+ }
127
+
128
+ /** The reserved-stream namespace a lineage's events live under in an `EventStore`. */
129
+ export const LINEAGE_STREAM_PREFIX = "__loom_ui_lineage__:";
130
+ /** The `workflowId` a lineage event is written under unless `eventStoreLineageStore` is given another. */
131
+ export const LINEAGE_WORKFLOW_ID = "loom-ui.lineage";
132
+ const WORKFLOW_ID = LINEAGE_WORKFLOW_ID;
133
+
134
+ /** Every stream prefix this package may write under; the kernel's own reserved prefixes are not ours. */
135
+ export const LOOM_UI_STREAM_NAMESPACE = "__loom_ui_";
136
+ const WORKFLOW_ID_SHAPE = /^[A-Za-z0-9._:-]{1,128}$/;
137
+
138
+ /** A `LineageStore` that knows which stream prefix it writes under (see {@link eventStoreLineageStore}). */
139
+ export interface PrefixedLineageStore extends LineageStore {
140
+ readonly streamPrefix: string;
141
+ /**
142
+ * The `workflowId` its events are written under. A `Lineage` passes it, with the stream id, as the
143
+ * `ContentAccess` of every artifact blob, so one host `subjectOf` covers events and blobs alike.
144
+ */
145
+ readonly workflowId?: string;
146
+ }
147
+
148
+ /**
149
+ * A `LineageStore` over loom's `EventStore`. One stream per experience
150
+ * (`__loom_ui_lineage__:<experienceId>`, a RESERVED stream so enumerators of real executions skip
151
+ * it; see the README for what a reserved stream costs); the lineage sequence IS the envelope sequence, so the CAS is the log's own
152
+ * `(executionId, sequence)` uniqueness. The adapter checks the tip first (a store enforces a
153
+ * taken slot, not a skipped one) and treats a `DuplicateSequenceError` from a racing writer as a
154
+ * lost CAS. Fencing, tenancy and sharding decorators wrap `store` as usual.
155
+ *
156
+ * `streamPrefix` must start with `__loom_ui_` (and say more): it is the namespace this package owns
157
+ * inside loom's reserved `__loom_` space, so a configuration can never write into the kernel's own
158
+ * control streams (`__loom_lifecycle__`, `__loom_erasure__`) or an execution's id space. `workflowId`
159
+ * is a short identifier. A bad value THROWS at construction (a misconfiguration, found at startup).
160
+ */
161
+ export function eventStoreLineageStore(
162
+ store: EventStore,
163
+ opts: { readonly streamPrefix?: string; readonly workflowId?: string } = {}
164
+ ): PrefixedLineageStore {
165
+ const prefix = opts.streamPrefix ?? LINEAGE_STREAM_PREFIX;
166
+ const workflowId = opts.workflowId ?? WORKFLOW_ID;
167
+ if (
168
+ typeof prefix !== "string" ||
169
+ !prefix.startsWith(LOOM_UI_STREAM_NAMESPACE) ||
170
+ prefix.length <= LOOM_UI_STREAM_NAMESPACE.length ||
171
+ prefix.length > 128
172
+ )
173
+ throw new Error(
174
+ `streamPrefix must start with "${LOOM_UI_STREAM_NAMESPACE}" and name its stream (got ${JSON.stringify(String(prefix).slice(0, 40))})`
175
+ );
176
+ if (typeof workflowId !== "string" || !WORKFLOW_ID_SHAPE.test(workflowId))
177
+ throw new Error("workflowId must be 1-128 characters of [A-Za-z0-9._:-]");
178
+ const streamOf = (experienceId: string) => `${prefix}${experienceId}`;
179
+ return {
180
+ streamPrefix: prefix,
181
+ workflowId,
182
+ async append(experienceId, expectedSequence, record) {
183
+ const executionId = streamOf(experienceId);
184
+ const tip = (await headOf(store, executionId)) ?? 0;
185
+ if (tip !== expectedSequence) return { ok: false, tip };
186
+ const sequence = expectedSequence + 1;
187
+ try {
188
+ await store.append({
189
+ id: `${executionId}#${sequence}`,
190
+ workflowId,
191
+ executionId,
192
+ eventType: record.type,
193
+ sequence,
194
+ timestamp: Date.parse(record.at) || 0,
195
+ payload: clone(record.payload),
196
+ metadata: { at: record.at },
197
+ });
198
+ } catch (err) {
199
+ if (isWriteConflict(err))
200
+ return { ok: false, tip: (await headOf(store, executionId)) ?? sequence };
201
+ throw err;
202
+ }
203
+ return { ok: true, sequence };
204
+ },
205
+ async read(experienceId) {
206
+ return toRecords(await store.read(streamOf(experienceId)));
207
+ },
208
+ async readTail(experienceId, limit) {
209
+ const id = streamOf(experienceId);
210
+ const tip = (await headOf(store, id)) ?? 0;
211
+ if (tip === 0 || limit <= 0) return [];
212
+ return toRecords(await readWindow(store, id, Math.max(1, tip - limit + 1), tip));
213
+ },
214
+ async readAfter(experienceId, afterSequence, limit = Number.POSITIVE_INFINITY) {
215
+ const id = streamOf(experienceId);
216
+ const tip = (await headOf(store, id)) ?? 0;
217
+ const from = Math.max(1, afterSequence + 1);
218
+ const to = Number.isFinite(limit) ? Math.min(tip, from + Math.max(0, limit) - 1) : tip;
219
+ if (to < from) return [];
220
+ return toRecords(await readWindow(store, id, from, to));
221
+ },
222
+ ...(store.listExecutionsPage !== undefined || store.listExecutions !== undefined
223
+ ? {
224
+ async *listStreams(): AsyncGenerator<string, void, void> {
225
+ // Reserved streams ARE surfaced by the SPI's enumeration (`__loom_*`), so ours are found
226
+ // by prefix. Pages are keyset-ordered by code point, so with pages the scan starts just
227
+ // before our prefix and stops once past it; the unpaged fallback filters the full list.
228
+ if (store.listExecutionsPage === undefined) {
229
+ for (const id of (await store.listExecutions?.()) ?? [])
230
+ if (id.startsWith(prefix)) yield id.slice(prefix.length);
231
+ return;
232
+ }
233
+ let cursor: string | undefined = prefix.slice(0, -1);
234
+ for (;;) {
235
+ const page: { executionIds: string[]; nextCursor?: string } =
236
+ await store.listExecutionsPage({
237
+ ...(cursor !== undefined && { cursor }),
238
+ limit: 500,
239
+ });
240
+ for (const id of page.executionIds) {
241
+ if (id.startsWith(prefix)) yield id.slice(prefix.length);
242
+ else if (id > prefix) return;
243
+ }
244
+ if (page.nextCursor === undefined || page.nextCursor === cursor) return;
245
+ cursor = page.nextCursor;
246
+ }
247
+ },
248
+ }
249
+ : {}),
250
+ };
251
+ }
252
+
253
+ function toRecords(events: readonly EventEnvelope[]): StoredLineageRecord[] {
254
+ return events
255
+ .slice()
256
+ .sort((a, b) => a.sequence - b.sequence)
257
+ .map((e) => ({
258
+ sequence: e.sequence,
259
+ type: e.eventType,
260
+ payload: clone(e.payload),
261
+ at: typeof e.metadata?.at === "string" ? e.metadata.at : new Date(e.timestamp).toISOString(),
262
+ }));
263
+ }
264
+
265
+ /** Sequences `from..to` (inclusive) of a stream: the store's `readRange`, else `readFrom`/`read` filtered. */
266
+ async function readWindow(
267
+ store: EventStore,
268
+ id: string,
269
+ from: number,
270
+ to: number
271
+ ): Promise<EventEnvelope[]> {
272
+ if (store.readRange !== undefined) return store.readRange(id, from, to);
273
+ return (await store.read(id)).filter((e) => e.sequence >= from && e.sequence <= to);
274
+ }