@loomwork/ui-changes 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 +134 -2
- package/dist/conformance.d.ts +18 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +243 -0
- package/dist/conformance.js.map +1 -0
- package/dist/cursor.d.ts +43 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +50 -0
- package/dist/cursor.js.map +1 -0
- package/dist/esm/conformance.js +239 -0
- package/dist/esm/conformance.js.map +1 -0
- package/dist/esm/cursor.js +44 -0
- package/dist/esm/cursor.js.map +1 -0
- package/dist/esm/feed.js +301 -0
- package/dist/esm/feed.js.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/signal.js +141 -0
- package/dist/esm/signal.js.map +1 -0
- package/dist/esm/untrusted.js +14 -0
- package/dist/esm/untrusted.js.map +1 -0
- package/dist/feed.d.ts +125 -0
- package/dist/feed.d.ts.map +1 -0
- package/dist/feed.js +305 -0
- package/dist/feed.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/signal.d.ts +33 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +145 -0
- package/dist/signal.js.map +1 -0
- package/dist/untrusted.d.ts +5 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +17 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +60 -3
- package/src/conformance.ts +248 -0
- package/src/cursor.ts +89 -0
- package/src/feed.ts +446 -0
- package/src/index.ts +18 -0
- package/src/signal.ts +200 -0
- package/src/untrusted.ts +16 -0
package/src/feed.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChangeDescription,
|
|
3
|
+
type DeclaredToolClasses,
|
|
4
|
+
type DescribeOptions,
|
|
5
|
+
type Finding,
|
|
6
|
+
type HeadSignal,
|
|
7
|
+
type ProjectionCatalog,
|
|
8
|
+
type ReadPolicy,
|
|
9
|
+
type UndeclaredToolPolicy,
|
|
10
|
+
describeChange,
|
|
11
|
+
mergeReadPolicy,
|
|
12
|
+
} from "@loomwork/ui-core";
|
|
13
|
+
import {
|
|
14
|
+
EXPERIENCE_BRANCHED,
|
|
15
|
+
EXPERIENCE_PROMOTED,
|
|
16
|
+
EXPERIENCE_REVERTED,
|
|
17
|
+
LineErasedError,
|
|
18
|
+
type Lineage,
|
|
19
|
+
type LineageStore,
|
|
20
|
+
foldLineage,
|
|
21
|
+
} from "@loomwork/ui-lineage";
|
|
22
|
+
|
|
23
|
+
// ── The change feed ───────────────────────────────────────────────────────────
|
|
24
|
+
// A feed turns one experience's lineage log into a stream of CHANGE EVENTS a notifier can consume:
|
|
25
|
+
// "version X was composed", "version Y is now shown". An event says THAT and WHERE; it carries no
|
|
26
|
+
// node, no data and no text, because what a change MEANS is viewer-specific and the log holds
|
|
27
|
+
// content the viewer may not see. `describe(event, { viewer })` is where a viewer learns what
|
|
28
|
+
// changed, through `describeChange`'s redaction.
|
|
29
|
+
//
|
|
30
|
+
// - RESUMABLE: a consumer keeps a cursor (the last `sequence` it handled) and asks for what is
|
|
31
|
+
// after it. The cursor is the log's own dense sequence, so it survives a restart.
|
|
32
|
+
// - AT-LEAST-ONCE with IDEMPOTENT ids: `id` is a pure function of the log position, so reading
|
|
33
|
+
// the same range twice, or resubscribing from an older cursor, yields the same ids. A consumer
|
|
34
|
+
// dedupes on `id` (or just on `sequence`).
|
|
35
|
+
// - SERVER-SIDE. It reads the lineage store and the content store; never import it from a client.
|
|
36
|
+
|
|
37
|
+
export type ChangeEventKind = "composed" | "reverted" | "branched" | "promoted";
|
|
38
|
+
|
|
39
|
+
export interface ChangeEvent {
|
|
40
|
+
/** Stable, unique per log position: `<experienceId>#<sequence>`. The idempotency key. */
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly experienceId: string;
|
|
43
|
+
/** 1-based position in the line's log; also the cursor value that means "handled through here". */
|
|
44
|
+
readonly sequence: number;
|
|
45
|
+
readonly at: string;
|
|
46
|
+
readonly kind: ChangeEventKind;
|
|
47
|
+
/** The version this event is about (the new head, or the version now shown). */
|
|
48
|
+
readonly versionHash: string;
|
|
49
|
+
/** What it replaced: the parent (composed/reverted), the forked version (branched), the version shown before (promoted); null = nothing. */
|
|
50
|
+
readonly base: { readonly experienceId: string; readonly versionHash: string } | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ReadResult =
|
|
54
|
+
| {
|
|
55
|
+
readonly ok: true;
|
|
56
|
+
readonly events: readonly ChangeEvent[];
|
|
57
|
+
/** The sequence of the last event returned, or `after` when none was: pass it back as `after`. */
|
|
58
|
+
readonly cursor: number;
|
|
59
|
+
/** More events remain past `cursor` (the page was full). */
|
|
60
|
+
readonly more: boolean;
|
|
61
|
+
/** The log's tip at read time (0 = empty). */
|
|
62
|
+
readonly tip: number;
|
|
63
|
+
}
|
|
64
|
+
| { readonly ok: false; readonly finding: Finding };
|
|
65
|
+
|
|
66
|
+
export type DescribeResult =
|
|
67
|
+
| { readonly ok: true; readonly description: ChangeDescription }
|
|
68
|
+
| { readonly ok: false; readonly finding: Finding };
|
|
69
|
+
|
|
70
|
+
export interface RetryOptions {
|
|
71
|
+
/** First retry delay in ms (default 250). Doubles per consecutive failure. */
|
|
72
|
+
readonly baseMs?: number;
|
|
73
|
+
/** Delay ceiling in ms (default 30000). */
|
|
74
|
+
readonly maxMs?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SubscribeOptions {
|
|
78
|
+
/** Deliver events after this sequence (default 0 = from the start). */
|
|
79
|
+
readonly after?: number;
|
|
80
|
+
/** A failed read, or a handler that threw. Never throws itself. */
|
|
81
|
+
readonly onError?: (err: unknown) => void;
|
|
82
|
+
/** Backoff for re-trying a failed delivery or read with no new append. See `subscribe`. */
|
|
83
|
+
readonly retry?: RetryOptions;
|
|
84
|
+
/**
|
|
85
|
+
* Called after each failed delivery of the SAME event, with the count of consecutive failures.
|
|
86
|
+
* Return `true` to SKIP the event (the cursor moves past it, it is never redelivered to this
|
|
87
|
+
* subscription); anything else (or a throw) keeps retrying. Absent = retry forever with capped
|
|
88
|
+
* backoff: a poison event then stalls this subscription (and only it); a host that wants out
|
|
89
|
+
* resubscribes with `after: <poison sequence>`.
|
|
90
|
+
*/
|
|
91
|
+
readonly onPoison?: (event: ChangeEvent, attempts: number) => boolean | Promise<boolean>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ChangeFeed {
|
|
95
|
+
read(experienceId: string, opts?: { after?: number; limit?: number }): Promise<ReadResult>;
|
|
96
|
+
/**
|
|
97
|
+
* Catch up from `after`, then follow the line, until the returned function is called. Events
|
|
98
|
+
* arrive in sequence order, one at a time (the next waits for the handler's promise). A handler
|
|
99
|
+
* that throws stops this pass at that event WITHOUT advancing: it is delivered again (at-least-
|
|
100
|
+
* once), and `onError` hears of it. The retry does NOT wait for a new append: while the cursor is
|
|
101
|
+
* behind the tip (a handler threw, or a read failed) a bounded exponential backoff timer
|
|
102
|
+
* (jittered, `unref`'d, cleared on unsubscribe) re-runs the pass, so a quiet line is never stuck.
|
|
103
|
+
* A permanently failing event is a poison event: see `SubscribeOptions.onPoison`. Resubscribing
|
|
104
|
+
* from an older cursor redelivers the same ids.
|
|
105
|
+
*/
|
|
106
|
+
subscribe(
|
|
107
|
+
experienceId: string,
|
|
108
|
+
handler: (event: ChangeEvent) => void | Promise<void>,
|
|
109
|
+
opts?: SubscribeOptions
|
|
110
|
+
): () => void;
|
|
111
|
+
/** What `event` changed, for one viewer. See `describeChange`: restricted content is never named. */
|
|
112
|
+
describe(
|
|
113
|
+
event: ChangeEvent,
|
|
114
|
+
opts?: Omit<DescribeOptions, "ops" | "summary" | "summaryClass">
|
|
115
|
+
): Promise<DescribeResult>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const DEFAULT_PAGE = 100;
|
|
119
|
+
export const MAX_PAGE = 1000;
|
|
120
|
+
|
|
121
|
+
export interface LineageChangeFeedOptions {
|
|
122
|
+
readonly lineage: Lineage;
|
|
123
|
+
/** The same `LineageStore` the `Lineage` writes to. */
|
|
124
|
+
readonly events: LineageStore;
|
|
125
|
+
/** What wakes a subscription; see `pollingHeadSignal` / `eventStoreHeadSignal`. */
|
|
126
|
+
readonly signal: HeadSignal;
|
|
127
|
+
/**
|
|
128
|
+
* The projections the host offers (the same `ProjectionCatalog` `compile` takes). ABSENT = none:
|
|
129
|
+
* every projection-bound node is hidden from every viewer in `describe`, and a change to one is
|
|
130
|
+
* a `restricted-changed`. An id the catalogue lacks, or a version pin that drifted, hides too.
|
|
131
|
+
*/
|
|
132
|
+
readonly projections?: ProjectionCatalog;
|
|
133
|
+
/** The host's hydration projection id (reserved; see `DescribeOptions`). */
|
|
134
|
+
readonly hydrationProjectionId?: string;
|
|
135
|
+
/** The host's declared class per tool: a read-time floor (core `DeclaredToolClasses`). */
|
|
136
|
+
readonly declaredToolClasses?: DeclaredToolClasses;
|
|
137
|
+
/** What a tool the declared table does not list reads as (`"allow"` default, or `"deny"`). */
|
|
138
|
+
readonly undeclared?: UndeclaredToolPolicy;
|
|
139
|
+
/**
|
|
140
|
+
* The read policy bound ONCE (the same shape every reader takes). Joined, raise-only, with the
|
|
141
|
+
* `lineage`'s own bound policy, the individual options above and any per-call `describe` options.
|
|
142
|
+
*/
|
|
143
|
+
readonly readPolicy?: ReadPolicy;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const err = (code: string, message: string): { ok: false; finding: Finding } => ({
|
|
147
|
+
ok: false,
|
|
148
|
+
finding: { code, severity: "error", message },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const asCursor = (v: unknown): number | undefined =>
|
|
152
|
+
typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A `ChangeFeed` over a `Lineage`. `read` folds the whole line (validating it: a malformed log is a
|
|
156
|
+
* finding, never partial events) so it is O(log length) per call; a deployment with very long lines
|
|
157
|
+
* pages with `limit` but still pays the fold: cache above this seam, not inside it.
|
|
158
|
+
*/
|
|
159
|
+
export class LineageChangeFeed implements ChangeFeed {
|
|
160
|
+
private readonly lineage: Lineage;
|
|
161
|
+
private readonly events: LineageStore;
|
|
162
|
+
private readonly signal: HeadSignal;
|
|
163
|
+
private readonly policy: ReadPolicy;
|
|
164
|
+
|
|
165
|
+
constructor(opts: LineageChangeFeedOptions) {
|
|
166
|
+
this.policy = mergeReadPolicy(opts.lineage.readPolicy, opts.readPolicy, opts);
|
|
167
|
+
this.lineage = opts.lineage;
|
|
168
|
+
this.events = opts.events;
|
|
169
|
+
this.signal = opts.signal;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async read(
|
|
173
|
+
experienceId: string,
|
|
174
|
+
opts: { after?: number; limit?: number } = {}
|
|
175
|
+
): Promise<ReadResult> {
|
|
176
|
+
const after = opts.after === undefined ? 0 : asCursor(opts.after);
|
|
177
|
+
if (after === undefined) return err("cursor-invalid", "after is not a non-negative integer");
|
|
178
|
+
const limit =
|
|
179
|
+
opts.limit === undefined
|
|
180
|
+
? DEFAULT_PAGE
|
|
181
|
+
: Number.isSafeInteger(opts.limit) && opts.limit >= 1
|
|
182
|
+
? Math.min(opts.limit, MAX_PAGE)
|
|
183
|
+
: undefined;
|
|
184
|
+
if (limit === undefined) return err("limit-invalid", "limit is not a positive integer");
|
|
185
|
+
let records: Awaited<ReturnType<LineageStore["read"]>>;
|
|
186
|
+
try {
|
|
187
|
+
records = await this.events.read(experienceId);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
// The feed INHERITS the lineage's `confirmErased` hook (it classifies through `lineage.block`):
|
|
190
|
+
// a confirmed erasure is `line-erased`, any other unreadable line is `line-unreadable` (or
|
|
191
|
+
// `artifact-unsealed`); both fail closed. Anything else propagates unchanged.
|
|
192
|
+
const b = await this.lineage.block(experienceId, e);
|
|
193
|
+
if (b === undefined) throw e;
|
|
194
|
+
return err(
|
|
195
|
+
b instanceof LineErasedError ? "line-erased" : b.reason,
|
|
196
|
+
b instanceof LineErasedError
|
|
197
|
+
? `lineage "${experienceId}" is erased: its key is destroyed (confirmed)`
|
|
198
|
+
: `lineage "${experienceId}" is unreadable: its key is unavailable on this node or its ciphertext does not open; not confirmed erased`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const fold = foldLineage(experienceId, records);
|
|
202
|
+
if (!fold.ok) return { ok: false, finding: fold.finding };
|
|
203
|
+
const out: ChangeEvent[] = [];
|
|
204
|
+
let promoted: string | null = null;
|
|
205
|
+
let more = false;
|
|
206
|
+
for (const rec of records) {
|
|
207
|
+
// The fold validated every record, so these payload reads are of checked shapes.
|
|
208
|
+
const p = rec.payload as { versionHash: string };
|
|
209
|
+
const version = fold.state.versions.get(p.versionHash);
|
|
210
|
+
let kind: ChangeEventKind;
|
|
211
|
+
let base: ChangeEvent["base"];
|
|
212
|
+
if (rec.type === EXPERIENCE_PROMOTED) {
|
|
213
|
+
kind = "promoted";
|
|
214
|
+
base = promoted === null ? null : { experienceId, versionHash: promoted };
|
|
215
|
+
promoted = p.versionHash;
|
|
216
|
+
} else {
|
|
217
|
+
kind =
|
|
218
|
+
rec.type === EXPERIENCE_REVERTED
|
|
219
|
+
? "reverted"
|
|
220
|
+
: rec.type === EXPERIENCE_BRANCHED
|
|
221
|
+
? "branched"
|
|
222
|
+
: "composed";
|
|
223
|
+
const parent = version?.parents[0];
|
|
224
|
+
base =
|
|
225
|
+
kind === "branched" && version?.from !== undefined
|
|
226
|
+
? { experienceId: version.from.experienceId, versionHash: version.from.versionHash }
|
|
227
|
+
: parent === undefined
|
|
228
|
+
? null
|
|
229
|
+
: { experienceId, versionHash: parent };
|
|
230
|
+
}
|
|
231
|
+
if (rec.sequence <= after) continue;
|
|
232
|
+
if (out.length === limit) {
|
|
233
|
+
more = true;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
out.push({
|
|
237
|
+
id: `${experienceId}#${rec.sequence}`,
|
|
238
|
+
experienceId,
|
|
239
|
+
sequence: rec.sequence,
|
|
240
|
+
at: rec.at,
|
|
241
|
+
kind,
|
|
242
|
+
versionHash: p.versionHash,
|
|
243
|
+
base,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
events: out,
|
|
249
|
+
cursor: out.at(-1)?.sequence ?? after,
|
|
250
|
+
more,
|
|
251
|
+
tip: fold.state.sequence,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
subscribe(
|
|
256
|
+
experienceId: string,
|
|
257
|
+
handler: (event: ChangeEvent) => void | Promise<void>,
|
|
258
|
+
opts: SubscribeOptions = {}
|
|
259
|
+
): () => void {
|
|
260
|
+
let cursor = asCursor(opts.after) ?? 0;
|
|
261
|
+
let stopped = false;
|
|
262
|
+
let running = false;
|
|
263
|
+
let again = false;
|
|
264
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
265
|
+
let failures = 0; // consecutive failed passes, for the backoff
|
|
266
|
+
let poisonSeq = 0;
|
|
267
|
+
let poisonAttempts = 0;
|
|
268
|
+
const baseMs = Math.max(1, finite(opts.retry?.baseMs, 250));
|
|
269
|
+
const maxMs = Math.max(baseMs, finite(opts.retry?.maxMs, 30_000));
|
|
270
|
+
const report = (e: unknown) => {
|
|
271
|
+
try {
|
|
272
|
+
opts.onError?.(e);
|
|
273
|
+
} catch {
|
|
274
|
+
// a reporting hook must not break delivery
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
// The cursor is behind the tip whenever a pass failed: retry on a timer, since a quiet line
|
|
278
|
+
// raises no signal. Delay = min(max, base * 2^n), jittered into [50%, 100%].
|
|
279
|
+
const arm = (): void => {
|
|
280
|
+
if (stopped || timer !== undefined) return;
|
|
281
|
+
const delay = Math.min(maxMs, baseMs * 2 ** Math.min(failures, 30));
|
|
282
|
+
failures += 1;
|
|
283
|
+
timer = setTimeout(
|
|
284
|
+
() => {
|
|
285
|
+
timer = undefined;
|
|
286
|
+
void pump();
|
|
287
|
+
},
|
|
288
|
+
Math.max(1, Math.round(delay * (0.5 + Math.random() / 2)))
|
|
289
|
+
);
|
|
290
|
+
(timer as { unref?: () => void }).unref?.();
|
|
291
|
+
};
|
|
292
|
+
const skipPoison = async (e: ChangeEvent): Promise<boolean> => {
|
|
293
|
+
if (poisonSeq !== e.sequence) {
|
|
294
|
+
poisonSeq = e.sequence;
|
|
295
|
+
poisonAttempts = 0;
|
|
296
|
+
}
|
|
297
|
+
poisonAttempts += 1;
|
|
298
|
+
if (opts.onPoison === undefined) return false;
|
|
299
|
+
try {
|
|
300
|
+
return (await opts.onPoison(e, poisonAttempts)) === true;
|
|
301
|
+
} catch (thrown) {
|
|
302
|
+
report(thrown);
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const pump = async (): Promise<void> => {
|
|
307
|
+
if (running) {
|
|
308
|
+
again = true;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
running = true;
|
|
312
|
+
let failed = false;
|
|
313
|
+
try {
|
|
314
|
+
do {
|
|
315
|
+
again = false;
|
|
316
|
+
for (;;) {
|
|
317
|
+
if (stopped) return;
|
|
318
|
+
const r = await this.read(experienceId, { after: cursor });
|
|
319
|
+
if (!r.ok) {
|
|
320
|
+
report(r.finding);
|
|
321
|
+
// A CONFIRMED erasure is terminal: report once and stop (no retry, no further wakes).
|
|
322
|
+
// An unconfirmed `line-unreadable` keeps retrying: the key may come back to this node.
|
|
323
|
+
if (r.finding.code === "line-erased") {
|
|
324
|
+
terminate();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
failed = true;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
for (const e of r.events) {
|
|
331
|
+
if (stopped) return;
|
|
332
|
+
try {
|
|
333
|
+
await handler(e);
|
|
334
|
+
} catch (thrown) {
|
|
335
|
+
report(thrown);
|
|
336
|
+
if (await skipPoison(e)) {
|
|
337
|
+
cursor = e.sequence;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
failed = true;
|
|
341
|
+
return; // not advanced: redelivered by the retry timer or the next wake
|
|
342
|
+
}
|
|
343
|
+
cursor = e.sequence;
|
|
344
|
+
failures = 0;
|
|
345
|
+
}
|
|
346
|
+
if (!r.more) break;
|
|
347
|
+
}
|
|
348
|
+
} while (again && !stopped);
|
|
349
|
+
} catch (thrown) {
|
|
350
|
+
report(thrown);
|
|
351
|
+
failed = true;
|
|
352
|
+
} finally {
|
|
353
|
+
running = false;
|
|
354
|
+
if (failed) arm();
|
|
355
|
+
else failures = 0;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
let unwatch: (() => void) | undefined;
|
|
359
|
+
const terminate = (): void => {
|
|
360
|
+
stopped = true;
|
|
361
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
362
|
+
timer = undefined;
|
|
363
|
+
unwatch?.();
|
|
364
|
+
unwatch = undefined;
|
|
365
|
+
};
|
|
366
|
+
unwatch = this.signal.watch(experienceId, () => void pump());
|
|
367
|
+
void pump();
|
|
368
|
+
return terminate;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async describe(
|
|
372
|
+
event: ChangeEvent,
|
|
373
|
+
opts: Omit<DescribeOptions, "ops" | "summary" | "summaryClass"> = {}
|
|
374
|
+
): Promise<DescribeResult> {
|
|
375
|
+
// The caller's event is a CLAIM about the log, not the log: its experience and sequence are
|
|
376
|
+
// used only to find the recorded event, and `kind`/`base`/`versionHash` come from the fold.
|
|
377
|
+
// An event that does not match the record at its position is refused (a forged `base` would
|
|
378
|
+
// otherwise describe a different line's nodes: a confused deputy).
|
|
379
|
+
const seq = event?.sequence;
|
|
380
|
+
if (typeof event?.experienceId !== "string" || !Number.isSafeInteger(seq) || seq < 1)
|
|
381
|
+
return err("event-invalid", "the event has no readable line and sequence");
|
|
382
|
+
const at = await this.read(event.experienceId, { after: seq - 1, limit: 1 });
|
|
383
|
+
if (!at.ok) return { ok: false, finding: at.finding };
|
|
384
|
+
const truth = at.events[0];
|
|
385
|
+
if (truth === undefined || truth.sequence !== seq)
|
|
386
|
+
return err("event-unknown", "the event is not in the line's log");
|
|
387
|
+
if (
|
|
388
|
+
truth.versionHash !== event.versionHash ||
|
|
389
|
+
truth.kind !== event.kind ||
|
|
390
|
+
JSON.stringify(truth.base) !== JSON.stringify(event.base ?? null)
|
|
391
|
+
)
|
|
392
|
+
return err("event-mismatch", "the event does not match the log's record at its sequence");
|
|
393
|
+
const versions = await this.lineage.versions(truth.experienceId);
|
|
394
|
+
if (!versions.ok) return refusal(versions.refusal);
|
|
395
|
+
const record = versions.value.find((v) => v.versionHash === truth.versionHash);
|
|
396
|
+
if (record === undefined)
|
|
397
|
+
return err("version-unknown", `"${truth.versionHash}" is not a version of the line`);
|
|
398
|
+
const next = await this.lineage.get(truth.experienceId, truth.versionHash);
|
|
399
|
+
if (!next.ok) return refusal(next.refusal);
|
|
400
|
+
let prev = null;
|
|
401
|
+
if (truth.base !== null) {
|
|
402
|
+
const p = await this.lineage.get(truth.base.experienceId, truth.base.versionHash);
|
|
403
|
+
if (!p.ok) return refusal(p.refusal);
|
|
404
|
+
prev = p.value;
|
|
405
|
+
}
|
|
406
|
+
// The ops and the summary belong to ONE version. They describe this event when it IS that
|
|
407
|
+
// version's creation, or when a promotion spans exactly that one step.
|
|
408
|
+
const own = truth.kind !== "promoted" || record.parents.includes(truth.base?.versionHash ?? "");
|
|
409
|
+
// The bound policy first: a per-call value can only ADD to it (a table is joined strictest-wins,
|
|
410
|
+
// `"deny"` sticks, a bound projection catalogue is not replaced), so `{}` or an explicit
|
|
411
|
+
// `undefined` per call cannot lower what the host bound at construction.
|
|
412
|
+
const policy = mergeReadPolicy(this.policy, opts);
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
description: describeChange(prev, next.value, {
|
|
416
|
+
...opts,
|
|
417
|
+
// AFTER `...opts`: the joined policy wins over every per-call spelling of these fields.
|
|
418
|
+
...(policy.projections !== undefined && { projections: policy.projections }),
|
|
419
|
+
...(policy.hydrationProjectionId !== undefined && {
|
|
420
|
+
hydrationProjectionId: policy.hydrationProjectionId,
|
|
421
|
+
}),
|
|
422
|
+
...(policy.declaredToolClasses !== undefined && {
|
|
423
|
+
declaredToolClasses: policy.declaredToolClasses,
|
|
424
|
+
}),
|
|
425
|
+
...(policy.undeclared !== undefined && { undeclared: policy.undeclared }),
|
|
426
|
+
...(own && record.ops.length > 0 && { ops: record.ops }),
|
|
427
|
+
...(own && record.summary !== undefined && { summary: record.summary }),
|
|
428
|
+
...(own && record.summaryClass !== undefined && { summaryClass: record.summaryClass }),
|
|
429
|
+
}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const finite = (v: unknown, dflt: number): number =>
|
|
435
|
+
typeof v === "number" && Number.isFinite(v) ? v : dflt;
|
|
436
|
+
|
|
437
|
+
function refusal(r: { kind: string; finding?: Finding }): { ok: false; finding: Finding } {
|
|
438
|
+
return {
|
|
439
|
+
ok: false,
|
|
440
|
+
finding: r.finding ?? {
|
|
441
|
+
code: r.kind,
|
|
442
|
+
severity: "error",
|
|
443
|
+
message: `the lineage refused: ${r.kind}`,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { LineageChangeFeed, DEFAULT_PAGE, MAX_PAGE } from "./feed";
|
|
2
|
+
export type {
|
|
3
|
+
ChangeEvent,
|
|
4
|
+
ChangeEventKind,
|
|
5
|
+
ChangeFeed,
|
|
6
|
+
DescribeResult,
|
|
7
|
+
LineageChangeFeedOptions,
|
|
8
|
+
ReadResult,
|
|
9
|
+
RetryOptions,
|
|
10
|
+
SubscribeOptions,
|
|
11
|
+
} from "./feed";
|
|
12
|
+
export { InMemoryUnreadStore, markRead, unreadChanges } from "./cursor";
|
|
13
|
+
export type { UnreadResult, UnreadStore } from "./cursor";
|
|
14
|
+
export { eventStoreHeadSignal, pollingHeadSignal } from "./signal";
|
|
15
|
+
export type { PollingOptions, PushOptions, SignalOptions } from "./signal";
|
|
16
|
+
export { checkChangeFeedConformance, checkUnreadStoreConformance } from "./conformance";
|
|
17
|
+
export type { FeedHarness } from "./conformance";
|
|
18
|
+
export { summaryAsTainted } from "./untrusted";
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { EventStore } from "@loomwork/event-runtime";
|
|
2
|
+
import type { HeadChange, HeadSignal } from "@loomwork/ui-core";
|
|
3
|
+
import {
|
|
4
|
+
LineErasedError,
|
|
5
|
+
type LineageStore,
|
|
6
|
+
classifyLineFailure,
|
|
7
|
+
foldLineage,
|
|
8
|
+
} from "@loomwork/ui-lineage";
|
|
9
|
+
import { LINEAGE_STREAM_PREFIX } from "@loomwork/ui-lineage";
|
|
10
|
+
|
|
11
|
+
// ── Head signals: how a reader learns "this experience moved" ─────────────────
|
|
12
|
+
// Two implementations of core's `HeadSignal`, sharing one check: read the line, and if its tip is
|
|
13
|
+
// past what this watcher last reported, report `{ sequence, head, promoted }`. They differ only in
|
|
14
|
+
// WHAT TRIGGERS the check.
|
|
15
|
+
//
|
|
16
|
+
// pollingHeadSignal a timer. Works over ANY `LineageStore`; costs one read per interval
|
|
17
|
+
// per watcher. The default; a missed beat is only latency.
|
|
18
|
+
// eventStoreHeadSignal a subscription to the line's stream on loom's `EventStore`
|
|
19
|
+
// (`EventStore.subscribe`): the check runs when an event lands. A push
|
|
20
|
+
// adapter for the store the lineage already writes to.
|
|
21
|
+
//
|
|
22
|
+
// loom's `@loomwork/live-query` was checked and does NOT fit: it folds an EXECUTION's `ExecutionState`
|
|
23
|
+
// (`loadState`), and a lineage stream is a reserved stream of lineage events, not an execution.
|
|
24
|
+
// `EventStore.subscribe` is the push primitive underneath it, used here directly. A host with its
|
|
25
|
+
// own transport (a websocket, SSE, a database notify) implements `HeadSignal` (one method).
|
|
26
|
+
|
|
27
|
+
export interface SignalOptions {
|
|
28
|
+
/** A read or fold that failed. The watcher keeps going: the next trigger tries again. */
|
|
29
|
+
readonly onError?: (err: unknown) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Has this line's erasure been confirmed? (The same host hook as `LineageOptions.confirmErased`;
|
|
32
|
+
* a signal is built over a store, not a lineage, so it takes its own.) A CONFIRMED erasure reports
|
|
33
|
+
* one `LineErasedError` and the watcher goes terminal (no more checks). An unconfirmed unreadable
|
|
34
|
+
* line reports `LineUnreadableError` on every check and keeps retrying.
|
|
35
|
+
*/
|
|
36
|
+
readonly confirmErased?: (experienceId: string) => Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One watcher's check: at most one read in flight, reports each new tip once. */
|
|
40
|
+
function checker(
|
|
41
|
+
store: LineageStore,
|
|
42
|
+
experienceId: string,
|
|
43
|
+
onChange: (c: HeadChange) => void,
|
|
44
|
+
onError: ((e: unknown) => void) | undefined,
|
|
45
|
+
confirmErased: ((experienceId: string) => Promise<boolean>) | undefined,
|
|
46
|
+
onTerminal: () => void
|
|
47
|
+
): { check: () => Promise<void>; stop: () => void } {
|
|
48
|
+
let seen = 0;
|
|
49
|
+
let stopped = false;
|
|
50
|
+
let busy = false;
|
|
51
|
+
let rerun = false;
|
|
52
|
+
const fail = (e: unknown) => {
|
|
53
|
+
try {
|
|
54
|
+
onError?.(e);
|
|
55
|
+
} catch {
|
|
56
|
+
// never break the signal
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const check = async (): Promise<void> => {
|
|
60
|
+
if (stopped) return;
|
|
61
|
+
if (busy) {
|
|
62
|
+
rerun = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
busy = true;
|
|
66
|
+
try {
|
|
67
|
+
do {
|
|
68
|
+
rerun = false;
|
|
69
|
+
const records = await store.read(experienceId);
|
|
70
|
+
if (stopped || records.length <= seen) continue;
|
|
71
|
+
seen = records.length;
|
|
72
|
+
const fold = foldLineage(experienceId, records);
|
|
73
|
+
if (!fold.ok) {
|
|
74
|
+
fail(fold.finding);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
onChange({
|
|
79
|
+
experienceId,
|
|
80
|
+
sequence: fold.state.sequence,
|
|
81
|
+
head: fold.state.head,
|
|
82
|
+
promoted: fold.state.promoted,
|
|
83
|
+
});
|
|
84
|
+
} catch (e) {
|
|
85
|
+
fail(e);
|
|
86
|
+
}
|
|
87
|
+
} while (rerun && !stopped);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// an unreadable line reports a typed error; anything else is reported unchanged
|
|
90
|
+
const b = await classifyLineFailure(experienceId, e, confirmErased);
|
|
91
|
+
fail(b ?? e);
|
|
92
|
+
if (b instanceof LineErasedError) {
|
|
93
|
+
// confirmed erased: terminal, reported once
|
|
94
|
+
stopped = true;
|
|
95
|
+
onTerminal();
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
busy = false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
check,
|
|
103
|
+
stop: () => {
|
|
104
|
+
stopped = true;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PollingOptions extends SignalOptions {
|
|
110
|
+
/** Milliseconds between checks (default 2000; floor 10). */
|
|
111
|
+
readonly intervalMs?: number;
|
|
112
|
+
/** Injectable timer: run `fn` every `ms`, return a canceller. Default `setInterval` (unref'd). */
|
|
113
|
+
readonly schedule?: (fn: () => void, ms: number) => () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const defaultSchedule = (fn: () => void, ms: number): (() => void) => {
|
|
117
|
+
const t = setInterval(fn, ms);
|
|
118
|
+
(t as { unref?: () => void }).unref?.();
|
|
119
|
+
return () => clearInterval(t);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function pollingHeadSignal(store: LineageStore, opts: PollingOptions = {}): HeadSignal {
|
|
123
|
+
const intervalMs = Math.max(
|
|
124
|
+
10,
|
|
125
|
+
Number.isFinite(opts.intervalMs) ? (opts.intervalMs as number) : 2000
|
|
126
|
+
);
|
|
127
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
128
|
+
return {
|
|
129
|
+
watch(experienceId, onChange) {
|
|
130
|
+
let cancel: (() => void) | undefined;
|
|
131
|
+
let ended = false;
|
|
132
|
+
const c = checker(store, experienceId, onChange, opts.onError, opts.confirmErased, () => {
|
|
133
|
+
ended = true;
|
|
134
|
+
cancel?.();
|
|
135
|
+
});
|
|
136
|
+
void c.check();
|
|
137
|
+
if (!ended) cancel = schedule(() => void c.check(), intervalMs);
|
|
138
|
+
return () => {
|
|
139
|
+
c.stop();
|
|
140
|
+
cancel?.();
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface PushOptions extends SignalOptions {
|
|
147
|
+
/**
|
|
148
|
+
* A safety-net poll (ms; floor 10) beside the push subscription: a push can be missed (the
|
|
149
|
+
* subscription failed to attach, a notify was dropped), and the fallback bounds that to one
|
|
150
|
+
* interval. Absent = push only.
|
|
151
|
+
*/
|
|
152
|
+
readonly fallbackIntervalMs?: number;
|
|
153
|
+
/** Injectable timer for the fallback; default `setInterval` (unref'd). */
|
|
154
|
+
readonly schedule?: (fn: () => void, ms: number) => () => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function eventStoreHeadSignal(
|
|
158
|
+
events: EventStore,
|
|
159
|
+
store: LineageStore,
|
|
160
|
+
opts: PushOptions = {}
|
|
161
|
+
): HeadSignal {
|
|
162
|
+
const fallback = Number.isFinite(opts.fallbackIntervalMs)
|
|
163
|
+
? Math.max(10, opts.fallbackIntervalMs as number)
|
|
164
|
+
: undefined;
|
|
165
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
166
|
+
const report = (e: unknown) => {
|
|
167
|
+
try {
|
|
168
|
+
opts.onError?.(e);
|
|
169
|
+
} catch {
|
|
170
|
+
// never break the signal
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
watch(experienceId, onChange) {
|
|
175
|
+
let cancel: (() => void) | undefined;
|
|
176
|
+
let off: (() => void) | undefined;
|
|
177
|
+
let stopped = false;
|
|
178
|
+
const c = checker(store, experienceId, onChange, opts.onError, opts.confirmErased, () => {
|
|
179
|
+
stopped = true;
|
|
180
|
+
cancel?.();
|
|
181
|
+
off?.();
|
|
182
|
+
});
|
|
183
|
+
events
|
|
184
|
+
.subscribe(`${LINEAGE_STREAM_PREFIX}${experienceId}`, () => void c.check())
|
|
185
|
+
.then((u) => {
|
|
186
|
+
if (stopped) u();
|
|
187
|
+
else off = u;
|
|
188
|
+
})
|
|
189
|
+
.catch(report);
|
|
190
|
+
void c.check();
|
|
191
|
+
if (!stopped && fallback !== undefined) cancel = schedule(() => void c.check(), fallback);
|
|
192
|
+
return () => {
|
|
193
|
+
stopped = true;
|
|
194
|
+
c.stop();
|
|
195
|
+
cancel?.();
|
|
196
|
+
off?.();
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/untrusted.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type TaintedValue, tainted } from "@loomwork/classify";
|
|
2
|
+
import type { UntrustedText } from "@loomwork/ui-core";
|
|
3
|
+
|
|
4
|
+
// ── The summary as loom's untrusted value ─────────────────────────────────────
|
|
5
|
+
// A change summary is model text. `UntrustedText` marks it for DISPLAY; when a host feeds it to
|
|
6
|
+
// another model (a digest, a notification writer) it must cross as loom's ADR 0085 taint
|
|
7
|
+
// (`@loomwork/classify` `TaintedValue`), which `@loomwork/llm`'s `guardRequest` refuses in any instruction
|
|
8
|
+
// position and fences in a data position. This is a server-side helper: the taint package is a
|
|
9
|
+
// loom kernel-side dependency and must not enter a client bundle.
|
|
10
|
+
//
|
|
11
|
+
// NEVER put a summary in a prompt's instruction (system) position, tainted or not.
|
|
12
|
+
|
|
13
|
+
/** The summary as an ADR 0085 tainted value, origin `loom-ui:change-summary`. */
|
|
14
|
+
export function summaryAsTainted(summary: UntrustedText): TaintedValue<string> {
|
|
15
|
+
return tainted(summary.text, "loom-ui:change-summary");
|
|
16
|
+
}
|