@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +471 -2
- package/dist/conformance.d.ts +4 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +143 -0
- package/dist/conformance.js.map +1 -0
- package/dist/derive.d.ts +15 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +35 -0
- package/dist/derive.js.map +1 -0
- package/dist/esm/conformance.js +140 -0
- package/dist/esm/conformance.js.map +1 -0
- package/dist/esm/derive.js +29 -0
- package/dist/esm/derive.js.map +1 -0
- package/dist/esm/events.js +101 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lineage.js +1376 -0
- package/dist/esm/lineage.js.map +1 -0
- package/dist/esm/retention.js +73 -0
- package/dist/esm/retention.js.map +1 -0
- package/dist/esm/rootsFor.js +49 -0
- package/dist/esm/rootsFor.js.map +1 -0
- package/dist/esm/seen.js +733 -0
- package/dist/esm/seen.js.map +1 -0
- package/dist/esm/seenCrypto.js +197 -0
- package/dist/esm/seenCrypto.js.map +1 -0
- package/dist/esm/seenGc.js +122 -0
- package/dist/esm/seenGc.js.map +1 -0
- package/dist/esm/state.js +185 -0
- package/dist/esm/state.js.map +1 -0
- package/dist/esm/store.js +172 -0
- package/dist/esm/store.js.map +1 -0
- package/dist/events.d.ts +781 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +106 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/lineage.d.ts +394 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +1383 -0
- package/dist/lineage.js.map +1 -0
- package/dist/retention.d.ts +45 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +78 -0
- package/dist/retention.js.map +1 -0
- package/dist/rootsFor.d.ts +33 -0
- package/dist/rootsFor.d.ts.map +1 -0
- package/dist/rootsFor.js +52 -0
- package/dist/rootsFor.js.map +1 -0
- package/dist/seen.d.ts +375 -0
- package/dist/seen.d.ts.map +1 -0
- package/dist/seen.js +739 -0
- package/dist/seen.js.map +1 -0
- package/dist/seenCrypto.d.ts +72 -0
- package/dist/seenCrypto.d.ts.map +1 -0
- package/dist/seenCrypto.js +210 -0
- package/dist/seenCrypto.js.map +1 -0
- package/dist/seenGc.d.ts +73 -0
- package/dist/seenGc.d.ts.map +1 -0
- package/dist/seenGc.js +126 -0
- package/dist/seenGc.js.map +1 -0
- package/dist/state.d.ts +66 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +190 -0
- package/dist/state.js.map +1 -0
- package/dist/store.d.ts +93 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +177 -0
- package/dist/store.js.map +1 -0
- package/package.json +60 -3
- package/src/conformance.ts +148 -0
- package/src/derive.ts +57 -0
- package/src/events.ts +125 -0
- package/src/index.ts +79 -0
- package/src/lineage.ts +1655 -0
- package/src/retention.ts +118 -0
- package/src/rootsFor.ts +75 -0
- package/src/seen.ts +965 -0
- package/src/seenCrypto.ts +271 -0
- package/src/seenGc.ts +233 -0
- package/src/state.ts +268 -0
- 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
|
+
}
|