@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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +134 -2
  4. package/dist/conformance.d.ts +18 -0
  5. package/dist/conformance.d.ts.map +1 -0
  6. package/dist/conformance.js +243 -0
  7. package/dist/conformance.js.map +1 -0
  8. package/dist/cursor.d.ts +43 -0
  9. package/dist/cursor.d.ts.map +1 -0
  10. package/dist/cursor.js +50 -0
  11. package/dist/cursor.js.map +1 -0
  12. package/dist/esm/conformance.js +239 -0
  13. package/dist/esm/conformance.js.map +1 -0
  14. package/dist/esm/cursor.js +44 -0
  15. package/dist/esm/cursor.js.map +1 -0
  16. package/dist/esm/feed.js +301 -0
  17. package/dist/esm/feed.js.map +1 -0
  18. package/dist/esm/index.js +6 -0
  19. package/dist/esm/index.js.map +1 -0
  20. package/dist/esm/signal.js +141 -0
  21. package/dist/esm/signal.js.map +1 -0
  22. package/dist/esm/untrusted.js +14 -0
  23. package/dist/esm/untrusted.js.map +1 -0
  24. package/dist/feed.d.ts +125 -0
  25. package/dist/feed.d.ts.map +1 -0
  26. package/dist/feed.js +305 -0
  27. package/dist/feed.js.map +1 -0
  28. package/dist/index.d.ts +10 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +20 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/signal.d.ts +33 -0
  33. package/dist/signal.d.ts.map +1 -0
  34. package/dist/signal.js +145 -0
  35. package/dist/signal.js.map +1 -0
  36. package/dist/untrusted.d.ts +5 -0
  37. package/dist/untrusted.d.ts.map +1 -0
  38. package/dist/untrusted.js +17 -0
  39. package/dist/untrusted.js.map +1 -0
  40. package/package.json +60 -3
  41. package/src/conformance.ts +248 -0
  42. package/src/cursor.ts +89 -0
  43. package/src/feed.ts +446 -0
  44. package/src/index.ts +18 -0
  45. package/src/signal.ts +200 -0
  46. 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
+ }
@@ -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
+ }