@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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
import type { Finding } from "@loomwork/ui-core";
|
|
3
|
+
|
|
4
|
+
// ── Per-viewer data keys for the record of what a viewer saw (U12) ─────────────────────────────
|
|
5
|
+
// WHY THIS IS HERE AND NOT `@loomwork/plugin-crypto`'s `shredSubject`: loom's shredder refuses the
|
|
6
|
+
// reserved `__loom_*` streams (a logged loom follow-up), and a seen log has to live in one. What IS
|
|
7
|
+
// reused is the SHAPE of loom's `KeyStore` (getOrCreate / get / destroy, destroy = the erasure
|
|
8
|
+
// primitive, never resurrected): a host's existing KMS-backed `KeyStore` satisfies `SeenKeyStore`
|
|
9
|
+
// structurally and can be passed as is. What is NOT reused is `encryptValue`: it has no associated
|
|
10
|
+
// data, and this design needs the ciphertext bound to (experience, viewer, record, entry) so a ref
|
|
11
|
+
// cannot be transplanted. So the primitive (AES-256-GCM, node:crypto, as loom's) is local.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Key material for one (experience, viewer) subject. Destroying a key is the erasure primitive, so a
|
|
15
|
+
* production store must really delete it (KMS/HSM), and `durable: false` stores lose every viewer's
|
|
16
|
+
* record on restart (indistinguishable from erasure).
|
|
17
|
+
*/
|
|
18
|
+
export interface SeenKeyStore {
|
|
19
|
+
/**
|
|
20
|
+
* The subject's 32-byte key, created on first use. MUST reject for a destroyed subject (never
|
|
21
|
+
* resurrect). Create-if-absent MUST be ATOMIC: two concurrent calls for one new subject return the
|
|
22
|
+
* SAME key (a durable implementation needs a conditional insert / compare-and-set, not
|
|
23
|
+
* read-then-write), or one writer's records are sealed under a key that is then lost.
|
|
24
|
+
*/
|
|
25
|
+
getOrCreate(subjectId: string): Promise<Uint8Array>;
|
|
26
|
+
/**
|
|
27
|
+
* The key, or `null` if it never existed OR was destroyed. AFTER `destroy` an implementation MUST
|
|
28
|
+
* NOT serve the key from ANY layer, including an in-process cache warmed by an earlier `get`: a
|
|
29
|
+
* cache that outlives `destroy` makes erasure a lie on that process. (`SeenLog.record` re-reads
|
|
30
|
+
* the key just before it appends, so a destroyed key is observed there.)
|
|
31
|
+
*/
|
|
32
|
+
get(subjectId: string): Promise<Uint8Array | null>;
|
|
33
|
+
/** Irreversibly destroy the key. Idempotent; a no-op for an absent subject (which stays destroyed). */
|
|
34
|
+
destroy(subjectId: string): Promise<void>;
|
|
35
|
+
readonly durable?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reference in-memory key store (tests / ephemeral apps): NOT durable. */
|
|
39
|
+
export class InMemorySeenKeyStore implements SeenKeyStore {
|
|
40
|
+
readonly durable = false;
|
|
41
|
+
private readonly keys = new Map<string, Uint8Array>();
|
|
42
|
+
private readonly destroyed = new Set<string>();
|
|
43
|
+
|
|
44
|
+
async getOrCreate(subjectId: string): Promise<Uint8Array> {
|
|
45
|
+
if (this.destroyed.has(subjectId))
|
|
46
|
+
throw new Error(`the key for "${subjectId}" was destroyed; it cannot be recreated`);
|
|
47
|
+
let key = this.keys.get(subjectId);
|
|
48
|
+
if (key === undefined) {
|
|
49
|
+
key = new Uint8Array(randomBytes(32));
|
|
50
|
+
this.keys.set(subjectId, key);
|
|
51
|
+
}
|
|
52
|
+
return new Uint8Array(key); // a copy: a caller cannot mutate (or retain a handle on) the stored key
|
|
53
|
+
}
|
|
54
|
+
async get(subjectId: string): Promise<Uint8Array | null> {
|
|
55
|
+
const key = this.keys.get(subjectId);
|
|
56
|
+
return key === undefined ? null : new Uint8Array(key);
|
|
57
|
+
}
|
|
58
|
+
async destroy(subjectId: string): Promise<void> {
|
|
59
|
+
this.keys.get(subjectId)?.fill(0); // zeroize, then forget
|
|
60
|
+
this.keys.delete(subjectId);
|
|
61
|
+
this.destroyed.add(subjectId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The subject (unit of erasure) of a stream key. */
|
|
66
|
+
export const seenKeySubject = (streamKey: string): string => `loom-ui/seen/${streamKey}`;
|
|
67
|
+
|
|
68
|
+
export interface DerivedSeenKeys {
|
|
69
|
+
readonly enc: Buffer;
|
|
70
|
+
readonly mac: Buffer;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Separate encryption and MAC keys from the stored data key (HKDF-SHA256). Throws on a malformed key. */
|
|
74
|
+
export function deriveSeenKeys(master: Uint8Array): DerivedSeenKeys {
|
|
75
|
+
if (!(master instanceof Uint8Array) || master.length !== 32)
|
|
76
|
+
throw new Error("a seen data key must be 32 bytes");
|
|
77
|
+
const d = (info: string) =>
|
|
78
|
+
Buffer.from(hkdfSync("sha256", master, Buffer.alloc(0), `loom-ui/seen/${info}/v1`, 32));
|
|
79
|
+
return { enc: d("enc"), mac: d("mac") };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The associated data a sealed ref is bound to. */
|
|
83
|
+
export const refAad = (
|
|
84
|
+
experienceId: string,
|
|
85
|
+
viewerId: string,
|
|
86
|
+
sequence: number,
|
|
87
|
+
entryIndex: number
|
|
88
|
+
): Buffer =>
|
|
89
|
+
Buffer.from(
|
|
90
|
+
JSON.stringify(["loom-ui/seen/ref/v1", experienceId, viewerId, sequence, entryIndex])
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Plaintext length (bytes) every sealed ref is padded to, so the ciphertext length does not reveal
|
|
95
|
+
* the value's size (the `bytes` of an entry lives only INSIDE the seal). Addresses longer than this
|
|
96
|
+
* round up to the next multiple, which a real content address never needs.
|
|
97
|
+
*/
|
|
98
|
+
export const SEALED_REF_BLOCK = 256;
|
|
99
|
+
/** Longest content address `sealRef` accepts. Real addresses are sha256-sized; anything longer fails closed so padding never rounds up (a length leak). */
|
|
100
|
+
export const MAX_SEALED_ADDRESS_CHARS = 128;
|
|
101
|
+
|
|
102
|
+
/** The plaintext inside a sealed ref: the content ref and the value's measured size, padded to a constant length. */
|
|
103
|
+
export function sealRef(
|
|
104
|
+
enc: Buffer,
|
|
105
|
+
aad: Buffer,
|
|
106
|
+
ref: { readonly __loomContent: string },
|
|
107
|
+
bytes: number
|
|
108
|
+
): string {
|
|
109
|
+
if (ref.__loomContent.length > MAX_SEALED_ADDRESS_CHARS)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`sealRef: a content address over ${MAX_SEALED_ADDRESS_CHARS} characters is refused`
|
|
112
|
+
);
|
|
113
|
+
const body = JSON.stringify({ r: ref, b: bytes });
|
|
114
|
+
const len = Buffer.byteLength(body, "utf-8");
|
|
115
|
+
const padded = Math.ceil(len / SEALED_REF_BLOCK) * SEALED_REF_BLOCK;
|
|
116
|
+
return sealText(enc, aad, body + " ".repeat(padded - len));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** AES-256-GCM: `base64url(0x01 | iv | ciphertext | tag)`. A fresh random IV per call. */
|
|
120
|
+
export function sealText(enc: Buffer, aad: Buffer, plaintext: string): string {
|
|
121
|
+
const iv = randomBytes(12);
|
|
122
|
+
const c = createCipheriv("aes-256-gcm", enc, iv);
|
|
123
|
+
c.setAAD(aad);
|
|
124
|
+
const ct = Buffer.concat([c.update(Buffer.from(plaintext, "utf-8")), c.final()]);
|
|
125
|
+
return Buffer.concat([Buffer.from([1]), iv, ct, c.getAuthTag()]).toString("base64url");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The plaintext, or `undefined` for ANY failure (wrong key, wrong AAD, tampering, malformed): fail closed. */
|
|
129
|
+
export function openText(enc: Buffer, aad: Buffer, sealed: unknown): string | undefined {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof sealed !== "string") return undefined;
|
|
132
|
+
const raw = Buffer.from(sealed, "base64url");
|
|
133
|
+
if (raw.length < 1 + 12 + 16 || raw[0] !== 1) return undefined;
|
|
134
|
+
const d = createDecipheriv("aes-256-gcm", enc, raw.subarray(1, 13));
|
|
135
|
+
d.setAAD(aad);
|
|
136
|
+
d.setAuthTag(raw.subarray(raw.length - 16));
|
|
137
|
+
return Buffer.concat([d.update(raw.subarray(13, raw.length - 16)), d.final()]).toString(
|
|
138
|
+
"utf-8"
|
|
139
|
+
);
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `undefined` for any failure; otherwise the ref and size sealed by {@link sealRef}. */
|
|
146
|
+
export function openRef(
|
|
147
|
+
enc: Buffer,
|
|
148
|
+
aad: Buffer,
|
|
149
|
+
sealed: unknown
|
|
150
|
+
): { readonly ref: { readonly __loomContent: string }; readonly bytes: number } | undefined {
|
|
151
|
+
const text = openText(enc, aad, sealed);
|
|
152
|
+
if (text === undefined) return undefined;
|
|
153
|
+
try {
|
|
154
|
+
const v = JSON.parse(text) as { r?: { __loomContent?: unknown }; b?: unknown };
|
|
155
|
+
const addr = v?.r?.__loomContent;
|
|
156
|
+
if (
|
|
157
|
+
typeof addr !== "string" ||
|
|
158
|
+
addr.length === 0 ||
|
|
159
|
+
addr.length > 256 ||
|
|
160
|
+
!Number.isSafeInteger(v.b) ||
|
|
161
|
+
(v.b as number) < 0
|
|
162
|
+
)
|
|
163
|
+
return undefined;
|
|
164
|
+
return { ref: { __loomContent: addr }, bytes: v.b as number };
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Keyed digest: an unkeyed hash of a manifest would let anyone confirm a guessed value. */
|
|
171
|
+
export const keyedDigest = (mac: Buffer, text: string): string =>
|
|
172
|
+
`hmac:${createHmac("sha256", mac).update(text).digest("hex")}`;
|
|
173
|
+
|
|
174
|
+
/** The `SeenKeyStore` conformance kit: findings (a test asserts none), never throws. */
|
|
175
|
+
export async function checkSeenKeyStoreConformance(
|
|
176
|
+
make: () => SeenKeyStore | Promise<SeenKeyStore>
|
|
177
|
+
): Promise<Finding[]> {
|
|
178
|
+
const findings: Finding[] = [];
|
|
179
|
+
const fail = (code: string, message: string) =>
|
|
180
|
+
findings.push({ code, severity: "error", message });
|
|
181
|
+
const store = await make();
|
|
182
|
+
const same = (a: Uint8Array | null, b: Uint8Array | null) =>
|
|
183
|
+
a !== null && b !== null && Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
184
|
+
const cases: Array<[string, () => Promise<void>]> = [
|
|
185
|
+
[
|
|
186
|
+
"an unknown subject has no key",
|
|
187
|
+
async () => {
|
|
188
|
+
if ((await store.get("k-none")) !== null)
|
|
189
|
+
fail("get-unknown", "get of an unknown subject was not null");
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"getOrCreate yields a stable 32-byte key",
|
|
194
|
+
async () => {
|
|
195
|
+
const a = await store.getOrCreate("k-a");
|
|
196
|
+
if (a.length !== 32) fail("key-length", `a key is ${a.length} bytes, not 32`);
|
|
197
|
+
if (!same(a, await store.getOrCreate("k-a")) || !same(a, await store.get("k-a")))
|
|
198
|
+
fail("key-stable", "getOrCreate/get did not return one key for one subject");
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
"subjects get independent keys",
|
|
203
|
+
async () => {
|
|
204
|
+
if (same(await store.getOrCreate("k-1"), await store.getOrCreate("k-2")))
|
|
205
|
+
fail("key-distinct", "two subjects share a key");
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
"destroy erases and never resurrects",
|
|
210
|
+
async () => {
|
|
211
|
+
await store.getOrCreate("k-d");
|
|
212
|
+
await store.destroy("k-d");
|
|
213
|
+
if ((await store.get("k-d")) !== null)
|
|
214
|
+
fail("destroy-get", "get after destroy was not null");
|
|
215
|
+
let recreated = false;
|
|
216
|
+
try {
|
|
217
|
+
await store.getOrCreate("k-d");
|
|
218
|
+
recreated = true;
|
|
219
|
+
} catch {
|
|
220
|
+
/* required */
|
|
221
|
+
}
|
|
222
|
+
if (recreated) fail("destroy-resurrect", "getOrCreate after destroy created a new key");
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
[
|
|
226
|
+
"get after destroy is null even after a prior get (no cache outlives destroy)",
|
|
227
|
+
async () => {
|
|
228
|
+
await store.getOrCreate("k-w");
|
|
229
|
+
await store.get("k-w"); // warm any cache
|
|
230
|
+
await store.get("k-w");
|
|
231
|
+
await store.destroy("k-w");
|
|
232
|
+
if ((await store.get("k-w")) !== null)
|
|
233
|
+
fail("destroy-cache", "get after destroy served a key from a warm cache");
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
[
|
|
237
|
+
"concurrent getOrCreate of one new subject returns one key",
|
|
238
|
+
async () => {
|
|
239
|
+
const [a, b, c] = await Promise.all([
|
|
240
|
+
store.getOrCreate("k-race"),
|
|
241
|
+
store.getOrCreate("k-race"),
|
|
242
|
+
store.getOrCreate("k-race"),
|
|
243
|
+
]);
|
|
244
|
+
if (!same(a, b) || !same(b, c))
|
|
245
|
+
fail(
|
|
246
|
+
"create-race",
|
|
247
|
+
"concurrent getOrCreate returned different keys (create is not atomic)"
|
|
248
|
+
);
|
|
249
|
+
if (!same(a, await store.get("k-race")))
|
|
250
|
+
fail("create-race", "the stored key is not the one concurrent callers received");
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
[
|
|
254
|
+
"destroy is idempotent and works on an absent subject",
|
|
255
|
+
async () => {
|
|
256
|
+
await store.destroy("k-d");
|
|
257
|
+
await store.destroy("k-never");
|
|
258
|
+
if ((await store.get("k-never")) !== null)
|
|
259
|
+
fail("destroy-absent", "an absent subject has a key");
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
];
|
|
263
|
+
for (const [name, run] of cases) {
|
|
264
|
+
try {
|
|
265
|
+
await run();
|
|
266
|
+
} catch (err) {
|
|
267
|
+
fail("store-threw", `${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return findings;
|
|
271
|
+
}
|
package/src/seenGc.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { PinsExtra } from "./lineage";
|
|
2
|
+
import type { RetentionPolicy } from "./retention";
|
|
3
|
+
import { type SeenLog, type SeenRefusal, type SeenUnreadable, UNREADABLE_EXAMPLES } from "./seen";
|
|
4
|
+
|
|
5
|
+
// ── Reclaiming the value blobs of the record of what a viewer saw (U12) ──────────────────────
|
|
6
|
+
// MARK-AND-SWEEP, not a refcount. There is no transaction across the event log and the ContentStore,
|
|
7
|
+
// so a counter drifts, and a crash between "append the manifest" and "increment" would delete a live
|
|
8
|
+
// value. The roots are re-derived from the logs on every pass, so nothing here can drift:
|
|
9
|
+
//
|
|
10
|
+
// MARK every content address cited by (a) every line of the lineage (artifacts, op payloads,
|
|
11
|
+
// anything stored as a ref in an event) and (b) every DECRYPTABLE ref of every viewer's
|
|
12
|
+
// retention window (the age window is kept `maxSkewMs` wider than a read's); plus whatever the host's `extraRoots` names. Erased viewers and
|
|
13
|
+
// rolled-off views are unreadable or out of window, so they mark nothing.
|
|
14
|
+
// SWEEP every blob the store lists that is not marked, deleted with the store's own conditional
|
|
15
|
+
// `deleteIfUntouchedSince` so a blob that is touched (a put, including a dedup'd re-put)
|
|
16
|
+
// after the cutoff is never deleted, in one atomic step.
|
|
17
|
+
//
|
|
18
|
+
// SAFETY WINDOW. `cutoff = store.touchClock() at mark start - graceMs`. A blob touched at or after the
|
|
19
|
+
// cutoff is kept: that covers a blob written during the pass AND one younger than `graceMs`, i.e. a
|
|
20
|
+
// value whose manifest is not appended yet. `graceMs` MUST exceed the longest a `record()` (or a
|
|
21
|
+
// lineage commit) can take between putting a blob and appending the event that cites it; a crash in
|
|
22
|
+
// that gap leaks the blob only until the first pass past its grace, and never loses a cited value.
|
|
23
|
+
// The pass is idempotent and safe to rerun (or to crash in the middle of).
|
|
24
|
+
//
|
|
25
|
+
// FAIL CLOSED. Anything the collector cannot establish aborts BEFORE any delete: a store that cannot
|
|
26
|
+
// enumerate its streams, a key-store failure, ANYTHING UNREADABLE in the live window (a stream key
|
|
27
|
+
// that does not round-trip, a record that fails to parse, e.g. a newer writer's shape during a rolling
|
|
28
|
+
// upgrade, a sealed ref that fails to open under a present key: `gc-unreadable-record`, with counts), a ContentStore without the SPI 1.3.0 set
|
|
29
|
+
// (`listPage` + `deleteIfUntouchedSince` + `touchClock`), a root set over `maxMarked`.
|
|
30
|
+
//
|
|
31
|
+
// MEMORY. The marked set holds one address string per distinct live blob (O(live blobs); `maxMarked`
|
|
32
|
+
// refuses rather than grow past it). Streams are read one at a time; the sweep pages by `pageSize`.
|
|
33
|
+
|
|
34
|
+
export interface SeenGcOptions {
|
|
35
|
+
/** Blobs touched within this many ms before the pass began are never swept. MUST exceed one `record()`'s duration. */
|
|
36
|
+
readonly graceMs: number;
|
|
37
|
+
/**
|
|
38
|
+
* You assert EVERY blob in the log's ContentStore is either rooted by the lineage / the seen logs
|
|
39
|
+
* or named by `extraRoots`. The collector deletes what it cannot find a root for: pointing it at a
|
|
40
|
+
* store that also holds anything else would destroy that data. A literal `true`, so it is a decision.
|
|
41
|
+
*/
|
|
42
|
+
readonly ownsContentStore: true;
|
|
43
|
+
/** Addresses this collector cannot see but must keep (other users of the same ContentStore). */
|
|
44
|
+
readonly extraRoots?: () => AsyncIterable<string> | Iterable<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Reclaim old LINEAGE versions in the same pass. Absent (the default), every artifact any version
|
|
47
|
+
* ever cited is rooted (`Lineage.forEachContentRef`), so this collector reclaims only the seen
|
|
48
|
+
* log's value blobs. With a policy, the lineage half of the root set is `Lineage.pins(policy)` plus
|
|
49
|
+
* the versions the live views still name, so versions the policy dropped (and not a viewer's) stop
|
|
50
|
+
* being rooted and are swept. Use this when ONE collector owns the whole store; use
|
|
51
|
+
* `Lineage.pins` + loom's `sweepUnpinnedContent` when loom's sweep owns it and pass it
|
|
52
|
+
* `collectSeenRoots(log)` as `extra`. Never run both against the same store with different roots.
|
|
53
|
+
*/
|
|
54
|
+
readonly lineageRetention?: RetentionPolicy;
|
|
55
|
+
/** Refuse (before deleting anything) if more than this many distinct addresses are live. Default 5,000,000. */
|
|
56
|
+
readonly maxMarked?: number;
|
|
57
|
+
/** Blobs per `listPage`. Default 500. */
|
|
58
|
+
readonly pageSize?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Proceed although some live-window stream / record / sealed ref could not be read. OPT-IN MEANS
|
|
61
|
+
* "I ACCEPT SWEEPING WHAT THOSE CITE": their blobs are unrooted and WILL be deleted once past the
|
|
62
|
+
* grace period (for a rolling upgrade, finish the upgrade and rerun instead). Default: refuse.
|
|
63
|
+
* A viewer whose key is ABSENT (erased) is not unreadable: that is the intended shred path.
|
|
64
|
+
*/
|
|
65
|
+
readonly allowUnreadable?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SeenGcReport {
|
|
69
|
+
/** Distinct live addresses found. */
|
|
70
|
+
readonly marked: number;
|
|
71
|
+
/** Blobs older than the cutoff that were examined. */
|
|
72
|
+
readonly scanned: number;
|
|
73
|
+
/** Blobs deleted. */
|
|
74
|
+
readonly deleted: number;
|
|
75
|
+
/** Unrooted blobs spared because they were touched at or after the cutoff (in grace). */
|
|
76
|
+
readonly spared: number;
|
|
77
|
+
readonly cutoffMs: number;
|
|
78
|
+
/** What could not be read (non-zero only with `allowUnreadable: true`). */
|
|
79
|
+
readonly unreadable: SeenUnreadable;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type SeenGcResult =
|
|
83
|
+
| { readonly ok: true; readonly report: SeenGcReport }
|
|
84
|
+
| { readonly ok: false; readonly refusal: SeenRefusal };
|
|
85
|
+
|
|
86
|
+
const no = (code: string, message: string): SeenGcResult => ({
|
|
87
|
+
ok: false,
|
|
88
|
+
refusal: {
|
|
89
|
+
kind: "refused",
|
|
90
|
+
finding: { code, severity: "error", message },
|
|
91
|
+
code,
|
|
92
|
+
message,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** Run one collection pass over `log`'s content store. See the header for the contract. */
|
|
97
|
+
export async function collectSeenGarbage(log: SeenLog, opts: SeenGcOptions): Promise<SeenGcResult> {
|
|
98
|
+
if (opts?.ownsContentStore !== true)
|
|
99
|
+
return no(
|
|
100
|
+
"gc-ownership-unasserted",
|
|
101
|
+
"ownsContentStore: true is required: the sweep deletes unrooted blobs"
|
|
102
|
+
);
|
|
103
|
+
if (!Number.isFinite(opts.graceMs) || opts.graceMs < 0)
|
|
104
|
+
return no("gc-grace-invalid", "graceMs must be a non-negative number of milliseconds");
|
|
105
|
+
const maxMarked = opts.maxMarked ?? 5_000_000;
|
|
106
|
+
const pageSize = opts.pageSize ?? 500;
|
|
107
|
+
if (
|
|
108
|
+
!Number.isSafeInteger(maxMarked) ||
|
|
109
|
+
maxMarked < 1 ||
|
|
110
|
+
!Number.isSafeInteger(pageSize) ||
|
|
111
|
+
pageSize < 1
|
|
112
|
+
)
|
|
113
|
+
return no("gc-options-invalid", "maxMarked and pageSize must be positive integers");
|
|
114
|
+
const content = log.contentStore;
|
|
115
|
+
if (
|
|
116
|
+
content.listPage === undefined ||
|
|
117
|
+
content.deleteIfUntouchedSince === undefined ||
|
|
118
|
+
content.touchClock === undefined
|
|
119
|
+
)
|
|
120
|
+
return no(
|
|
121
|
+
"gc-content-store-unsupported",
|
|
122
|
+
"the ContentStore must implement listPage, deleteIfUntouchedSince and touchClock (SPI 1.3.0): without a touch time a young blob cannot be told from an orphan"
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const marked = new Set<string>();
|
|
126
|
+
const visit = (address: string) => {
|
|
127
|
+
marked.add(address);
|
|
128
|
+
if (marked.size > maxMarked) throw new Error("maxMarked exceeded");
|
|
129
|
+
};
|
|
130
|
+
let cutoffMs: number;
|
|
131
|
+
let unreadable: SeenUnreadable = {
|
|
132
|
+
streams: 0,
|
|
133
|
+
records: 0,
|
|
134
|
+
refs: 0,
|
|
135
|
+
examples: { streams: [], records: [], refs: [] },
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
// The clock is read BEFORE marking: anything touched after this instant is spared, so a blob
|
|
139
|
+
// written while we mark (whose manifest we may have read too early to see) survives.
|
|
140
|
+
cutoffMs = (await content.touchClock()) - opts.graceMs;
|
|
141
|
+
const versions = new Map<string, { experienceId: string; versionHash: string }>();
|
|
142
|
+
unreadable = await log.forEachLiveRef(visit, (experienceId, versionHash) =>
|
|
143
|
+
versions.set(`${experienceId}\u0000${versionHash}`, { experienceId, versionHash })
|
|
144
|
+
);
|
|
145
|
+
if (opts.lineageRetention === undefined) await log.lineageOf.forEachContentRef(visit);
|
|
146
|
+
else
|
|
147
|
+
for await (const a of log.lineageOf.pins(opts.lineageRetention, {
|
|
148
|
+
keepVersions: versions.values(),
|
|
149
|
+
}))
|
|
150
|
+
visit(a);
|
|
151
|
+
if (opts.extraRoots !== undefined) for await (const a of opts.extraRoots()) visit(a);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return no(
|
|
154
|
+
"gc-roots-unavailable",
|
|
155
|
+
`the root set could not be established (nothing was deleted): ${err instanceof Error ? err.message : String(err)}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
unreadable.streams + unreadable.records + unreadable.refs > 0 &&
|
|
161
|
+
opts.allowUnreadable !== true
|
|
162
|
+
)
|
|
163
|
+
return no(
|
|
164
|
+
"gc-unreadable-record",
|
|
165
|
+
`${unreadable.streams} stream(s), ${unreadable.records} record(s) and ${unreadable.refs} sealed ref(s) in the live window could not be read, so what they cite is unrooted (nothing was deleted). Fix or finish the upgrade and rerun, or pass allowUnreadable: true to sweep it. Offenders (first ${UNREADABLE_EXAMPLES} per kind): streams [${unreadable.examples.streams.join(", ")}], records [${unreadable.examples.records.join(", ")}], refs [${unreadable.examples.refs.join(", ")}]`
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
let scanned = 0;
|
|
169
|
+
let deleted = 0;
|
|
170
|
+
let spared = 0;
|
|
171
|
+
try {
|
|
172
|
+
let cursor: string | undefined;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const page = await content.listPage({
|
|
175
|
+
...(cursor !== undefined && { cursor }),
|
|
176
|
+
limit: pageSize,
|
|
177
|
+
});
|
|
178
|
+
for (const entry of page.entries) {
|
|
179
|
+
if (marked.has(entry.address)) continue;
|
|
180
|
+
// 0 = unknown touch time (never orphan-eligible); at/after the cutoff = young or just touched
|
|
181
|
+
if (entry.touchedAt === 0 || !(entry.touchedAt < cutoffMs)) {
|
|
182
|
+
spared += 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
scanned += 1;
|
|
186
|
+
if (await content.deleteIfUntouchedSince({ __loomContent: entry.address }, cutoffMs))
|
|
187
|
+
deleted += 1;
|
|
188
|
+
else spared += 1; // touched between the listing and the delete
|
|
189
|
+
}
|
|
190
|
+
if (page.nextCursor === undefined || page.nextCursor === cursor) break;
|
|
191
|
+
cursor = page.nextCursor;
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return no(
|
|
195
|
+
"gc-sweep-interrupted",
|
|
196
|
+
`the sweep stopped after deleting ${deleted} blobs (safe to rerun): ${err instanceof Error ? err.message : String(err)}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
report: { marked: marked.size, scanned, deleted, spared, cutoffMs, unreadable },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The seen log's share of a shared content store's root set, for `Lineage.pins(policy, extra)`:
|
|
207
|
+
* the sealed value blobs of every live view and the (experience, version) each live view reads.
|
|
208
|
+
*
|
|
209
|
+
* roots: () => lineage.pins(policy, await collectSeenRoots(seenLog)) // sweepUnpinnedContent
|
|
210
|
+
*
|
|
211
|
+
* With `reclaimOrphans: { completeScan: true }` loom erases EVERYTHING in the store no root names, so
|
|
212
|
+
* the roots must come from every user of it (this seen log, other lineages, loom's executions). THROWS
|
|
213
|
+
* if anything in the live window could not be read (a root set that silently omits what it could not
|
|
214
|
+
* open would delete it): fix or finish the upgrade, or pass `allowUnreadable: true` to accept that.
|
|
215
|
+
*/
|
|
216
|
+
export async function collectSeenRoots(
|
|
217
|
+
log: SeenLog,
|
|
218
|
+
opts: { readonly allowUnreadable?: boolean } = {}
|
|
219
|
+
): Promise<Required<Pick<PinsExtra, "keepVersions" | "keepAddresses">>> {
|
|
220
|
+
const addresses = new Set<string>();
|
|
221
|
+
const versions = new Map<string, { experienceId: string; versionHash: string }>();
|
|
222
|
+
const unreadable = await log.forEachLiveRef(
|
|
223
|
+
(a) => addresses.add(a),
|
|
224
|
+
(experienceId, versionHash) =>
|
|
225
|
+
versions.set(`${experienceId}\u0000${versionHash}`, { experienceId, versionHash })
|
|
226
|
+
);
|
|
227
|
+
const n = unreadable.streams + unreadable.records + unreadable.refs;
|
|
228
|
+
if (n > 0 && opts.allowUnreadable !== true)
|
|
229
|
+
throw new Error(
|
|
230
|
+
`collectSeenRoots: ${n} stream(s)/record(s)/sealed ref(s) in the live window could not be read, so what they cite would be unrooted (examples: ${[...unreadable.examples.streams, ...unreadable.examples.records, ...unreadable.examples.refs].slice(0, UNREADABLE_EXAMPLES).join(", ")}). Nothing was collected; pass allowUnreadable: true to sweep it anyway`
|
|
231
|
+
);
|
|
232
|
+
return { keepVersions: [...versions.values()], keepAddresses: [...addresses] };
|
|
233
|
+
}
|