@loomwork/ui-lineage 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/README.md
CHANGED
|
@@ -1,3 +1,472 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loomwork/ui-lineage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Experience **lineage runtime**: what remembers which artifact of an experience is current, who
|
|
4
|
+
won a race to change it, and how to go back. `@loomwork/ui-core` can fold ops onto an artifact and hash
|
|
5
|
+
it; it cannot say which artifact is the head, refuse a stale author, undo, or notice that a stored
|
|
6
|
+
`ops` list disagrees with the artifact it claims to produce (ops are not in the content address).
|
|
7
|
+
This package is that memory. It is **optional** (an app using only core keeps working) and
|
|
8
|
+
**server-side** (an event log plus a content store: never import it from a client bundle;
|
|
9
|
+
`hydration/clientEntry.test.ts` names it among the packages a client graph must not reach).
|
|
10
|
+
|
|
11
|
+
## Install / import
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// package.json of a consumer checked out beside this repo (the packages are not published)
|
|
15
|
+
"@loomwork/ui-lineage": "link:../loom-ui/packages/lineage"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Entry point: `@loomwork/ui-lineage` (server-side only; Node, CommonJS and ESM).
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Lineage, InMemoryLineageStore, eventStoreLineageStore } from "@loomwork/ui-lineage";
|
|
24
|
+
|
|
25
|
+
const lineage = new Lineage({
|
|
26
|
+
events: eventStoreLineageStore(loomEventStore), // or new InMemoryLineageStore()
|
|
27
|
+
content: loomContentStore, // the ContentStore putExperience already uses
|
|
28
|
+
});
|
|
29
|
+
const v1 = await lineage.start(genesis(...), by); // first version
|
|
30
|
+
const v2 = await lineage.compose({ experienceId, expectedHead, ops, by });
|
|
31
|
+
const r = await lineage.revert({ experienceId, expectedHead, to, by }); // a NEW version
|
|
32
|
+
const f = await lineage.branch({ from: { experienceId, versionHash }, experienceId: "fork", by });
|
|
33
|
+
await lineage.verify(experienceId); // Finding[]; [] = consistent
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Bind the host's read policy once.** `new Lineage({ ..., readPolicy })` takes a core `ReadPolicy`
|
|
37
|
+
(`{ declaredToolClasses, projections, hydrationProjectionId, undeclared }`); `explain` applies it
|
|
38
|
+
with no per-call options (a per-call value may only ADD, never lower), and a `SeenLog` or
|
|
39
|
+
`LineageChangeFeed` built over the lineage inherits it (`lineage.readPolicy`). Build the table with
|
|
40
|
+
`declaredToolClassesFrom(capabilities)` from `@loomwork/ui-planner`, the same `CapabilityRef`s the
|
|
41
|
+
planner reads:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const lineage = new Lineage({ events, content, readPolicy: {
|
|
45
|
+
declaredToolClasses: declaredToolClassesFrom(capabilities),
|
|
46
|
+
undeclared: "deny", // optional: a tool the table omits is unreadable until declared
|
|
47
|
+
} });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Retention: which versions to keep (U17)
|
|
51
|
+
|
|
52
|
+
Every version stores its whole artifact, and a scanned lineage stream pins every one of them for
|
|
53
|
+
ever (loom's `sweepUnpinnedContent` treats a bare ref as load-bearing). `lineage.pins(policy,
|
|
54
|
+
extra?)` is the replacement root set: an async iterable of the content addresses a collector must
|
|
55
|
+
keep, to be passed as `roots` while the lineage's `LineageStore` is NOT passed as an `eventStore`.
|
|
56
|
+
`retainedVersions(state, policy, branchSources)` is the pure policy: the newest `keepLast` (default
|
|
57
|
+
20; `Infinity` keeps all), the head, the promoted version, the genesis (`keepGenesis`), `keepSince`,
|
|
58
|
+
a host `keep(version)` predicate, and always every version another line branched from. The artifact
|
|
59
|
+
ref of every other version is left out, so that blob becomes reclaimable; every other ref a line's
|
|
60
|
+
events cite (anything an op carries) stays pinned, and so does every content ref found INSIDE a
|
|
61
|
+
retained artifact's bytes (the sweep does not read blobs). It refuses (throws) over a store that
|
|
62
|
+
cannot enumerate its lines, a line that does not fold, or a retained artifact it cannot read.
|
|
63
|
+
**Cost.** `pins` reads each distinct retained artifact once per call (deduplicated by content
|
|
64
|
+
address across lines and versions; artifacts embed their parents and line, so distinct versions
|
|
65
|
+
seldom share one and the dedupe is a safety net, the real saving is concurrency), with at most
|
|
66
|
+
`extra.concurrency` (default 8) reads of one line in flight; a read error other than "already gone"
|
|
67
|
+
throws before that line yields. Reads are O(retained versions), at `concurrency` per line; a lower
|
|
68
|
+
`keepLast` shrinks it directly. A malformed policy THROWS (NaN, negative or fractional `keepLast`; a
|
|
69
|
+
`keepSince` that is not an ISO-8601 date/time string, a valid `Date` or epoch ms): a deletion policy
|
|
70
|
+
never coerces.
|
|
71
|
+
|
|
72
|
+
**Build `roots` with `rootsFor`, always.** `rootsFor({ lineages: [{ lineage, policy }, ...],
|
|
73
|
+
seenLogs?, allowUnreadable?, extra? })` returns the ONE async iterable of addresses loom's
|
|
74
|
+
`sweepUnpinnedContent({ roots: () => rootsFor(...), completeScan: true })` needs: every lineage's
|
|
75
|
+
`pins(policy)`, every seen log's sealed value blobs, the versions each seen log's views still read
|
|
76
|
+
(kept by the lineage that log is built over, which must be listed), and your `extra` (app tables;
|
|
77
|
+
sync or async iterable), deduplicated. It validates every policy and the wiring up front, and fails
|
|
78
|
+
closed: any source throwing makes the iterator throw before it yields anything, so a sweep never
|
|
79
|
+
erases against a partial set. Passing a single lineage's `pins()` to a store several lineages share
|
|
80
|
+
erases the others' artifacts; do not hand-compose it.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
await sweepUnpinnedContent({
|
|
84
|
+
/* ... */
|
|
85
|
+
roots: () => rootsFor({ lineages: [{ lineage: a, policy }, { lineage: b }], seenLogs: [seen] }),
|
|
86
|
+
reclaimOrphans: { completeScan: true },
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**The root set must be complete.** With `reclaimOrphans: { completeScan: true }` loom erases every
|
|
91
|
+
blob in the store that no root names, so `roots` must come from EVERY user of the store: this
|
|
92
|
+
lineage's `pins`, every other lineage's `pins`, a `SeenLog` sharing the store, and loom's own
|
|
93
|
+
execution scan. For a seen log, `collectSeenRoots` (which `rootsFor` calls) adds its sealed value
|
|
94
|
+
blobs and the versions its live views still read (it throws if anything in the live window was
|
|
95
|
+
unreadable, rather than return an incomplete set). Which collector: with loom's sweep owning the
|
|
96
|
+
store, use `pins` + `collectSeenRoots`; with ONE collector owning the store,
|
|
97
|
+
`collectSeenGarbage(log, { lineageRetention: policy, ... })` does both in one pass. Never run the
|
|
98
|
+
two against the same store with different roots.
|
|
99
|
+
|
|
100
|
+
**Races.** `promote`, `branch` and `SeenLog.record` cite an artifact that already exists, with no
|
|
101
|
+
`put`, so each `touch`es it first (`ContentStore.touch`, SPI 1.4.0; loom ADR 0055 D6 amendment 2): a
|
|
102
|
+
sweep whose roots were computed a moment earlier then refuses to delete it, including a fork made
|
|
103
|
+
between `pins`' two passes. A store without `touch` is left alone, and loom's sweep refuses to erase
|
|
104
|
+
through one, so use a store that implements it.
|
|
105
|
+
|
|
106
|
+
Known limit: reclaiming a version leaves its event in the log, so `get` of it reports the missing
|
|
107
|
+
artifact and `verify` of a line with reclaimed versions reports them (and what derives from them),
|
|
108
|
+
because `verify` replays from the genesis. Reclaim only versions nobody needs to re-open. Verifying
|
|
109
|
+
from a retained checkpoint (a tombstone event so the fold knows a version was reclaimed on purpose)
|
|
110
|
+
is the follow-up; it is loom-ui code and needs no loom change.
|
|
111
|
+
|
|
112
|
+
## Design
|
|
113
|
+
|
|
114
|
+
**Storage is two loom seams.** Artifact bytes go in a `ContentStore` (`putExperience`, unchanged).
|
|
115
|
+
Version and promotion events go in a `LineageStore`: an append-only, per-line, sequenced log whose
|
|
116
|
+
`append(line, expectedSequence, record)` is a compare-and-set on the tip. That is exactly what
|
|
117
|
+
loom's `EventStore` already guarantees (`(executionId, sequence)` uniqueness), so
|
|
118
|
+
`eventStoreLineageStore` adapts it (one reserved stream `__loom_ui_lineage__:<experienceId>` per
|
|
119
|
+
line; fencing, tenancy and sharding decorators wrap the store as usual). `InMemoryLineageStore` is
|
|
120
|
+
the reference, and `checkLineageStoreConformance(make)` is the kit any backend must pass (returns
|
|
121
|
+
findings, never throws).
|
|
122
|
+
|
|
123
|
+
**Head and compare-and-set.** A line's head is the latest version. Every write names the head it was
|
|
124
|
+
authored against (`expectedHead`; `null` = the line must be empty). A lost race is a *typed result*
|
|
125
|
+
(`{ kind: "head-moved", expectedHead, actualHead }`), never an overwrite or a throw; the caller
|
|
126
|
+
re-reads and re-authors. `compose` applies its first op with core's `applyAt`, so an op that
|
|
127
|
+
declares a `baseVersion` other than the head is refused (`stale-base-version`). A blob is written
|
|
128
|
+
before its event and is content-addressed, so a lost race leaves an unreferenced blob, never an
|
|
129
|
+
event without bytes.
|
|
130
|
+
|
|
131
|
+
**Events.** `EXPERIENCE_COMPOSED { ref, parents, ops }` (core's convention, now with a producer, a
|
|
132
|
+
consumer and a validator; plus `experienceId`, `versionHash`, `by`), `EXPERIENCE_REVERTED`,
|
|
133
|
+
`EXPERIENCE_BRANCHED`, and `EXPERIENCE_PROMOTED` (the version the app *shows*; its own CAS pointer,
|
|
134
|
+
`expectedPromoted`). `foldLineage` is pure and total and checks structure: dense sequences, one
|
|
135
|
+
version per address, every version's `parents` naming the head it was appended on.
|
|
136
|
+
|
|
137
|
+
**Verification closes the ops-vs-artifact gap.** `advance`/`compose` refuse (writing nothing) any
|
|
138
|
+
version whose recorded ops, replayed from the parent, do not reproduce the artifact's content
|
|
139
|
+
address (`ops-artifact-mismatch`), whose ops are refused by `apply` (a recorded `declassify*` needs
|
|
140
|
+
the host's per-call `allowDeclassify`), whose `baseVersion` is not the parent, or that records no
|
|
141
|
+
ops after the first version. `verify(experienceId)` is the consumer: it REBUILDS the whole line from
|
|
142
|
+
the log (never from the blobs: a composed version by replay on the rebuilt parent, a revert or
|
|
143
|
+
branch recomputed from the rebuilt artifacts it names) and compares addresses and stored blobs. The
|
|
144
|
+
genesis is attested by its address alone. A branch is only as sound as its source, so `verify` also
|
|
145
|
+
verifies the source line up to the version the fork carried classes from (recursively, bounded) and
|
|
146
|
+
reports its findings on the branch; `carriedFrom` must be the forked version or a descendant of it.
|
|
147
|
+
`verify` replays with `declassify*` allowed: it checks that addresses reproduce, and the write path
|
|
148
|
+
is the gate.
|
|
149
|
+
|
|
150
|
+
**Revert, undo, branch are new versions.** Nothing is rewritten. `revert` restores an older
|
|
151
|
+
artifact's content as a version whose parent is the head (and can itself be reverted: that is redo).
|
|
152
|
+
`undo` is a revert to the head's parent. `branch` forks a version into a NEW line (its first
|
|
153
|
+
version's parent is the source version, in another line). Revert and branch are deterministic
|
|
154
|
+
functions of two artifacts, which is what lets `verify` recompute them.
|
|
155
|
+
|
|
156
|
+
**Sticky classes are carried forward (fail-closed).** A revert or branch adopts an artifact older
|
|
157
|
+
than the line, and the line has learned more since. Both fold the classes of the artifact the line
|
|
158
|
+
had reached (the head, or the source head at the fork) into the adopted one with core's
|
|
159
|
+
`carryClasses`: `strictest`, never lower, and including classes only that artifact's *live* nodes
|
|
160
|
+
carried (they would vanish with the nodes). So a restored or forked version can be stricter than it
|
|
161
|
+
was, never laxer than the line knew; handle, tool and projection classes are all carried. A trusted
|
|
162
|
+
`declassify*` is an ordinary composed version; reverting past it re-raises the class (the safe
|
|
163
|
+
direction).
|
|
164
|
+
|
|
165
|
+
**Concurrent authors.** Two authors composing against the same head: one wins, the other gets
|
|
166
|
+
`head-moved` with the winner's hash. A promotion slipping between a writer's read and append retries
|
|
167
|
+
at the new tip (bounded) because the head did not move.
|
|
168
|
+
|
|
169
|
+
## Trust and operations
|
|
170
|
+
|
|
171
|
+
**The log is untrusted input.** Anyone with append access to a stream wrote what `foldLineage`
|
|
172
|
+
reads, so the fold is total: a record that is null, throws when read, records more than
|
|
173
|
+
`MAX_OPS_PER_VERSION` ops, or nests an op's node (or `prompt` ops) past the caps is a
|
|
174
|
+
`lineage-event-invalid` finding, never a stack overflow or an exception out of every read of that
|
|
175
|
+
line.
|
|
176
|
+
|
|
177
|
+
**Lineage performs NO authorization.** `by` (on a version and on each recorded operation) is an
|
|
178
|
+
unverified caller claim; nothing here checks that the caller may act as that principal or touch that
|
|
179
|
+
line. `start` accepts ANY genesis, so a host that lets callers supply artifacts can have classes
|
|
180
|
+
laundered by copying an artifact with its sticky class maps stripped into a new line. The host must
|
|
181
|
+
gate who may `start`/`advance`/`branch`/`promote` and what artifacts it passes. `allowDeclassify` is
|
|
182
|
+
a per-call argument of `advance`/`compose` (there is no instance-level default): pass it only from
|
|
183
|
+
code that authored the ops itself. Stored `meta` is derived from `by` and the clock (as a revert's
|
|
184
|
+
is), not taken from the artifact argument.
|
|
185
|
+
|
|
186
|
+
**Branching.** `branch` reads the source line and then writes the new one (no lock across the two):
|
|
187
|
+
a source that advances in between is fine, because the fork records the head it carried classes from
|
|
188
|
+
(`carriedFrom`). Classes the source learns AFTER the fork do not reach the branch, which is a
|
|
189
|
+
different line from then on.
|
|
190
|
+
|
|
191
|
+
**Reserved stream namespace.** `eventStoreLineageStore` writes `__loom_ui_lineage__:<experienceId>`,
|
|
192
|
+
inside loom's `RESERVED_STREAM_PREFIX` (`__loom_`), so enumerators of real executions skip lineage
|
|
193
|
+
streams (a prefix outside it would be mistaken for an execution). Streams are never compacted, so a
|
|
194
|
+
long-lived line grows without bound.
|
|
195
|
+
|
|
196
|
+
**Erasure (crypto-shred).** A line holds personal data: op props and labels, the `summary`, `by`.
|
|
197
|
+
A reserved stream is not an execution, so no default maps it to a person; the host does, and then
|
|
198
|
+
erasing the owner makes the line unreadable:
|
|
199
|
+
|
|
200
|
+
1. In the `subjectOf` you give loom's `EncryptingEventStore` (`@loomwork/plugin-crypto`), map the stream
|
|
201
|
+
id (`__loom_ui_lineage__:<experienceId>`, or your `streamPrefix`) to the owner's subject.
|
|
202
|
+
2. Call `registerLineageSensitivePaths(encryptingEventStore)` once at composition (the paths are
|
|
203
|
+
`LINEAGE_SENSITIVE_PATHS`: `ops`, `summary`, `by` on a composed event, `by` on the others;
|
|
204
|
+
identifiers, hashes and refs stay plain so the log still folds structurally).
|
|
205
|
+
3. Back the `Lineage` with an `EncryptingContentStore` over the SAME `KeyStore` and the SAME
|
|
206
|
+
`subjectOf`. The lineage passes every artifact `put`/`get` a `ContentAccess` of
|
|
207
|
+
`{ executionId: <the line's stream id>, workflowId: <the lineage workflow id> }`, so the one
|
|
208
|
+
mapping covers events and blobs. A blob's address is scoped to its subject: the same bytes in two
|
|
209
|
+
lines with different owners get different refs. Nothing here compares refs across lines (a version
|
|
210
|
+
is identified by `versionHash`).
|
|
211
|
+
4. Give the `Lineage` a `confirmErased` hook (below), so a destroyed key reads as an erasure and not
|
|
212
|
+
as an unreadable line.
|
|
213
|
+
5. Erase the owner the normal way: `app.eraseSubject`, or loom's `shredSubject` with ALL of the
|
|
214
|
+
owner's executions. The lineage stream needs nothing extra: it shares the owner's key, and a
|
|
215
|
+
reserved stream is never tombstoned. Pass `executionIds: []` ONLY for an owner with no executions
|
|
216
|
+
at all: a key destroyed that way leaves any execution sealed under it without a tombstone, and a
|
|
217
|
+
later shred can no longer write one (loom `plugins/crypto/README.md`, A6). Do not call
|
|
218
|
+
`keys.destroy` directly for an owner who has executions either.
|
|
219
|
+
|
|
220
|
+
**Erased versus unreadable.** loom reads "the key is not on this node" and "the key was destroyed"
|
|
221
|
+
the same way for a sealed event (`KeyUnavailableError`), and reports a sealed event that will not
|
|
222
|
+
open under a PRESENT key (a tampered row, a `subjectOf` that drifted) as `KeyUnavailableError` too, so
|
|
223
|
+
none of those can be told apart from the read alone. `isPayloadShredded` alone is not confirmation
|
|
224
|
+
either. So the package separates them with ONE rule: a line is `line-erased` only when the host says so,
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
new Lineage({
|
|
228
|
+
events, content,
|
|
229
|
+
// the KeyStore recorded the destruction of this line's owner key
|
|
230
|
+
confirmErased: async (experienceId) =>
|
|
231
|
+
(await keys.wasDestroyed?.(subjectOf({ executionId: `__loom_ui_lineage__:${experienceId}` }))) === true,
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
and every other read that hits `isKeyUnavailable` / `isPayloadShredded` is **`line-unreadable`** ("the
|
|
236
|
+
line's key is unavailable on this node or its ciphertext does not open; not confirmed erased"). A
|
|
237
|
+
blob that exists but does not open although the key exists (plaintext written before sealing was
|
|
238
|
+
composed, or damaged bytes: `ContentUndecryptableError`) is `line-unreadable` with the distinct
|
|
239
|
+
finding code `artifact-unsealed`, and is never an erasure. A throwing hook counts as "not confirmed".
|
|
240
|
+
Both outcomes are fail-closed, and writes are refused either way. `LineErasedError` (confirmed) and
|
|
241
|
+
`LineUnreadableError` (`confirmed: false`, `reason`) are the two error classes of the plain-value reads.
|
|
242
|
+
`@loomwork/ui-changes` inherits the hook for its `ChangeFeed` (it classifies through `lineage.block`); a
|
|
243
|
+
head signal is built over a store, so it takes its own `confirmErased` option.
|
|
244
|
+
|
|
245
|
+
Afterwards a read of that line refuses with `{ kind: "refused", finding: { code: "line-erased" } }`
|
|
246
|
+
(or `line-unreadable`): `state`, `head`, `versions`, `walk`, `get`, `getMany`, `resolveLatest`,
|
|
247
|
+
`resolvePromoted`, `provenance`, `explain` and `cite`; `verify` returns a single such finding. A
|
|
248
|
+
write (`start`, `advance`, `compose`, `revert`, `undo`, `promote`, `branch`) on such a line refuses
|
|
249
|
+
with the same code; the owner's key is never re-minted. A key destroyed between the read and the
|
|
250
|
+
append is re-checked on the append's failure and returns the typed refusal. Recognition is by
|
|
251
|
+
`isKeyUnavailable` / `isPayloadShredded` (`@loomwork/event-runtime`), never by message text.
|
|
252
|
+
|
|
253
|
+
**GC.** The plain-value reads (`forEachContentRef`, `pins`, so `rootsFor` and `collectSeenGarbage`)
|
|
254
|
+
SKIP a line whose erasure is confirmed: it contributes no roots, so its sealed blobs become
|
|
255
|
+
reclaimable, which is the point of erasure. An unconfirmed `line-unreadable` line makes them throw
|
|
256
|
+
`LineUnreadableError` (and `collectSeenGarbage` return `gc-roots-unavailable`): guessing could delete
|
|
257
|
+
live blobs. Without a `confirmErased` hook GC therefore stays refused after any erasure.
|
|
258
|
+
|
|
259
|
+
Erasure is scoped to the owner. A branch reads its source with the SOURCE line's access and writes its
|
|
260
|
+
own first artifact under the TARGET line's, so a line branched from an erased line keeps reading. A
|
|
261
|
+
branch COPIES the source's content into the target line under the TARGET owner's key: it is NOT
|
|
262
|
+
erased with the source owner (erase the target's owner too if it must go). Its `verify` can no longer
|
|
263
|
+
recompute the fork from the erased source, so it attests that first version by its content address
|
|
264
|
+
alone and reports a `branch-source-erased` WARNING, only when the source's erasure is confirmed (also
|
|
265
|
+
when the host deleted the source's events too); an unconfirmed unreadable source is an ERROR finding
|
|
266
|
+
(`line-unreadable`). Versions after it verify normally.
|
|
267
|
+
|
|
268
|
+
**What stays plaintext after erasure.** Only `ops`, `summary` and `by` are sealed. `versionHash`,
|
|
269
|
+
`parents`, `revertedTo`, `carriedFrom`, `from` and `experienceId` stay plain so the log folds, and
|
|
270
|
+
`versionHash` is a plain SHA-256 of the artifact: after erasure a guessed artifact can be CONFIRMED
|
|
271
|
+
against it. Do not put personal data in `experienceId`, `streamPrefix` or `workflowId`.
|
|
272
|
+
|
|
273
|
+
**Seen records are separate.** `SeenLog` calls `content.put` WITHOUT a `ContentAccess`, so it cannot
|
|
274
|
+
share a sealing `EncryptingContentStore` and needs its own store. Erasing a line's owner does not
|
|
275
|
+
erase viewers' seen records of that line: those have their own per-viewer erasure (`eraseViewer`), and
|
|
276
|
+
`SeenLog.read` on an erased line returns `restricted: true` with no entries.
|
|
277
|
+
|
|
278
|
+
**Enabling sealing on an existing deployment.** What was written before an `EncryptingContentStore`
|
|
279
|
+
(or `registerLineageSensitivePaths`) was composed stays plaintext, and reads through a sealing store
|
|
280
|
+
as `line-unreadable` / `artifact-unsealed`, never as erased. There is no `reseal` helper: a version's
|
|
281
|
+
event records the artifact's ref, and an append-only log cannot be rewritten to a new ref without
|
|
282
|
+
new events (a new version, which changes the line's history). Seal from the first write: start a new
|
|
283
|
+
store and re-create the lines, or keep a plain store for the old lines.
|
|
284
|
+
|
|
285
|
+
A `Lineage` fails closed at construction when its content store declares `sealsContent: true` but its
|
|
286
|
+
events store does not expose a `streamPrefix` (use `eventStoreLineageStore`): the blobs could
|
|
287
|
+
otherwise be sealed under a different subject than the events.
|
|
288
|
+
|
|
289
|
+
A `describe` of Bob's branched event whose BASE is an erased line refuses (the base cannot be read);
|
|
290
|
+
that edge is a known limitation.
|
|
291
|
+
|
|
292
|
+
Genericity: nothing here names a consumer.
|
|
293
|
+
|
|
294
|
+
## Change delivery
|
|
295
|
+
|
|
296
|
+
`compose({ ..., summary })` records what the author said about a change (a planner's
|
|
297
|
+
`PlanResult.summary`) on the `EXPERIENCE_COMPOSED` event: untrusted model text, bounded and stripped
|
|
298
|
+
(`boundSummary`) on the way in and again when the fold reads it. It is not part of the content
|
|
299
|
+
address. `VersionRecord.summary` carries it. The feed, the unread cursor and the head signals that
|
|
300
|
+
consume the log live in [`@loomwork/ui-changes`](../changes/README.md).
|
|
301
|
+
|
|
302
|
+
## Provenance and what a viewer saw (U12)
|
|
303
|
+
|
|
304
|
+
`lineage.provenance(experienceId, versionHash?)` is the per-node ledger of a version (see core's
|
|
305
|
+
`foldProvenance`), derived from the recorded ops alone: no artifact is read and nothing is stored. A
|
|
306
|
+
revert has the ledger of the version it restores; a branch starts from its source version's (depth
|
|
307
|
+
bounded; beyond it the origin is unknown and the ledger says it was truncated). `lineage.explain(
|
|
308
|
+
experienceId, nodeId, { viewer, audience?, versionHash?, projections? })` is core's `explainNode` over that
|
|
309
|
+
ledger, plus the notes the line holds: the creating version's `summary` / `summaryClass`, with the
|
|
310
|
+
creating version's own class floor added (a class can be lowered between versions; a version that
|
|
311
|
+
cannot be read counts as `secret`). Stamp the planner's decision on the ops with
|
|
312
|
+
`@loomwork/ui-planner`'s `plannerProvenance(result)` so a node links back to the recorded model call.
|
|
313
|
+
|
|
314
|
+
Every `by` a write records (the write's own and each op's) is checked against core's `Provenance`
|
|
315
|
+
before the append and refused `provenance-invalid` (any other payload the fold would refuse is
|
|
316
|
+
`event-payload-invalid`): the fold stays strict, and the log has no delete, so the refusal is on the
|
|
317
|
+
write side. `plannerProvenance` omits a decision id outside 1-256 characters rather than stamp one.
|
|
318
|
+
`explain` resolves the creating version across lines (a branch's ledger starts from its source's,
|
|
319
|
+
bounded), and a writer it cannot find counts as `secret`; it asks the viewer-safe question first, so
|
|
320
|
+
a hidden node and an unknown id cost the same reads. `source.lastChanged` names the latest op that
|
|
321
|
+
bound, remapped or declassified a provider, so a viewer cleared for the node can learn that a
|
|
322
|
+
declassify happened (not what the class was): accepted, it is the audit trail.
|
|
323
|
+
|
|
324
|
+
**`SeenLog` records what a viewer was shown.** (`new SeenLog({ ..., readPolicy })` binds a read
|
|
325
|
+
policy for `record` and `read`, joined with the lineage's.) `record({ experienceId, viewerId,
|
|
326
|
+
snapshot, viewer })` takes a core `snapshotSeen` snapshot, does not trust it (the version must be on
|
|
327
|
+
the line; entries are re-validated, restamped against that version's artifact, dropped when the
|
|
328
|
+
recording viewer may not see them, and re-capped), writes the value bytes to the `ContentStore`
|
|
329
|
+
(content-addressed) and appends a manifest (`VIEW_RECORDED`: the version, per source a class stamp,
|
|
330
|
+
state, status and a SEALED content ref) to a per-(experience, viewer) stream. `read({ ..., reader
|
|
331
|
+
})` returns the views newest first (`until` = as of a time), redacted for the READER before any blob
|
|
332
|
+
is fetched. An identical consecutive view is not re-recorded.
|
|
333
|
+
|
|
334
|
+
**A broadcast surface needs its own `viewerId`.** Seen records are keyed by `(experienceId,
|
|
335
|
+
viewerId)` and carry no audience marker, so a wallboard recorded under a person's id would
|
|
336
|
+
interleave with that person's personal views (and `read` would show them as one history). Give a
|
|
337
|
+
`"broadcast"` surface a `viewerId` of its own (for example `wallboard:<surface>`), and pass the
|
|
338
|
+
compile result's `viewer` (the no-clearance effective viewer) as `viewer`
|
|
339
|
+
(or `audience: "broadcast"`, which `record` and `read` take to act as a viewer with no clearance).
|
|
340
|
+
|
|
341
|
+
*Why a separate log, and not the lineage stream or the hydration store.* A lineage line is the
|
|
342
|
+
shared history of an experience with one compare-and-set tip that every writer contends for and
|
|
343
|
+
every reader (the change feed, fanned out to every viewer) folds; a view is per-viewer, frequent and
|
|
344
|
+
carries data, so recording it there would put every open of a surface in a race with every writer,
|
|
345
|
+
mix one viewer's classified values into a log all readers fold, and change what `verify` of the line
|
|
346
|
+
must explain. The hydration store is a latest-value cache for the surface being drawn (evicts,
|
|
347
|
+
projected to a client, no history): it cannot say what a viewer saw on Tuesday.
|
|
348
|
+
|
|
349
|
+
*Erasure is crypto-shredding.* A plain SHA-256 in an append-only log confirms any low-entropy value
|
|
350
|
+
("did this viewer see `42`?" is one hash away) and the log has no delete, so each entry's content
|
|
351
|
+
ref is stored ENCRYPTED (AES-256-GCM, node:crypto) under a per-(experience, viewer) data key, and
|
|
352
|
+
the manifest digest is an HMAC under that key, not a hash. The value bytes stay in the
|
|
353
|
+
`ContentStore` under their plain content address (identical values dedupe across viewers and with
|
|
354
|
+
the lineage). The key lives in a host-supplied `SeenKeyStore` (`getOrCreate` / `get` / `destroy`;
|
|
355
|
+
`InMemorySeenKeyStore` is the non-durable reference, `checkSeenKeyStoreConformance` the kit; a loom
|
|
356
|
+
`@loomwork/plugin-crypto` `KeyStore` satisfies it structurally, so a KMS-backed one can be passed as
|
|
357
|
+
is). `eraseViewer({ experienceId, viewerId })` destroys the key and then appends a `VIEWER_ERASED`
|
|
358
|
+
tombstone (idempotent; a rerun completes a half-done erasure): afterwards `read` returns `{ erased:
|
|
359
|
+
true, views: [] }`, `record` refuses (`seen-viewer-erased`), and nothing left in the stream is
|
|
360
|
+
decryptable. The ciphertext is bound as associated data to (experience, viewer, record sequence,
|
|
361
|
+
entry index), so a ref cannot be moved between records, entries or viewers (even under one shared
|
|
362
|
+
key); a ref that fails to open (tampered, transplanted, wrong key) is withheld (`restricted`), never
|
|
363
|
+
read as plaintext. A missing or failing key store refuses a `record` and a `read`
|
|
364
|
+
(`seen-key-unavailable`): fail closed. Why the key lives here and not in `shredSubject`: loom's
|
|
365
|
+
shredder refuses the reserved `__loom_*` streams (a logged loom follow-up), and a seen log has to
|
|
366
|
+
live in one; `encryptValue` has no associated data, which this needs, so the primitive is local (the
|
|
367
|
+
`KeyStore` SHAPE is reused). The sealed payload is `{ref, bytes}` padded to a constant length, so a
|
|
368
|
+
`seen` entry's value size is hidden too (neither a field nor the ciphertext length separates `true`
|
|
369
|
+
from `false` from a short enum). What an erased stream STILL reveals: that the viewer opened the
|
|
370
|
+
experience; the sources each view named (handles / projection ids and paths), node ids, class
|
|
371
|
+
stamps, delivery statuses, the version hash and record times; and, for an `oversize` / `over-budget`
|
|
372
|
+
entry only, a power-of-two size class of its measured size (never the exact byte count: there the
|
|
373
|
+
size class IS the recorded fact). It reveals no value, no address and no size of a recorded value. A
|
|
374
|
+
value blob stays readable in the `ContentStore` until the collector below reclaims it.
|
|
375
|
+
`SeenKeyStore` contract: after `destroy` NO layer (including a warm cache) may serve the key;
|
|
376
|
+
create-if-absent is atomic (concurrent `getOrCreate` of one subject return one key); `record`
|
|
377
|
+
re-reads the key just before it appends and refuses (`seen-viewer-erased`) if it is gone.
|
|
378
|
+
`eraseViewer` performs no authorization (erasing a pair that never existed writes a tombstone), so
|
|
379
|
+
the host decides who may erase what. `eraseViewerEverywhere({ viewerId })` is the per-data-subject
|
|
380
|
+
form: it enumerates the seen streams (`listStreams`; refused with `seen-enumeration-unavailable`
|
|
381
|
+
without it) and erases that viewer in every experience, returning per-experience results (`complete`
|
|
382
|
+
only when all succeeded; idempotent and resumable). Each failed result's `refusal` is a lineage
|
|
383
|
+
`Refused`, so it now also serializes `kind` and `finding` keys beside the original fields: additive,
|
|
384
|
+
nothing removed. Coverage is the streams that exist when enumeration runs: a stream created
|
|
385
|
+
afterwards (a new view during the erase) is not covered, so the host serializes erasure against new
|
|
386
|
+
views for that subject or reruns; an orphan key with no stream (crash between `getOrCreate` and the
|
|
387
|
+
first append) is not destroyed, and holds no data.
|
|
388
|
+
|
|
389
|
+
*Reclaiming blobs: `collectSeenGarbage(log, { graceMs, ownsContentStore: true })`.* MARK-AND-SWEEP,
|
|
390
|
+
not a refcount (there is no transaction across the event log and the `ContentStore`, so a counter
|
|
391
|
+
drifts and a crash between "append" and "increment" would delete a live value). MARK: every content
|
|
392
|
+
address the lineage cites (any `__loomContent` in any event of any line, via
|
|
393
|
+
`Lineage.forEachContentRef`) plus every DECRYPTABLE ref inside every viewer's retention window
|
|
394
|
+
(`SeenLog.forEachLiveRef`) plus the host's `extraRoots`. SWEEP: page the store (`listPage`) and
|
|
395
|
+
delete every unmarked blob with the store's atomic `deleteIfUntouchedSince(ref, cutoff)`, `cutoff =
|
|
396
|
+
touchClock() at mark start - graceMs`. A blob touched at or after the cutoff (written during the
|
|
397
|
+
pass, or younger than `graceMs`, including a dedup'd re-put) is never swept: that is what protects a
|
|
398
|
+
value whose manifest is not appended yet; `graceMs` must exceed the longest a `record()` can take
|
|
399
|
+
between putting a blob and appending its manifest. A crash in that gap leaks the blob until the
|
|
400
|
+
first pass past grace and never loses a cited value. Erased viewers and rolled-off views mark
|
|
401
|
+
nothing, so their unique blobs are swept while a blob another viewer or the lineage still cites
|
|
402
|
+
survives. A pass is idempotent and safe to rerun after a crash. It FAILS CLOSED before deleting
|
|
403
|
+
anything if the root set cannot be established (a `LineageStore` without `listStreams`, a key-store
|
|
404
|
+
failure, ANYTHING UNREADABLE in a live window: a stream key that does not round-trip, a record that
|
|
405
|
+
fails to parse (a newer writer's shape during a rolling upgrade), a sealed ref that fails to open
|
|
406
|
+
under a PRESENT key: refusal `gc-unreadable-record` with counts, overridden only by
|
|
407
|
+
`allowUnreadable: true`, which means "sweep what those cite"; an ABSENT key is an erased viewer, not
|
|
408
|
+
unreadable, a `ContentStore` without the SPI 1.3.0 set `listPage` + `deleteIfUntouchedSince` +
|
|
409
|
+
`touchClock`, more than `maxMarked` live addresses). Memory: one string per distinct live blob
|
|
410
|
+
(`maxMarked`, default 5M, refuses rather than grow), one stream at a time, pages of `pageSize`.
|
|
411
|
+
`ownsContentStore: true` is an assertion you make: the sweep deletes what it cannot root, so the
|
|
412
|
+
store must hold nothing the lineage, the seen logs and `extraRoots` do not name. No loom or
|
|
413
|
+
`ContentStore` SPI change was needed: SPI 1.3.0 already carries the touch time.
|
|
414
|
+
|
|
415
|
+
*Retention rolls off, it does not refuse.* `retention: { maxViews = 10_000, maxAgeMs?, maxSkewMs =
|
|
416
|
+
300_000 }`. Reads apply `maxAgeMs` exactly; the two irreversible age actions (a persisted watermark,
|
|
417
|
+
the collector) are conservative by `maxSkewMs`, so a writer whose clock runs ahead by up to that
|
|
418
|
+
much cannot roll off or sweep a view still inside the window by true time. With out-of-order
|
|
419
|
+
timestamps within `maxSkewMs` at the `maxViews` cap, the conservative window can differ from the
|
|
420
|
+
exact window's newest-N, in the direction of keeping MORE. A new view ALWAYS records. The log is
|
|
421
|
+
append-only, so roll-off is a watermark: each view carries the `floor` in force when written and a
|
|
422
|
+
`VIEWS_RETAINED` record raises it (about once per `maxViews/10` roll-offs); `read` and the collector
|
|
423
|
+
apply the window themselves (floor, age, newest `maxViews`), so correctness never depends on a
|
|
424
|
+
watermark having been written. Records at or below the floor are neither read nor marked, so their
|
|
425
|
+
blobs are reclaimed by the collector. Cost: `record` reads the LAST record only (tip, floor and the
|
|
426
|
+
last digest all ride on it) plus the window when a watermark is due; `read` and the collector read
|
|
427
|
+
`readAfter(floor)`, i.e. the window, never the log's lifetime. That needs the optional
|
|
428
|
+
`LineageStore.readTail` / `readAfter` (both reference stores and `eventStoreLineageStore` implement
|
|
429
|
+
them; a store without them falls back to a full `read`, O(log) per call). The rolled-off records
|
|
430
|
+
stay in the raw log as ciphertext (destroyed with the viewer's key).
|
|
431
|
+
|
|
432
|
+
Operations: the stream lives in its own reserved namespace (`eventStoreLineageStore(store,
|
|
433
|
+
seenStoreOptions)` writes `__loom_ui_seen__:<experience>/<viewer>`), so the notes on reserved
|
|
434
|
+
streams above apply (loom's `shredSubject` refuses them, which is why the seen log carries its own
|
|
435
|
+
key store). `LineageStore` gained three OPTIONAL methods: `readTail`, `readAfter` and `listStreams`
|
|
436
|
+
(the collector's enumeration; `eventStoreLineageStore` implements it over the SPI's
|
|
437
|
+
`listExecutionsPage` / `listExecutions`, which DO surface reserved streams, found by prefix).
|
|
438
|
+
`viewerId` and `viewer` are the host's claims: this package authorizes nothing.
|
|
439
|
+
|
|
440
|
+
Reference example (for `loom-examples`, not generic to one app): record views for two viewers who
|
|
441
|
+
saw the same value, erase one (`eraseViewer`), show the raw stream holds no readable address, show
|
|
442
|
+
`read` says `erased` while the other viewer's identical value still reads, run `collectSeenGarbage`
|
|
443
|
+
and show the erased viewer's unique blob is swept while the shared one and the lineage's artifacts
|
|
444
|
+
survive, then roll a viewer past `maxViews` and show the oldest views fall out of `read` and the
|
|
445
|
+
next sweep reclaims their blobs.
|
|
446
|
+
|
|
447
|
+
Seen-record details. An entry is judged by what its nodes actually bind: every node it lists must
|
|
448
|
+
bind exactly its `source`, an entry with no nodes is withheld, and `record` refuses a snapshot that
|
|
449
|
+
names one source twice (`seen-duplicate-source`); a read carries each value ref by the entry's
|
|
450
|
+
position in the record, never by source. A read hashes the bytes the content store returns and
|
|
451
|
+
compares them to the address in the manifest; a mismatch is state `corrupt` (no value). Trust
|
|
452
|
+
boundary: a manifest ref points at whatever the store holds under that address, so write access to
|
|
453
|
+
the `ContentStore` (or the seen stream) is write access to what a viewer is later shown, exactly as
|
|
454
|
+
for the lineage's artifacts; this package does not prove a blob was written by the stream that names
|
|
455
|
+
it. `record` refuses a snapshot or record that a read could not parse (`seen-snapshot-invalid`,
|
|
456
|
+
`seen-record-invalid`) rather than append a permanently malformed one. A `read` folds the lineage
|
|
457
|
+
once for all the versions it touches; `record` folds it once (O(line length) per write). An
|
|
458
|
+
identical consecutive view is not re-recorded, so the kept record carries the FIRST view's `at`. The
|
|
459
|
+
seen stream's prefix must not overlap the lineage's (`Lineage.sharesStreamsWith` compares both ways,
|
|
460
|
+
`a.startsWith(b) || b.startsWith(a)`, and `SeenLog` throws on a shared store or an overlapping
|
|
461
|
+
prefix). The vetted store must expose a readable `streamPrefix` (or be an `InMemoryLineageStore`): a
|
|
462
|
+
decorator that HIDES it cannot be told apart from a colliding store, so it is refused; re-expose
|
|
463
|
+
`streamPrefix` on a legitimate wrapper. If the LINEAGE's own store is such a wrapper its prefix is
|
|
464
|
+
assumed to be the default. And `eventStoreLineageStore` throws for a `streamPrefix` not under
|
|
465
|
+
`__loom_ui_` or a malformed `workflowId`.
|
|
466
|
+
|
|
467
|
+
## See also
|
|
468
|
+
|
|
469
|
+
- [../core/README.md](../core/README.md) — the Experience Model and `./log` persistence this package
|
|
470
|
+
versions.
|
|
471
|
+
- [../changes/README.md](../changes/README.md) — change delivery over the lineage events.
|
|
472
|
+
- [../../README.md](../../README.md) — root overview.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../src/conformance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAc5C,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,GAC/C,OAAO,CAAC,OAAO,EAAE,CAAC,CAkIpB"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkLineageStoreConformance = checkLineageStoreConformance;
|
|
4
|
+
// ── The LineageStore conformance kit ──────────────────────────────────────────
|
|
5
|
+
// Returns findings (a test asserts the list is empty), never throws: a store that throws on a case
|
|
6
|
+
// is reported as a finding, like `checkPlannerConformance`. Each case uses a fresh line id, so one
|
|
7
|
+
// `make()` store serves them all — but a backend that cannot isolate lines is exactly what the
|
|
8
|
+
// "lines are independent" case catches.
|
|
9
|
+
const rec = (n) => ({
|
|
10
|
+
type: "EXPERIENCE_COMPOSED",
|
|
11
|
+
payload: { n },
|
|
12
|
+
at: "2026-10-06T00:00:00.000Z",
|
|
13
|
+
});
|
|
14
|
+
async function checkLineageStoreConformance(make) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
const fail = (code, message) => findings.push({ code, severity: "error", message });
|
|
17
|
+
const store = await make();
|
|
18
|
+
const cases = [
|
|
19
|
+
[
|
|
20
|
+
"an unknown line reads empty",
|
|
21
|
+
async () => {
|
|
22
|
+
const r = await store.read("c-empty");
|
|
23
|
+
if (r.length !== 0)
|
|
24
|
+
fail("read-unknown", `an unknown line read ${r.length} records`);
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
"append at tip 0 wins sequence 1; the sequence is dense",
|
|
29
|
+
async () => {
|
|
30
|
+
const a = await store.append("c-seq", 0, rec(1));
|
|
31
|
+
const b = await store.append("c-seq", 1, rec(2));
|
|
32
|
+
if (!a.ok || a.sequence !== 1 || !b.ok || b.sequence !== 2)
|
|
33
|
+
fail("append-sequence", `sequential appends returned ${JSON.stringify([a, b])}`);
|
|
34
|
+
const r = await store.read("c-seq");
|
|
35
|
+
if (r.map((x) => x.sequence).join() !== "1,2")
|
|
36
|
+
fail("read-order", `read returned sequences ${r.map((x) => x.sequence).join()}`);
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
"a stale expectedSequence is refused with the real tip and writes nothing",
|
|
41
|
+
async () => {
|
|
42
|
+
await store.append("c-stale", 0, rec(1));
|
|
43
|
+
const s = await store.append("c-stale", 0, rec(2));
|
|
44
|
+
if (s.ok || s.tip !== 1)
|
|
45
|
+
fail("stale-append", `a stale append returned ${JSON.stringify(s)}`);
|
|
46
|
+
const ahead = await store.append("c-stale", 5, rec(3));
|
|
47
|
+
if (ahead.ok || ahead.tip !== 1)
|
|
48
|
+
fail("gap-append", `an append past the tip (a gap) returned ${JSON.stringify(ahead)}`);
|
|
49
|
+
const r = await store.read("c-stale");
|
|
50
|
+
if (r.length !== 1)
|
|
51
|
+
fail("refused-wrote", `a refused append left ${r.length} records`);
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"of N concurrent appends at one tip exactly one wins",
|
|
56
|
+
async () => {
|
|
57
|
+
const outs = await Promise.all(Array.from({ length: 8 }, (_, i) => store.append("c-race", 0, rec(i))));
|
|
58
|
+
const wins = outs.filter((o) => o.ok).length;
|
|
59
|
+
if (wins !== 1)
|
|
60
|
+
fail("race", `${wins} of 8 concurrent appends at the same tip won`);
|
|
61
|
+
const r = await store.read("c-race");
|
|
62
|
+
if (r.length !== 1)
|
|
63
|
+
fail("race-log", `the raced line holds ${r.length} records`);
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"lines are independent",
|
|
68
|
+
async () => {
|
|
69
|
+
await store.append("c-iso-a", 0, rec(1));
|
|
70
|
+
const b = await store.append("c-iso-b", 0, rec(2));
|
|
71
|
+
if (!b.ok)
|
|
72
|
+
fail("isolation", "an append to a second line was refused by the first line's tip");
|
|
73
|
+
if ((await store.read("c-iso-a")).length !== 1)
|
|
74
|
+
fail("isolation", "a line holds another's record");
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"payloads round-trip and neither side aliases the log",
|
|
79
|
+
async () => {
|
|
80
|
+
const payload = { nested: { list: [1, 2, { k: "v" }] } };
|
|
81
|
+
await store.append("c-copy", 0, {
|
|
82
|
+
type: "EXPERIENCE_COMPOSED",
|
|
83
|
+
payload,
|
|
84
|
+
at: "2026-10-06T00:00:00.000Z",
|
|
85
|
+
});
|
|
86
|
+
payload.nested.list.push(3);
|
|
87
|
+
const first = await store.read("c-copy");
|
|
88
|
+
if (JSON.stringify(first[0]?.payload) !==
|
|
89
|
+
JSON.stringify({ nested: { list: [1, 2, { k: "v" }] } }))
|
|
90
|
+
fail("copy-in", "mutating a payload after append changed the stored record");
|
|
91
|
+
first[0]?.payload.nested.list.push(9);
|
|
92
|
+
const second = await store.read("c-copy");
|
|
93
|
+
if (JSON.stringify(second[0]?.payload).includes("9"))
|
|
94
|
+
fail("copy-out", "mutating a read result changed the stored record");
|
|
95
|
+
if (second[0]?.type !== "EXPERIENCE_COMPOSED" ||
|
|
96
|
+
second[0]?.at !== "2026-10-06T00:00:00.000Z")
|
|
97
|
+
fail("fields", "type or at did not round-trip");
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
"optional readTail / readAfter / listStreams agree with read (when implemented)",
|
|
102
|
+
async () => {
|
|
103
|
+
for (let i = 0; i < 5; i++)
|
|
104
|
+
await store.append("c-opt", i, rec(i));
|
|
105
|
+
const all = await store.read("c-opt");
|
|
106
|
+
const seqs = (r) => r.map((x) => x.sequence).join();
|
|
107
|
+
if (store.readTail !== undefined) {
|
|
108
|
+
if (seqs(await store.readTail("c-opt", 2)) !== "4,5")
|
|
109
|
+
fail("read-tail", "readTail(2) did not return the newest two records in order");
|
|
110
|
+
if (seqs(await store.readTail("c-opt", 99)) !== seqs(all))
|
|
111
|
+
fail("read-tail", "readTail past the length did not return the whole line");
|
|
112
|
+
if ((await store.readTail("c-opt-none", 3)).length !== 0)
|
|
113
|
+
fail("read-tail", "readTail of an unknown line was not empty");
|
|
114
|
+
}
|
|
115
|
+
if (store.readAfter !== undefined) {
|
|
116
|
+
if (seqs(await store.readAfter("c-opt", 3)) !== "4,5")
|
|
117
|
+
fail("read-after", "readAfter(3) did not return the records past sequence 3");
|
|
118
|
+
if (seqs(await store.readAfter("c-opt", 0, 2)) !== "1,2")
|
|
119
|
+
fail("read-after", "readAfter(0, 2) did not honour the limit");
|
|
120
|
+
if ((await store.readAfter("c-opt", 5)).length !== 0)
|
|
121
|
+
fail("read-after", "readAfter at the tip was not empty");
|
|
122
|
+
}
|
|
123
|
+
if (store.listStreams !== undefined) {
|
|
124
|
+
const ids = [];
|
|
125
|
+
for await (const id of store.listStreams())
|
|
126
|
+
ids.push(id);
|
|
127
|
+
if (!ids.includes("c-opt") || new Set(ids).size !== ids.length)
|
|
128
|
+
fail("list-streams", "listStreams missed a line or listed one twice");
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
];
|
|
133
|
+
for (const [name, run] of cases) {
|
|
134
|
+
try {
|
|
135
|
+
await run();
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
fail("store-threw", `${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=conformance.js.map
|