@mmstack/worker 21.0.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/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # @mmstack/worker
2
+
3
+ Split-graph state and compute for Angular. Keep the reactive graph on the main thread for rendering, and hand owned state plus heavy computation to a Web Worker that runs its own graph. The main thread reads live replicas and routes writes; the worker owns the data, derives from it, and answers tasks. State stays in sync automatically over minimal deltas, never full snapshots.
4
+
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/worker/LICENSE)
6
+
7
+ Built on the store op-log from [`@mmstack/primitives`](https://www.npmjs.com/package/@mmstack/primitives): every change crosses the thread boundary as a small batch of path-level ops, applied in one notification wave on the other side.
8
+
9
+ ## Highlights
10
+
11
+ - **Main renders, worker computes.** The worker owns stores and derivations and runs off the main thread. The main thread holds read-only replicas and reads them like any signal store, so heavy work makes the UI pending, not frozen.
12
+ - **Deltas, not snapshots.** State mirrors as op batches (one `set`/`delete` per changed path), diffed by reference identity. The initial hydration is the only snapshot; everything after is a minimal delta.
13
+ - **Single writer, provable convergence.** Each store subtree has exactly one owner (the worker). Writes from the main thread route to that owner, which sequences them and fans the authoritative result back to every replica. No optimistic divergence, no CRDTs. Interleaved writes converge to identical state by construction.
14
+ - **Honest async.** A replica read is synchronous (the value is always held). Only writes and settles are async, and they surface as `pending` through transition scopes rather than promises you await in the template.
15
+ - **The resource surface you already know.** `workerResource` runs a function off-main and exposes `value` / `status` / `error` / `isLoading`, so it drops into `latest()` / `use()` and Suspense boundaries with no adapter.
16
+ - **Typed from the worker's contract.** `connectWorker<typeof host>()` infers store keys, value types, task signatures, and whether a subtree is writable, all from the worker you wrote.
17
+ - **SSR safe.** On the server nothing spawns. Replicas render their default value and resolve once the worker connects in the browser.
18
+ - **Resilient.** Lost batches re-hydrate from a fresh snapshot. A crashed worker respawns with backoff and re-subscribes. Pending writes reject so you can retry.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @mmstack/worker @mmstack/primitives
24
+ ```
25
+
26
+ `@mmstack/primitives`, `@angular/core`, and `@angular/common` are peer dependencies.
27
+
28
+ ## The mental model
29
+
30
+ There are two reactive graphs and a wire between them.
31
+
32
+ **The worker graph** owns the data. You build it inside a worker entry file with `createWorkerHost`. It can:
33
+
34
+ 1. own `stores` (writable signal stores the worker is the single writer of),
35
+ 2. `publish` derivations (read-only `computed`s or async `latest()` values recomputed on the worker),
36
+ 3. expose `tasks` (plain functions the main thread can call).
37
+
38
+ **The main graph** renders. From a component you `connectWorker` (which spawns the worker) and then:
39
+
40
+ 1. `workerStore(worker, key)` gives a live, read-only replica of an owned store, plus `write()` to route a change to the owner,
41
+ 2. `workerStore(worker, key)` on a published key gives a read-only mirror of a derivation (no `write()`),
42
+ 3. `workerResource(...)` runs a task and exposes it with the standard resource surface.
43
+
44
+ Those three capabilities are the "rungs". You can use only the first (run a heavy function off-main) or all three (a subsystem that lives on a worker and syncs to the UI).
45
+
46
+ ## Quick start
47
+
48
+ Two files. First the worker, which owns a counter, publishes stats derived from it, and exposes a heavy `fib` task.
49
+
50
+ ```ts
51
+ // app/demo.worker.ts
52
+ import { computed } from '@angular/core';
53
+ import { store } from '@mmstack/primitives';
54
+ import { createWorkerHost, workerStoreContext } from '@mmstack/worker/host';
55
+
56
+ export type CounterState = { value: number; history: number[] };
57
+ export type Stats = { count: number; sum: number; max: number };
58
+
59
+ // stores in a worker share ONE context (the worker's equivalent of providedIn: 'root')
60
+ const counter = store<CounterState>({ value: 0, history: [] }, workerStoreContext());
61
+
62
+ const stats = computed<Stats>(() => {
63
+ const h = counter().history;
64
+ return { count: h.length, sum: h.reduce((a, b) => a + b, 0), max: h.length ? Math.max(...h) : 0 };
65
+ });
66
+
67
+ const fib = (n: number): number => {
68
+ let a = 0;
69
+ let b = 1;
70
+ for (let i = 0; i < n; i++) [a, b] = [b, a + b];
71
+ return a;
72
+ };
73
+
74
+ const host = createWorkerHost({
75
+ stores: { counter },
76
+ published: { stats },
77
+ tasks: { fib },
78
+ });
79
+
80
+ // the worker's compile-time contract, imported (type-only) by the component below
81
+ export type AppWorker = typeof host;
82
+ ```
83
+
84
+ Then the component. The `new Worker(new URL(...))` literal lives here in app code so the bundler emits the worker chunk.
85
+
86
+ ```ts
87
+ // app/worker-example.ts
88
+ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
89
+ import { connectWorker, workerResource, workerStore } from '@mmstack/worker';
90
+ import type { AppWorker } from './demo.worker';
91
+
92
+ @Component({
93
+ selector: 'app-worker-demo',
94
+ changeDetection: ChangeDetectionStrategy.OnPush,
95
+ template: `
96
+ <p>{{ connected() ? 'connected' : 'connecting…' }}</p>
97
+
98
+ <p>counter: {{ counter.value()?.value ?? 0 }}</p>
99
+ <button (click)="add(1)">+1</button>
100
+
101
+ @if (stats.hasValue()) {
102
+ <p>count {{ stats.value()?.count }}, sum {{ stats.value()?.sum }}</p>
103
+ }
104
+
105
+ <p>fib(35) = {{ fib.isLoading() ? '…' : fib.value() }}</p>
106
+ `,
107
+ })
108
+ export class WorkerDemo {
109
+ // typed against the worker's contract: keys, value types, and write-ability are inferred
110
+ private readonly worker = connectWorker<AppWorker>(
111
+ () => new Worker(new URL('./demo.worker', import.meta.url), { type: 'module' }),
112
+ );
113
+
114
+ readonly connected = this.worker.connected;
115
+ readonly counter = workerStore(this.worker, 'counter'); // CounterState, writable (owned)
116
+ readonly stats = workerStore(this.worker, 'stats'); // Stats, read-only (published)
117
+
118
+ readonly fib = workerResource(() => 35, { worker: this.worker, task: 'fib' });
119
+
120
+ add(n: number): void {
121
+ this.counter.write((draft) => {
122
+ const next = (draft.value() ?? 0) + n;
123
+ draft.value.set(next);
124
+ draft.history.set([...(draft.history() ?? []), next]);
125
+ });
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Entry points
131
+
132
+ The package ships three entry points so worker code never pulls in main-thread code and the wire types can never drift between the two sides.
133
+
134
+ | Import | Where it runs | Contains |
135
+ | --- | --- | --- |
136
+ | `@mmstack/worker` | main thread | `connectWorker`, `workerStore`, `workerResource` |
137
+ | `@mmstack/worker/host` | worker entry file | `createWorkerHost`, `workerStoreContext` |
138
+ | `@mmstack/worker/protocol` | both (types) | `WorkerPortLike`, `transfer`, wire types, schema helpers |
139
+
140
+ ## The worker side: `createWorkerHost`
141
+
142
+ `createWorkerHost` is the whole worker runtime. It has no Angular DI (there is none in a worker), only signals and plain functions. Call it once at the top of your worker entry file. By default it serves the worker's own `self` scope, so your code never touches `self` or `postMessage`.
143
+
144
+ ```ts
145
+ const host = createWorkerHost({
146
+ stores: { todos, filter }, // writable stores the worker owns
147
+ published: { visible }, // derived signals mirrored read-only
148
+ tasks: { search, index }, // callable functions
149
+ });
150
+ export type AppWorker = typeof host;
151
+ ```
152
+
153
+ `workerStoreContext()` returns the one store context for the worker. Pass it to every `store` / `toStore` you create there so they share proxy identity and cleanup. This is the worker's version of `providedIn: 'root'`, and because the `/host` entry only ever loads in a worker, that single instance is scoped to the thread. Never use it on the main thread.
154
+
155
+ ## Connecting: `connectWorker`
156
+
157
+ `connectWorker` spawns the worker and performs the handshake. You pass a factory that returns the worker, not the worker itself, which is what makes it SSR safe (the factory is never called on the server) and what puts the bundler-visible `new Worker(new URL(...))` literal in your app code.
158
+
159
+ ```ts
160
+ const worker = connectWorker<AppWorker>(
161
+ () => new Worker(new URL('./demo.worker', import.meta.url), { type: 'module' }),
162
+ );
163
+
164
+ worker.connected(); // Signal<boolean>, true after the handshake (and after any auto-restart)
165
+ worker.manifest(); // Signal<{ hostId, stores, published, tasks } | null>
166
+ worker.runTask('fib', 35); // Promise<number>, typed from the worker's tasks
167
+ worker.destroy();
168
+ ```
169
+
170
+ The `new Worker(new URL('./x.worker', import.meta.url), { type: 'module' })` form must be written literally. The URL must be a string literal and the second argument must be `import.meta.url`. This is the one shape every modern bundler (the Angular application builder, webpack 5, Vite) recognizes and turns into a separate worker bundle. A computed path silently opts out.
171
+
172
+ ### Manifest typing
173
+
174
+ Pass the host type as `connectWorker<typeof host>()` and everything downstream is inferred:
175
+
176
+ ```ts
177
+ const worker = connectWorker<AppWorker>(spawn);
178
+
179
+ const todos = workerStore(worker, 'todos'); // Todo[], writable (todos is an owned store)
180
+ const visible = workerStore(worker, 'visible'); // read-only, no write() (published)
181
+ workerStore(worker, 'nope'); // wrong key: falls back to a loose ref
182
+ worker.runTask('search', 'foo'); // typed input and result
183
+ worker.runTask('unknown', 1); // compile error: not a task
184
+ ```
185
+
186
+ For an untyped connection you can still set the value type by hand: `workerStore<Todo[]>(worker, 'todos')`.
187
+
188
+ ### Server-side rendering
189
+
190
+ On the server `connectWorker` does not spawn. `connected()` stays `false`, replicas hold their `defaultValue` (or `undefined`), and `runTask` rejects. The subtree renders its "connecting" state and resolves once the worker connects during client hydration.
191
+
192
+ ## `workerResource`: run a function off-main
193
+
194
+ `workerResource` runs work on a worker and exposes it with the resource surface, so a slow computation makes the UI pending instead of frozen. The first argument reactively derives the input; the run re-fires when that input changes, and the latest run wins.
195
+
196
+ ```ts
197
+ const n = signal(40);
198
+
199
+ const fib = workerResource(() => n(), {
200
+ worker: this.worker,
201
+ task: 'fib',
202
+ });
203
+
204
+ fib.value(); // Signal<number | undefined>, held through re-runs
205
+ fib.status(); // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local'
206
+ fib.isLoading();
207
+ fib.error();
208
+ fib.reload(); // re-run with the current input
209
+ fib.abort(); // cancel the in-flight run; the value is kept, status becomes 'local'
210
+ ```
211
+
212
+ Because it satisfies the same shape as a `@mmstack/resource` query, it composes with `latest()` / `use()` and registers into transition scopes:
213
+
214
+ ```ts
215
+ const fib = workerResource(() => n(), { worker, task: 'fib', register: 'indicator' });
216
+ // a boundary above now shows aria-busy while fib runs, holding the previous value
217
+ ```
218
+
219
+ The task named in `{ worker, task }` is a function you exposed on the host with `createWorkerHost({ tasks })`. It runs on the same worker that owns your stores, so a whole subsystem lives on one thread. There is no `eval`, so nothing changes under a strict CSP.
220
+
221
+ ### Options
222
+
223
+ - `params`: `(ctx) => Input | undefined | ctx.paused`. Return `undefined` to disable (idle), or `ctx.paused` (the `PAUSED` symbol) to hold the current value and status.
224
+ - `worker` and `task`: the connected worker and the name of the task to run.
225
+ - `keepPrevious` (default `true`): hold the previous value through a re-run (`status` becomes `'reloading'`).
226
+ - `equal`: a result equality function; an equal result emits no notification.
227
+ - `register`: `'indicator'` or `'suspend'` to join a transition scope.
228
+
229
+ ## `workerStore`: a live replica plus routed writes
230
+
231
+ `workerStore` mirrors a worker-owned store to the main thread. The replica is a real, read-only `SignalStore` with per-leaf reads.
232
+
233
+ ```ts
234
+ const todos = workerStore(this.worker, 'todos');
235
+
236
+ todos.store; // SignalStore<Todo[]>, read deeply like any store
237
+ todos.value(); // Signal<Todo[] | undefined>, undefined before hydration
238
+ todos.status(); // 'loading' until the first snapshot, then 'resolved'
239
+ todos.hasValue();
240
+ todos.connected; // the worker connection's liveness
241
+ ```
242
+
243
+ You cannot set a leaf locally (that would diverge from the owner). To change owned state, route a write:
244
+
245
+ ```ts
246
+ await todos.write((draft) => {
247
+ draft.set([...draft(), { id: 1, text: 'buy milk', done: false }]);
248
+ });
249
+ ```
250
+
251
+ `write` runs your recipe against a scratch draft of the current replica, diffs it to minimal ops, and ships them to the owner. The owner applies them and emits the authoritative batch, which comes back and updates your replica. The promise resolves once that batch has landed locally. Nothing is applied optimistically, which is what guarantees every replica converges to the owner's ordering.
252
+
253
+ ### Optimistic UI
254
+
255
+ Because writes are honest, optimism is opt-in. Keep a local overlay, show it, route the write underneath, and drop the overlay when the authoritative value lands:
256
+
257
+ ```ts
258
+ const optimistic = signal<Todo[] | null>(null);
259
+ const view = computed(() => optimistic() ?? todos.value() ?? []); // render view()
260
+
261
+ async function add(todo: Todo) {
262
+ const next = [...(todos.value() ?? []), todo];
263
+ optimistic.set(next); // shown immediately
264
+ try {
265
+ await todos.write((d) => d.set(next));
266
+ } finally {
267
+ optimistic.set(null); // the owner's authoritative batch has landed on the replica
268
+ }
269
+ }
270
+ ```
271
+
272
+ ## Published subtrees
273
+
274
+ A published entry is a derivation the worker computes. The main thread mirrors it read-only. Because it satisfies the resource surface, an in-flight worker computation shows as pending on the main thread while holding the last value:
275
+
276
+ ```ts
277
+ // worker
278
+ const visible = latest(() => filter(use(query), todos()));
279
+ createWorkerHost({ stores: { todos }, published: { visible } });
280
+
281
+ // main
282
+ const visible = workerStore(this.worker, 'visible');
283
+ visible.status(); // 'reloading' while the worker recomputes, 'resolved' when it settles
284
+ visible.value(); // the last result, held during recompute
285
+ ```
286
+
287
+ Register it with `{ register: 'indicator' }` and a boundary above reflects the worker's pending state. `write()` on a published subtree is a compile error (and rejects at runtime if you reach past the types).
288
+
289
+ ## Transferables
290
+
291
+ For large binary payloads, `transfer` moves an `ArrayBuffer` into the worker instead of cloning it (zero copy, detached at the sender). It applies to a task's input:
292
+
293
+ ```ts
294
+ import { transfer } from '@mmstack/worker';
295
+
296
+ const buffer = new Float64Array(1_000_000).buffer;
297
+ workerResource(() => transfer({ buffer }, [buffer]), { worker, task: 'process' });
298
+ ```
299
+
300
+ ## Resilience
301
+
302
+ - **Lost message.** If a replica sees a version gap it re-subscribes, holds the stale value as `'reloading'`, and re-hydrates from a fresh snapshot.
303
+ - **Duplicate message.** Batches are applied by monotonic version, so a duplicate is dropped.
304
+ - **Crash.** With `restart: 'auto'` (the default) a crashed worker respawns with exponential backoff, re-handshakes, and every live replica re-subscribes. Pending writes reject with `WorkerCrashedError`, so the caller (who still has the value and the recipe) can retry. Pass `restart: 'manual'` to surface the disconnect instead.
305
+
306
+ ## How it works
307
+
308
+ Every owned store on the worker is observed by an op-log (the same `opLog` from `@mmstack/primitives`). A leaf change becomes a minimal batch of path ops, tagged with a monotonic version. The batch is fanned to every subscribed replica, which applies it in a single `set` (one notification wave, regardless of how many ops it carries).
309
+
310
+ Writes route to the owner rather than applying locally. The owner is the single sequencer: it applies incoming write ops through the store, which emits one authoritative owner-origin batch covering that write, and that batch goes to every replica including the writer. The writer's replica updates from that echo, and the echo is also the write's acknowledgement. Because all replicas apply the identical owner-ordered stream, they converge, and the async round trip is honest in the API (a `write()` that returns a promise) rather than hidden behind a `set` you cannot read back synchronously.
311
+
312
+ The protocol rides a minimal structural port (`postMessage` / `onmessage`), so a `MessageChannel` pair works in tests and the same envelope stream can later run over other transports.
313
+
314
+ ## Performance
315
+
316
+ A worker does not make work finish sooner. It makes it finish somewhere other than the thread that paints, so frames keep rendering. The number that matters is main-thread blocking time, not total wall-clock.
317
+
318
+ `benchmarks/data-grid.mjs` runs a grid workload (filter, then a three-column sort, then a group aggregate) over a range of row counts and reports how long the main thread is blocked three ways: inline, on a worker that receives the rows on every call, and on a worker that owns the rows and answers with just the result. Run it with `node packages/worker/benchmarks/data-grid.mjs`. On one machine, main-thread blocking in milliseconds:
319
+
320
+ | rows | inline | worker, ship rows each call | worker owns rows |
321
+ | --- | --- | --- | --- |
322
+ | 10,000 | 0.7 | 1.9 | 0.01 |
323
+ | 100,000 | 11.7 | 19.9 | 0.02 |
324
+ | 250,000 | 36.3 | 53.2 | 0.02 |
325
+ | 500,000 | 81.9 | 109 | 0.02 |
326
+
327
+ Two things fall out of this. First, moving work to a worker is not automatically a win: shipping the whole grid across on every call blocks the main thread more than the inline compute did, because serializing the rows costs more than the work. Second, the win is real once the worker owns the data and only deltas cross, which is exactly what `workerStore` does over the op-log. There the main-thread cost is flat and near zero no matter how large the grid grows, because the rows are hydrated once and never re-sent.
328
+
329
+ So reach for a worker when the data lives there and the main thread reads a replica, not to run a one-shot function over a large payload you hand it each time. For that second case the serialization usually costs more than it saves.
330
+
331
+ ## Notes and limits
332
+
333
+ - Store values that cross the wire must be structured-clonable. Class instances, functions, and `opaque()` values do not serialize.
334
+ - Same-length array reorders diff by value, not identity. Length changes travel as a whole-array op.
335
+ - A single owner per subtree, always. If a subtree needs to move threads, that is a new owner and a re-hydrate, not a protocol feature.
336
+ - Store values must be structured-clonable; a dev-mode guard names the offending store if not.
@@ -0,0 +1,347 @@
1
+ import { generateId, serializeError, PROTO_VERSION } from '@mmstack/worker/protocol';
2
+ export * from '@mmstack/worker/protocol';
3
+ import { isDevMode, untracked } from '@angular/core';
4
+ import { createWatch } from '@angular/core/primitives/signals';
5
+ import { opLog, applyOps, createStoreContext } from '@mmstack/primitives';
6
+
7
+ /**
8
+ * An injector-free {@link OpLogDriver} for `@mmstack/primitives` `opLog`. Schedules the emission
9
+ * reaction on the microtask queue via `createWatch` (Angular's renderer-independent watch
10
+ * primitive) instead of an `effect`, so an opLog can run where there is no Angular injector: the
11
+ * worker side of the worker-graph, whose store has no application tick driving it.
12
+ *
13
+ * This lives in `@mmstack/worker/host` rather than in primitives on purpose. `createWatch` is a
14
+ * lower-tier signals-primitives export; keeping it out of `@mmstack/primitives` lets that package
15
+ * back-port to older Angular majors that may not export it. The worker package tracks a newer
16
+ * Angular where `createWatch` is available.
17
+ *
18
+ * Batching is per-microtask rather than per-app-tick; the worker protocol orders by
19
+ * `(origin, version)`, never tick alignment, so that is safe.
20
+ */
21
+ function microtaskOpLogDriver() {
22
+ return (run) => {
23
+ let scheduled = false;
24
+ // run reads the source (tracking) and flushes; it never writes signals, so allowSignalWrites=false
25
+ const watch = createWatch(() => run(), (w) => {
26
+ if (scheduled)
27
+ return;
28
+ scheduled = true;
29
+ queueMicrotask(() => {
30
+ scheduled = false;
31
+ w.run(); // no-op if the watch is not dirty
32
+ });
33
+ }, false);
34
+ watch.notify(); // bootstrap: schedule the first run to establish the source dependency
35
+ return { destroy: () => watch.destroy() };
36
+ };
37
+ }
38
+
39
+ /** Maps an Angular ResourceStatus to the wire status; only the states the protocol carries. */
40
+ function toRemoteStatus(s) {
41
+ return s === 'idle' || s === 'loading' || s === 'reloading' || s === 'error'
42
+ ? s
43
+ : 'resolved';
44
+ }
45
+ /**
46
+ * Dev-only guard: a store value that is not structured-clonable (a function, a class instance, an
47
+ * `opaque()` value) throws a bare `DataCloneError` from `postMessage` with no context. Catch it
48
+ * before it hits the wire and name the store, so the fix is obvious. Stripped in production.
49
+ */
50
+ function devAssertCloneable(value, what) {
51
+ if (!isDevMode())
52
+ return;
53
+ try {
54
+ structuredClone(value);
55
+ }
56
+ catch (err) {
57
+ throw new Error(`[@mmstack/worker] ${what} holds a value that cannot be sent across the worker boundary. ` +
58
+ `Synced state must be structured-clonable: functions, class instances, and opaque() values ` +
59
+ `do not serialize. Keep it to plain JSON-like data.`, { cause: err });
60
+ }
61
+ }
62
+ function isWorkerGlobal(g) {
63
+ const scope = globalThis
64
+ .WorkerGlobalScope;
65
+ return (typeof scope === 'function' &&
66
+ g instanceof scope &&
67
+ typeof g.postMessage === 'function');
68
+ }
69
+ /**
70
+ * The worker-side runtime: owns store subtrees, hosts tasks, and mirrors owned state to every
71
+ * connected client as op batches. Plain functions + signals — NO Angular DI (there is none in a
72
+ * worker); each owned store is observed by a real {@link opLog} driven off the microtask queue.
73
+ *
74
+ * ```ts
75
+ * // my.worker.ts
76
+ * const todos = store<Todo[]>([], workerStoreContext());
77
+ * createWorkerHost({ stores: { todos } }); // serves `self` by default
78
+ * ```
79
+ */
80
+ function createWorkerHost(options) {
81
+ const hostId = generateId();
82
+ // the S/P/T generics drive the RETURN type only; the body works over loose records
83
+ const sources = (options.stores ?? {});
84
+ const publishedSources = (options.published ?? {});
85
+ const tasks = (options.tasks ?? {});
86
+ const taskNames = Object.keys(tasks);
87
+ const storeKeys = Object.keys(sources);
88
+ const publishedKeys = Object.keys(publishedSources);
89
+ const subtrees = new Map();
90
+ const connections = new Set();
91
+ // current wire status of each status-bearing published entry, read at subscribe time so a late
92
+ // subscriber sees an in-flight computation as pending instead of waiting for the next transition
93
+ const publishedStatus = new Map();
94
+ const statusWatches = [];
95
+ const fanout = (store, message) => {
96
+ for (const conn of Array.from(connections))
97
+ if (conn.stores.has(store))
98
+ conn.port.postMessage(message);
99
+ };
100
+ // observe a signal's value → snapshot/ops. Owned stores also route writes; published are read-only.
101
+ // Applied remote writes ride this same emission, echo-free (the owner is the single sequencer).
102
+ const observe = (key, src, writable) => {
103
+ const entry = {
104
+ read: () => untracked(src),
105
+ log: null,
106
+ version: 0,
107
+ writable,
108
+ };
109
+ entry.log = opLog(
110
+ // a published (read-only) signal is only ever READ by the log — apply() is never called on it
111
+ src, { driver: microtaskOpLogDriver(), origin: hostId });
112
+ entry.log.subscribe((batch) => {
113
+ entry.version = batch.version;
114
+ devAssertCloneable(batch, `store '${key}' update`);
115
+ fanout(key, { type: 'store:ops', store: key, batch });
116
+ });
117
+ subtrees.set(key, entry);
118
+ };
119
+ for (const key of storeKeys)
120
+ observe(key, sources[key], sources[key]);
121
+ for (const key of publishedKeys) {
122
+ const src = publishedSources[key];
123
+ observe(key, src, null);
124
+ // rung 3: a status-bearing derivation (a latest()) propagates its pending/error to replicas
125
+ const statusSig = src.status;
126
+ if (statusSig) {
127
+ publishedStatus.set(key, () => toRemoteStatus(untracked(statusSig)));
128
+ let scheduled = false;
129
+ let last = null;
130
+ const w = createWatch(() => {
131
+ const s = statusSig();
132
+ if (s === last)
133
+ return;
134
+ last = s;
135
+ fanout(key, {
136
+ type: 'store:status',
137
+ store: key,
138
+ status: toRemoteStatus(s),
139
+ });
140
+ }, (watch) => {
141
+ if (scheduled)
142
+ return;
143
+ scheduled = true;
144
+ queueMicrotask(() => {
145
+ scheduled = false;
146
+ watch.run();
147
+ });
148
+ }, false);
149
+ w.notify();
150
+ statusWatches.push(w);
151
+ }
152
+ }
153
+ const runTask = (conn, runId, handler, input) => {
154
+ const ac = new AbortController();
155
+ conn.taskRuns.set(runId, ac);
156
+ Promise.resolve()
157
+ .then(() => handler(input, { signal: ac.signal }))
158
+ .then((value) => {
159
+ if (ac.signal.aborted)
160
+ conn.port.postMessage({ type: 'task:aborted', runId });
161
+ else
162
+ conn.port.postMessage({ type: 'task:ok', runId, value });
163
+ }, (err) => {
164
+ if (ac.signal.aborted)
165
+ conn.port.postMessage({ type: 'task:aborted', runId });
166
+ else
167
+ conn.port.postMessage({
168
+ type: 'task:error',
169
+ runId,
170
+ error: serializeError(err),
171
+ });
172
+ })
173
+ .finally(() => {
174
+ if (conn.taskRuns.get(runId) === ac)
175
+ conn.taskRuns.delete(runId);
176
+ });
177
+ };
178
+ const handle = (conn, msg) => {
179
+ switch (msg.type) {
180
+ case 'hello': {
181
+ conn.clientId = msg.clientId;
182
+ conn.port.postMessage({
183
+ type: 'ready',
184
+ proto: PROTO_VERSION,
185
+ hostId,
186
+ stores: storeKeys,
187
+ published: publishedKeys,
188
+ tasks: taskNames,
189
+ });
190
+ return;
191
+ }
192
+ case 'store:subscribe': {
193
+ const sub = subtrees.get(msg.store);
194
+ if (!sub)
195
+ return; // unknown store — silently ignore (a client typo, not fatal)
196
+ // synchronous: read snapshot + version and register the client in one turn — no batch can
197
+ // interleave in a single-threaded worker, so every later op carries version > this one
198
+ conn.stores.add(msg.store);
199
+ const snapshot = sub.read();
200
+ devAssertCloneable(snapshot, `store '${msg.store}' snapshot`);
201
+ conn.port.postMessage({
202
+ type: 'store:snapshot',
203
+ store: msg.store,
204
+ version: sub.version,
205
+ value: snapshot,
206
+ });
207
+ // a status-bearing published entry also reports its CURRENT status, so a subscriber
208
+ // arriving mid-computation shows pending now, not on the next transition
209
+ const currentStatus = publishedStatus.get(msg.store);
210
+ if (currentStatus)
211
+ conn.port.postMessage({
212
+ type: 'store:status',
213
+ store: msg.store,
214
+ status: currentStatus(),
215
+ });
216
+ return;
217
+ }
218
+ case 'store:unsubscribe': {
219
+ conn.stores.delete(msg.store);
220
+ return;
221
+ }
222
+ case 'task:run': {
223
+ const handler = tasks[msg.task];
224
+ if (!handler) {
225
+ conn.port.postMessage({
226
+ type: 'task:error',
227
+ runId: msg.runId,
228
+ error: serializeError(new Error(`[@mmstack/worker] unknown task: ${msg.task}`)),
229
+ });
230
+ return;
231
+ }
232
+ runTask(conn, msg.runId, handler, msg.input);
233
+ return;
234
+ }
235
+ case 'task:abort': {
236
+ conn.taskRuns.get(msg.runId)?.abort();
237
+ conn.taskRuns.delete(msg.runId);
238
+ return;
239
+ }
240
+ case 'store:write': {
241
+ const sub = subtrees.get(msg.store);
242
+ if (!sub || !sub.writable) {
243
+ conn.port.postMessage({
244
+ type: 'store:write:error',
245
+ store: msg.store,
246
+ writeId: msg.writeId,
247
+ error: serializeError(new Error(sub
248
+ ? `[@mmstack/worker] store is read-only (published): ${msg.store}`
249
+ : `[@mmstack/worker] unknown store: ${msg.store}`)),
250
+ });
251
+ return;
252
+ }
253
+ try {
254
+ // the owner is the single sequencer: apply the routed ops through the store root, then
255
+ // FLUSH so its opLog emits ONE authoritative owner-origin batch (fanned to every client,
256
+ // the writer included — the echo that IS the write's confirmation). No baseline-advance
257
+ // trick: this is a genuine owner write, indistinguishable from the worker writing itself.
258
+ sub.writable.set(applyOps(sub.read(), msg.ops));
259
+ sub.log.flush();
260
+ conn.port.postMessage({
261
+ type: 'store:write:ack',
262
+ store: msg.store,
263
+ writeId: msg.writeId,
264
+ version: sub.version,
265
+ });
266
+ }
267
+ catch (err) {
268
+ conn.port.postMessage({
269
+ type: 'store:write:error',
270
+ store: msg.store,
271
+ writeId: msg.writeId,
272
+ error: serializeError(err),
273
+ });
274
+ }
275
+ return;
276
+ }
277
+ }
278
+ };
279
+ const connect = (port) => {
280
+ const conn = {
281
+ port,
282
+ clientId: null,
283
+ stores: new Set(),
284
+ taskRuns: new Map(),
285
+ };
286
+ connections.add(conn);
287
+ // assigning onmessage starts a MessagePort; a Worker/self delivers the same way
288
+ port.onmessage = (ev) => handle(conn, ev.data);
289
+ return () => {
290
+ connections.delete(conn);
291
+ port.onmessage = null; // a disconnected client must not keep invoking tasks/writes
292
+ for (const ac of conn.taskRuns.values())
293
+ ac.abort();
294
+ conn.taskRuns.clear();
295
+ };
296
+ };
297
+ const defaultPort = options.port ??
298
+ (isWorkerGlobal(globalThis)
299
+ ? globalThis
300
+ : undefined);
301
+ if (defaultPort)
302
+ connect(defaultPort);
303
+ return {
304
+ hostId,
305
+ connect,
306
+ flush() {
307
+ for (const { log } of subtrees.values())
308
+ log.flush();
309
+ },
310
+ dispose() {
311
+ for (const { log } of subtrees.values())
312
+ log.destroy();
313
+ for (const w of statusWatches)
314
+ w.destroy();
315
+ connections.clear();
316
+ },
317
+ };
318
+ }
319
+
320
+ // Memoized at THIS module's scope. `@mmstack/worker/host` only ever loads inside a worker, so a
321
+ // module-scope singleton here is a per-thread singleton — the worker equivalent of an app's root
322
+ // injector (providedIn: 'root'). All stores in the worker share it; it is the graph's single
323
+ // proxy-identity + GC-coordination point.
324
+ let context;
325
+ /**
326
+ * The one shared store context for this worker — pass it to every `store`/`toStore` you create in
327
+ * the worker so they share proxy identity and cleanup, exactly as `providedIn: 'root'` shares one
328
+ * cache across an app's stores.
329
+ *
330
+ * ```ts
331
+ * // my.worker.ts
332
+ * const todos = store<Todo[]>([], workerStoreContext());
333
+ * const filter = store({ q: '' }, workerStoreContext()); // same context
334
+ * ```
335
+ */
336
+ function workerStoreContext() {
337
+ return (context ??= createStoreContext());
338
+ }
339
+
340
+ // shared wire types, re-exported so worker code imports only from '@mmstack/worker/host'
341
+
342
+ /**
343
+ * Generated bundle index. Do not edit.
344
+ */
345
+
346
+ export { createWorkerHost, microtaskOpLogDriver, workerStoreContext };
347
+ //# sourceMappingURL=mmstack-worker-host.mjs.map