@loomwork/ui-hydration 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 +398 -2
- package/dist/cache.d.ts +71 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +157 -0
- package/dist/cache.js.map +1 -0
- package/dist/chatSource.d.ts +105 -0
- package/dist/chatSource.d.ts.map +1 -0
- package/dist/chatSource.js +259 -0
- package/dist/chatSource.js.map +1 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/contentRefs.d.ts +78 -0
- package/dist/contentRefs.d.ts.map +1 -0
- package/dist/contentRefs.js +129 -0
- package/dist/contentRefs.js.map +1 -0
- package/dist/controller.d.ts +228 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +454 -0
- package/dist/controller.js.map +1 -0
- package/dist/dataProvider.d.ts +53 -0
- package/dist/dataProvider.d.ts.map +1 -0
- package/dist/dataProvider.js +77 -0
- package/dist/dataProvider.js.map +1 -0
- package/dist/esm/cache.js +151 -0
- package/dist/esm/cache.js.map +1 -0
- package/dist/esm/chatSource.js +254 -0
- package/dist/esm/chatSource.js.map +1 -0
- package/dist/esm/client.js +15 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/contentRefs.js +119 -0
- package/dist/esm/contentRefs.js.map +1 -0
- package/dist/esm/controller.js +449 -0
- package/dist/esm/controller.js.map +1 -0
- package/dist/esm/dataProvider.js +72 -0
- package/dist/esm/dataProvider.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/invoker.js +2 -0
- package/dist/esm/invoker.js.map +1 -0
- package/dist/esm/invokers.js +310 -0
- package/dist/esm/invokers.js.map +1 -0
- package/dist/esm/loomEffectProvider.js +96 -0
- package/dist/esm/loomEffectProvider.js.map +1 -0
- package/dist/esm/params.js +207 -0
- package/dist/esm/params.js.map +1 -0
- package/dist/esm/path.js +24 -0
- package/dist/esm/path.js.map +1 -0
- package/dist/esm/plan.js +190 -0
- package/dist/esm/plan.js.map +1 -0
- package/dist/esm/projection.js +230 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/provider.js +416 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/repair.js +53 -0
- package/dist/esm/repair.js.map +1 -0
- package/dist/esm/reshape.js +156 -0
- package/dist/esm/reshape.js.map +1 -0
- package/dist/esm/restricted.js +54 -0
- package/dist/esm/restricted.js.map +1 -0
- package/dist/esm/scheduler.js +247 -0
- package/dist/esm/scheduler.js.map +1 -0
- package/dist/esm/select.js +18 -0
- package/dist/esm/select.js.map +1 -0
- package/dist/esm/shape.js +34 -0
- package/dist/esm/shape.js.map +1 -0
- package/dist/esm/split/client.js +309 -0
- package/dist/esm/split/client.js.map +1 -0
- package/dist/esm/split/index.js +3 -0
- package/dist/esm/split/index.js.map +1 -0
- package/dist/esm/split/server.js +488 -0
- package/dist/esm/split/server.js.map +1 -0
- package/dist/esm/split/shared.js +13 -0
- package/dist/esm/split/shared.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/invoker.d.ts +10 -0
- package/dist/invoker.d.ts.map +1 -0
- package/dist/invoker.js +3 -0
- package/dist/invoker.js.map +1 -0
- package/dist/invokers.d.ts +36 -0
- package/dist/invokers.d.ts.map +1 -0
- package/dist/invokers.js +315 -0
- package/dist/invokers.js.map +1 -0
- package/dist/loomEffectProvider.d.ts +67 -0
- package/dist/loomEffectProvider.d.ts.map +1 -0
- package/dist/loomEffectProvider.js +101 -0
- package/dist/loomEffectProvider.js.map +1 -0
- package/dist/params.d.ts +103 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +221 -0
- package/dist/params.js.map +1 -0
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +28 -0
- package/dist/path.js.map +1 -0
- package/dist/plan.d.ts +56 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +199 -0
- package/dist/plan.js.map +1 -0
- package/dist/projection.d.ts +122 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +238 -0
- package/dist/projection.js.map +1 -0
- package/dist/provider.d.ts +152 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +421 -0
- package/dist/provider.js.map +1 -0
- package/dist/repair.d.ts +18 -0
- package/dist/repair.d.ts.map +1 -0
- package/dist/repair.js +57 -0
- package/dist/repair.js.map +1 -0
- package/dist/reshape.d.ts +32 -0
- package/dist/reshape.d.ts.map +1 -0
- package/dist/reshape.js +164 -0
- package/dist/reshape.js.map +1 -0
- package/dist/restricted.d.ts +21 -0
- package/dist/restricted.d.ts.map +1 -0
- package/dist/restricted.js +61 -0
- package/dist/restricted.js.map +1 -0
- package/dist/scheduler.d.ts +82 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +254 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/select.d.ts +34 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +21 -0
- package/dist/select.js.map +1 -0
- package/dist/shape.d.ts +3 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +37 -0
- package/dist/shape.js.map +1 -0
- package/dist/split/client.d.ts +132 -0
- package/dist/split/client.d.ts.map +1 -0
- package/dist/split/client.js +313 -0
- package/dist/split/client.js.map +1 -0
- package/dist/split/index.d.ts +5 -0
- package/dist/split/index.d.ts.map +1 -0
- package/dist/split/index.js +14 -0
- package/dist/split/index.js.map +1 -0
- package/dist/split/server.d.ts +160 -0
- package/dist/split/server.d.ts.map +1 -0
- package/dist/split/server.js +496 -0
- package/dist/split/server.js.map +1 -0
- package/dist/split/shared.d.ts +6 -0
- package/dist/split/shared.d.ts.map +1 -0
- package/dist/split/shared.js +19 -0
- package/dist/split/shared.js.map +1 -0
- package/package.json +77 -3
- package/src/cache.ts +211 -0
- package/src/chatSource.ts +374 -0
- package/src/client.ts +46 -0
- package/src/contentRefs.ts +195 -0
- package/src/controller.ts +708 -0
- package/src/dataProvider.ts +141 -0
- package/src/index.ts +86 -0
- package/src/invoker.ts +14 -0
- package/src/invokers.ts +348 -0
- package/src/loomEffectProvider.ts +136 -0
- package/src/params.ts +314 -0
- package/src/path.ts +26 -0
- package/src/plan.ts +197 -0
- package/src/projection.ts +297 -0
- package/src/provider.ts +657 -0
- package/src/repair.ts +70 -0
- package/src/reshape.ts +193 -0
- package/src/restricted.ts +69 -0
- package/src/scheduler.ts +284 -0
- package/src/select.ts +59 -0
- package/src/shape.ts +39 -0
- package/src/split/client.ts +449 -0
- package/src/split/index.ts +18 -0
- package/src/split/server.ts +746 -0
- package/src/split/shared.ts +18 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import type { Projection, ProjectionRuntime } from "@loomwork/projection-runtime";
|
|
3
|
+
import type { Audience, Finding } from "@loomwork/ui-core";
|
|
4
|
+
import { type StreamedText, appendChunk, isBroadcast } from "@loomwork/ui-core/browser";
|
|
5
|
+
|
|
6
|
+
// ── One projection, keyed by handle ───────────────────────────────────────────
|
|
7
|
+
// Compiled widgets bind to `values.<handle>` and declare `status.<handle>` as their status
|
|
8
|
+
// channel (core's `hydrationPath`/`hydrationStatusPath`), and the compiler pins
|
|
9
|
+
// HYDRATION_PROJECTION_VERSION into the blueprint (ADR 0095). Hydration is imperative and
|
|
10
|
+
// transient — nothing here is durable or replayed — so the projection is driven by ONE synthetic
|
|
11
|
+
// in-memory event type. `subscribeSelector` gives each handle its own slice, so a value resolving
|
|
12
|
+
// wakes one widget, not the surface.
|
|
13
|
+
|
|
14
|
+
export const HYDRATION_PROJECTION_ID = "loom-ui.hydration";
|
|
15
|
+
/**
|
|
16
|
+
* Bump when the MEANING of `values`/`status` changes; the compiler pins this into every blueprint.
|
|
17
|
+
* 1.1.0: a status may be `empty` (loom ADR 0100), and the fold DROPS the handle's value with it.
|
|
18
|
+
*/
|
|
19
|
+
export const HYDRATION_PROJECTION_VERSION = "1.1.0";
|
|
20
|
+
export const HYDRATION_SET_EVENT = "LOOM_UI_HYDRATION_SET";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `empty`: delivered, and the upstream has no such row — "no data", never an error (loom ADR
|
|
24
|
+
* 0100). It never carries a value: the fold drops `values[handle]` on it.
|
|
25
|
+
*/
|
|
26
|
+
export type HandleState = "pending" | "ready" | "stale" | "error" | "empty";
|
|
27
|
+
export interface HandleStatus {
|
|
28
|
+
readonly state: HandleState;
|
|
29
|
+
readonly seq: number;
|
|
30
|
+
readonly finding?: Finding;
|
|
31
|
+
}
|
|
32
|
+
export interface HydrationState {
|
|
33
|
+
readonly values: Record<string, unknown>;
|
|
34
|
+
readonly status: Record<string, HandleStatus>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SetPayload =
|
|
38
|
+
| { kind: "status"; handle: string; status: HandleStatus }
|
|
39
|
+
| { kind: "value"; handle: string; value: unknown; status: HandleStatus }
|
|
40
|
+
| { kind: "remove"; handle: string };
|
|
41
|
+
|
|
42
|
+
const RESERVED = new Set(["__proto__", "constructor", "prototype"]);
|
|
43
|
+
export function isReservedHandle(handle: string): boolean {
|
|
44
|
+
return RESERVED.has(handle);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `Object.fromEntries` uses CreateDataProperty for each entry, so a `__proto__` key lands as
|
|
48
|
+
// an own property — never the prototype setter that a plain `out[k] = v` assignment would hit.
|
|
49
|
+
function without<T>(rec: Record<string, T>, key: string): Record<string, T> {
|
|
50
|
+
return Object.fromEntries(Object.entries(rec).filter(([k]) => k !== key));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hydrationProjection(): Projection<HydrationState> {
|
|
54
|
+
return {
|
|
55
|
+
id: HYDRATION_PROJECTION_ID,
|
|
56
|
+
version: HYDRATION_PROJECTION_VERSION,
|
|
57
|
+
initialState: { values: {}, status: {} },
|
|
58
|
+
handlers: {
|
|
59
|
+
[HYDRATION_SET_EVENT]: (state, event) => {
|
|
60
|
+
const p = event.payload as SetPayload;
|
|
61
|
+
// Defense in depth: `HydrationStore` already refuses reserved handles before emitting,
|
|
62
|
+
// but this projection is registered on a SHARED runtime — any other holder of that
|
|
63
|
+
// runtime can call `handleEvent` directly with a forged envelope, bypassing the store.
|
|
64
|
+
// The fold itself must never write a reserved handle into state.
|
|
65
|
+
if (isReservedHandle(p.handle)) return state;
|
|
66
|
+
if (p.kind === "remove") {
|
|
67
|
+
if (!Object.hasOwn(state.status, p.handle) && !Object.hasOwn(state.values, p.handle))
|
|
68
|
+
return state;
|
|
69
|
+
return {
|
|
70
|
+
values: without(state.values, p.handle),
|
|
71
|
+
status: without(state.status, p.handle),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const status = { ...state.status, [p.handle]: p.status };
|
|
75
|
+
// loom ADR 0100 D1: a producer MUST clear the value beside `empty` — an older reader that
|
|
76
|
+
// cannot see `empty` would otherwise show an earlier `ready` value as current.
|
|
77
|
+
if (p.status?.state === "empty")
|
|
78
|
+
return {
|
|
79
|
+
values: Object.hasOwn(state.values, p.handle)
|
|
80
|
+
? without(state.values, p.handle)
|
|
81
|
+
: state.values,
|
|
82
|
+
status,
|
|
83
|
+
};
|
|
84
|
+
if (p.kind === "status") return { values: state.values, status };
|
|
85
|
+
return { values: { ...state.values, [p.handle]: p.value }, status };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface HandleSlice {
|
|
92
|
+
readonly value: unknown;
|
|
93
|
+
readonly status: HandleStatus | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a store's state belongs to. Isolation is by (experience, principal, audience); two viewers
|
|
98
|
+
* of one experience must not share a store — `values` holds whatever the viewer's own principal chain
|
|
99
|
+
* was allowed to read, so a shared store is a cross-viewer data leak, not a cache win.
|
|
100
|
+
* Omit `principalId` only where there is genuinely one viewer (a single-user CLI, a test).
|
|
101
|
+
*/
|
|
102
|
+
export interface HydrationScope {
|
|
103
|
+
readonly experienceId: string;
|
|
104
|
+
readonly principalId?: string;
|
|
105
|
+
/**
|
|
106
|
+
* The audience the store's surface is drawn for (`@loomwork/ui-core` `Audience`). A broadcast
|
|
107
|
+
* surface and a viewer surface of the same principal are DIFFERENT scopes: a broadcast bind
|
|
108
|
+
* removes the handles it withholds, and that must never erase the personal surface's values.
|
|
109
|
+
* The controller refuses a store whose audience differs from its own ({@link HydrationAudienceMismatchError}).
|
|
110
|
+
*/
|
|
111
|
+
readonly audience?: Audience;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A controller and its store disagree about the audience (one broadcast, the other not). */
|
|
115
|
+
export class HydrationAudienceMismatchError extends Error {
|
|
116
|
+
readonly code = "hydration-audience-mismatch" as const;
|
|
117
|
+
constructor(readonly controllerBroadcast: boolean) {
|
|
118
|
+
super(
|
|
119
|
+
controllerBroadcast
|
|
120
|
+
? 'HydrationController: a broadcast controller needs a store scoped { audience: "broadcast" }'
|
|
121
|
+
: "HydrationController: a store scoped to a broadcast audience needs a broadcast controller"
|
|
122
|
+
);
|
|
123
|
+
this.name = "HydrationAudienceMismatchError";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An `experienceId` or `principalId` contains NUL, the separator of the broadcast scope key. */
|
|
128
|
+
export class HydrationScopeError extends Error {
|
|
129
|
+
readonly code = "hydration-scope-invalid" as const;
|
|
130
|
+
constructor(readonly field: "experienceId" | "principalId") {
|
|
131
|
+
super(
|
|
132
|
+
`HydrationStore: ${field} must not contain a NUL character (it separates the broadcast scope key)`
|
|
133
|
+
);
|
|
134
|
+
this.name = "HydrationScopeError";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class HydrationStore {
|
|
139
|
+
/** Open token streams by handle (see {@link append}). A replace/remove/empty ends the handle's. */
|
|
140
|
+
private readonly streams = new Map<string, StreamedText>();
|
|
141
|
+
/**
|
|
142
|
+
* Installed by the controller that fronts this store: a refusal for a handle that may not take
|
|
143
|
+
* chunks (restricted for the viewer, unbound, not a streaming widget). Absent on a bare store.
|
|
144
|
+
*/
|
|
145
|
+
private appendGuard: ((handle: string) => Finding | undefined) | undefined;
|
|
146
|
+
private seq = 0; // status seq — monotone per store
|
|
147
|
+
private emitSeq = 0; // envelope sequence — every emit, including remove
|
|
148
|
+
/** The projection state key: the scope, flattened. Derived — never passed in. */
|
|
149
|
+
readonly executionId: string;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Isolation is by (experience, principal); two viewers of one experience must not share a
|
|
153
|
+
* store. The projection is keyed by `executionId`, so a per-viewer store is what keeps one
|
|
154
|
+
* viewer's values out of another's widgets even on a SHARED `ProjectionRuntime`.
|
|
155
|
+
*/
|
|
156
|
+
constructor(
|
|
157
|
+
private readonly runtime: ProjectionRuntime,
|
|
158
|
+
readonly scope: HydrationScope,
|
|
159
|
+
opts: { register?: boolean } = {}
|
|
160
|
+
) {
|
|
161
|
+
// The key below joins with NUL; an id carrying one could forge another scope's key
|
|
162
|
+
// (principal "p\0broadcast" would land on the broadcast scope of "p").
|
|
163
|
+
if (scope.experienceId.includes("\u0000")) throw new HydrationScopeError("experienceId");
|
|
164
|
+
if (scope.principalId?.includes("\u0000")) throw new HydrationScopeError("principalId");
|
|
165
|
+
const base =
|
|
166
|
+
scope.principalId === undefined
|
|
167
|
+
? scope.experienceId
|
|
168
|
+
: `${scope.experienceId}:${scope.principalId}`;
|
|
169
|
+
// NUL cannot appear in a typed id suffix a principal could forge, so a principal named
|
|
170
|
+
// "p:broadcast" never lands on the broadcast scope of "p".
|
|
171
|
+
this.executionId = isBroadcast(scope.audience) ? `${base}\u0000broadcast` : base;
|
|
172
|
+
if (opts.register !== false) runtime.register(hydrationProjection());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
state(): HydrationState {
|
|
176
|
+
return this.runtime.getState<HydrationState>(HYDRATION_PROJECTION_ID, this.executionId);
|
|
177
|
+
}
|
|
178
|
+
status(handle: string): HandleStatus | undefined {
|
|
179
|
+
const s = this.state().status;
|
|
180
|
+
return Object.hasOwn(s, handle) ? s[handle] : undefined;
|
|
181
|
+
}
|
|
182
|
+
value(handle: string): unknown {
|
|
183
|
+
const v = this.state().values;
|
|
184
|
+
return Object.hasOwn(v, handle) ? v[handle] : undefined;
|
|
185
|
+
}
|
|
186
|
+
handles(): string[] {
|
|
187
|
+
return Object.keys(this.state().status);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setStatus(handle: string, status: Omit<HandleStatus, "seq">): void {
|
|
191
|
+
this.assertValidHandle(handle);
|
|
192
|
+
// `empty` drops the value, so a stream continuing onto it would resurrect a value the fold cleared.
|
|
193
|
+
if (status.state === "empty") this.streams.delete(handle);
|
|
194
|
+
this.emit({ kind: "status", handle, status: this.stamp(status) });
|
|
195
|
+
}
|
|
196
|
+
setValue(handle: string, value: unknown, status: Omit<HandleStatus, "seq">): void {
|
|
197
|
+
this.assertValidHandle(handle);
|
|
198
|
+
// A whole-value replace (a controller load, a supersede) ends the handle's token stream: the
|
|
199
|
+
// stream's accumulated text is no longer what the handle holds, so a producer's later chunks
|
|
200
|
+
// are refused (`stream-gap`) instead of being spliced onto the replacement.
|
|
201
|
+
this.streams.delete(handle);
|
|
202
|
+
this.emit({ kind: "value", handle, value, status: this.stamp(status) });
|
|
203
|
+
}
|
|
204
|
+
remove(handle: string): void {
|
|
205
|
+
this.assertValidHandle(handle);
|
|
206
|
+
this.streams.delete(handle);
|
|
207
|
+
this.emit({ kind: "remove", handle });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Gate every {@link append} through `guard` (a refusal finding, or undefined to allow). A
|
|
212
|
+
* `HydrationController` installs one over the store it fronts, so a producer cannot write a
|
|
213
|
+
* viewer-restricted handle's text into the projected store behind the controller's back. A bare
|
|
214
|
+
* store (no controller) has no viewer and no experience to gate against and stays ungated.
|
|
215
|
+
*/
|
|
216
|
+
guardAppend(guard: ((handle: string) => Finding | undefined) | undefined): void {
|
|
217
|
+
this.appendGuard = guard;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Append one chunk of a token stream (`StreamingText`) to a handle. The handle's value is the
|
|
222
|
+
* accumulated string — widgets and the value contract are unchanged — and its status is
|
|
223
|
+
* `pending` while the stream is open and `ready` once a chunk carries `done`.
|
|
224
|
+
*
|
|
225
|
+
* ORDERING with a whole-value writer (`setValue`/`remove`, e.g. the controller superseding a
|
|
226
|
+
* load): they are synchronous and the last writer wins. A replace ENDS the stream; the
|
|
227
|
+
* producer's next chunk then refuses (`stream-gap`, `seq` no longer 0) rather than corrupt the
|
|
228
|
+
* replacement, and a producer that wants to stream again starts a new stream at `seq` 0, which
|
|
229
|
+
* replaces the value. The contract itself (idempotence, gap, close, bounds) is core's
|
|
230
|
+
* `appendChunk`. A refusal emits nothing; a duplicate is `applied: false` and emits nothing.
|
|
231
|
+
*/
|
|
232
|
+
append(
|
|
233
|
+
handle: string,
|
|
234
|
+
chunk: unknown
|
|
235
|
+
): { ok: true; applied: boolean } | { ok: false; finding: Finding } {
|
|
236
|
+
this.assertValidHandle(handle);
|
|
237
|
+
const refusal = this.appendGuard?.(handle);
|
|
238
|
+
if (refusal !== undefined) return { ok: false, finding: refusal };
|
|
239
|
+
const r = appendChunk(this.streams.get(handle), chunk);
|
|
240
|
+
if (!r.ok) return r;
|
|
241
|
+
if (!r.applied) return { ok: true, applied: false };
|
|
242
|
+
this.streams.set(handle, r.state);
|
|
243
|
+
this.emit({
|
|
244
|
+
kind: "value",
|
|
245
|
+
handle,
|
|
246
|
+
value: r.state.text,
|
|
247
|
+
status: this.stamp({ state: r.state.done ? "ready" : "pending" }),
|
|
248
|
+
});
|
|
249
|
+
return { ok: true, applied: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whole-state subscription: fires on ANY handle's value or status change. For a surface that renders the full projection (e.g. a terminal `watch` loop) rather than one widget's slice. */
|
|
253
|
+
runtimeSubscribe(listener: (state: HydrationState) => void): () => void {
|
|
254
|
+
return this.runtime.subscribe<HydrationState>(
|
|
255
|
+
HYDRATION_PROJECTION_ID,
|
|
256
|
+
this.executionId,
|
|
257
|
+
listener
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Slice-scoped: fires only when THIS handle's status seq or value changes. Change-only, like loom's subscribe. */
|
|
262
|
+
subscribe(handle: string, listener: (slice: HandleSlice) => void): () => void {
|
|
263
|
+
return this.runtime.subscribeSelector<HydrationState, HandleSlice>(
|
|
264
|
+
HYDRATION_PROJECTION_ID,
|
|
265
|
+
this.executionId,
|
|
266
|
+
(s) => ({
|
|
267
|
+
value: Object.hasOwn(s.values, handle) ? s.values[handle] : undefined,
|
|
268
|
+
status: Object.hasOwn(s.status, handle) ? s.status[handle] : undefined,
|
|
269
|
+
}),
|
|
270
|
+
listener,
|
|
271
|
+
(a, b) => a.status?.seq === b.status?.seq && a.value === b.value
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private assertValidHandle(handle: string): void {
|
|
276
|
+
if (isReservedHandle(handle)) throw new Error(`handle "${handle}" is reserved`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private stamp(status: Omit<HandleStatus, "seq">): HandleStatus {
|
|
280
|
+
this.seq += 1;
|
|
281
|
+
return { ...status, seq: this.seq };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private emit(payload: SetPayload): void {
|
|
285
|
+
this.emitSeq += 1;
|
|
286
|
+
const envelope: EventEnvelope = {
|
|
287
|
+
id: `loom-ui-hydration-${this.executionId}-${this.emitSeq}`,
|
|
288
|
+
workflowId: "loom-ui.hydration",
|
|
289
|
+
executionId: this.executionId,
|
|
290
|
+
eventType: HYDRATION_SET_EVENT,
|
|
291
|
+
sequence: this.emitSeq,
|
|
292
|
+
timestamp: 0, // transient; never persisted, never compared
|
|
293
|
+
payload,
|
|
294
|
+
};
|
|
295
|
+
this.runtime.handleEvent(envelope);
|
|
296
|
+
}
|
|
297
|
+
}
|