@mmstack/worker 21.0.0 → 21.2.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.
@@ -1,7 +1,8 @@
1
1
  import { generateId, deserializeError, closePort, takeTransferables } from '@mmstack/worker/protocol';
2
2
  export * from '@mmstack/worker/protocol';
3
3
  import { inject, Injector, runInInjectionContext, PLATFORM_ID, signal, DestroyRef, computed, effect, untracked } from '@angular/core';
4
- import { injectTransitionScope, toStore, applyOps, diffOps } from '@mmstack/primitives';
4
+ import { injectTransitionScope, toStore, opSync } from '@mmstack/primitives';
5
+ import { createWatch } from '@angular/core/primitives/signals';
5
6
 
6
7
  /** Thrown into a task/write promise when it is aborted. `name` is `'AbortError'` (DOM convention). */
7
8
  class WorkerAbortError extends Error {
@@ -43,16 +44,16 @@ function build$2(spawn, opt) {
43
44
  let attempt = 0;
44
45
  let runIdSeq = 0;
45
46
  let destroyed = false;
46
- let crashed = false; // between a crash and the next successful handshake
47
+ let crashed = false;
47
48
  let port = null;
48
49
  const send = (msg, transfer) => {
49
50
  if (port)
50
- port.postMessage(msg, transfer); // no-op on the server / between crash and respawn
51
+ port.postMessage(msg, transfer);
51
52
  };
52
53
  const onMessage = (msg) => {
53
54
  switch (msg.type) {
54
55
  case 'ready':
55
- attempt = 0; // a successful handshake resets the backoff
56
+ attempt = 0;
56
57
  crashed = false;
57
58
  manifest.set({
58
59
  hostId: msg.hostId,
@@ -81,10 +82,9 @@ function build$2(spawn, opt) {
81
82
  return;
82
83
  }
83
84
  case 'task:aborted':
84
- taskPending.delete(msg.runId); // caller already rejected at abort
85
+ taskPending.delete(msg.runId);
85
86
  return;
86
87
  default:
87
- // store:* traffic → replicas
88
88
  for (const h of storeHandlers)
89
89
  h(msg);
90
90
  }
@@ -94,7 +94,6 @@ function build$2(spawn, opt) {
94
94
  return;
95
95
  crashed = true;
96
96
  connected.set(false);
97
- // in-flight tasks can't survive a crash; store replicas hold their value and re-hydrate on reconnect
98
97
  for (const [, p] of taskPending)
99
98
  p.reject(new WorkerCrashedError());
100
99
  taskPending.clear();
@@ -109,14 +108,11 @@ function build$2(spawn, opt) {
109
108
  }, delay);
110
109
  };
111
110
  const openPort = () => {
112
- // terminate the previous worker before respawning, so a transient/manual restart never orphans
113
- // a live thread (a genuine crash already killed it; terminate() is then a harmless no-op)
114
111
  if (port)
115
112
  closePort(port);
116
113
  port = spawn();
117
114
  port.onmessage = (ev) => onMessage(ev.data);
118
- // duck-typed crash detection via property setters (NOT addEventListener that perturbs a node
119
- // MessagePort's onmessage delivery). A real `Worker` has onerror; onmessageerror where present.
115
+ // crash detection via property setters, not addEventListener (perturbs node MessagePort delivery)
120
116
  const evt = port;
121
117
  if ('onerror' in evt)
122
118
  evt.onerror = handleCrash;
@@ -124,7 +120,6 @@ function build$2(spawn, opt) {
124
120
  evt.onmessageerror = handleCrash;
125
121
  send({ type: 'hello', proto: 1, clientId });
126
122
  };
127
- // no workers on the server — connected stays false, runTask rejects, replicas render their default
128
123
  if (!isServer)
129
124
  openPort();
130
125
  inject(DestroyRef).onDestroy(() => teardown());
@@ -134,8 +129,6 @@ function build$2(spawn, opt) {
134
129
  for (const [, p] of taskPending)
135
130
  p.reject(new WorkerAbortError('worker connection destroyed'));
136
131
  taskPending.clear();
137
- // same contract as a crash: anything pending on the connection (replica writes) settles NOW
138
- // rather than dangling — a destroyed port can never deliver the echo that would resolve them
139
132
  for (const fn of disconnectHooks)
140
133
  fn();
141
134
  if (port)
@@ -150,9 +143,6 @@ function build$2(spawn, opt) {
150
143
  return Promise.reject(new WorkerCrashedError('no worker on the server'));
151
144
  if (destroyed)
152
145
  return Promise.reject(new WorkerAbortError('worker connection destroyed'));
153
- // between a crash and the respawned handshake the port is dead — a message posted into it
154
- // vanishes and the promise would never settle. Reject honestly; the caller retries/reloads.
155
- // (Before the FIRST handshake this is false: a starting worker buffers messages, so we send.)
156
146
  if (crashed)
157
147
  return Promise.reject(new WorkerCrashedError('worker is restarting'));
158
148
  if (o?.signal?.aborted)
@@ -207,7 +197,7 @@ function build$1(params, options) {
207
197
  const { worker, task } = options;
208
198
  const runTask = (input, signal) => worker.runTask(task, input, { signal });
209
199
  const value = signal(options.defaultValue, { ...(ngDevMode ? { debugName: "value" } : /* istanbul ignore next */ {}), equal: userEqual
210
- ? (a, b) => (a === undefined || b === undefined ? a === b : userEqual(a, b))
200
+ ? (a, b) => a === undefined || b === undefined ? a === b : userEqual(a, b)
211
201
  : undefined });
212
202
  const status = signal('idle', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
213
203
  const error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
@@ -233,7 +223,7 @@ function build$1(params, options) {
233
223
  if (inFlight === ac)
234
224
  inFlight = null;
235
225
  if (myEpoch !== epoch)
236
- return; // superseded — discard
226
+ return;
237
227
  value.set(result);
238
228
  status.set('resolved');
239
229
  }, (err) => {
@@ -241,7 +231,8 @@ function build$1(params, options) {
241
231
  inFlight = null;
242
232
  if (myEpoch !== epoch)
243
233
  return;
244
- if (err instanceof WorkerAbortError || err?.name === 'AbortError')
234
+ if (err instanceof WorkerAbortError ||
235
+ err?.name === 'AbortError')
245
236
  return;
246
237
  error.set(err);
247
238
  status.set('error');
@@ -251,10 +242,9 @@ function build$1(params, options) {
251
242
  const ref = effect(() => {
252
243
  const i = input();
253
244
  if (isServer || i === PAUSED)
254
- return; // server: never run · paused: hold
245
+ return;
255
246
  if (i === undefined)
256
- return; // disabled — keep current value/status
257
- // dedup: a resume to the same input (e.g. after a pause) does not re-run
247
+ return;
258
248
  if (Object.is(i, lastInput) && hasRun && untracked(status) !== 'error')
259
249
  return;
260
250
  untracked(() => start(i));
@@ -277,11 +267,11 @@ function build$1(params, options) {
277
267
  },
278
268
  abort: () => {
279
269
  if (!inFlight)
280
- return; // nothing in flight (a scope's abortPending must not disturb a settled value)
270
+ return;
281
271
  epoch++;
282
272
  inFlight.abort();
283
273
  inFlight = null;
284
- status.set('local'); // value kept
274
+ status.set('local');
285
275
  },
286
276
  destroy: () => {
287
277
  epoch++;
@@ -292,105 +282,133 @@ function build$1(params, options) {
292
282
  if (options.register) {
293
283
  const scope = injectTransitionScope();
294
284
  scope.add(self, { suspends: options.register === 'suspend' });
295
- inject(DestroyRef).onDestroy(() => scope.remove(self));
285
+ let removed = false;
286
+ const remove = () => {
287
+ if (removed)
288
+ return;
289
+ removed = true;
290
+ scope.remove(self);
291
+ };
292
+ inject(DestroyRef).onDestroy(remove);
293
+ const destroy = self.destroy;
294
+ self.destroy = () => {
295
+ remove();
296
+ destroy();
297
+ };
296
298
  }
297
299
  return self;
298
300
  }
299
301
 
302
+ // An injector-free opLog driver (schedules emission off the microtask queue via `createWatch`), so
303
+ // the replica's `opSync` emits without depending on an application tick. Writes force a synchronous
304
+ // `flush()` at the call site; between writes, owner envelopes drive apply, never local emission.
305
+ const microtaskDriver = () => (run) => {
306
+ let scheduled = false;
307
+ const watch = createWatch(() => run(), (w) => {
308
+ if (scheduled)
309
+ return;
310
+ scheduled = true;
311
+ queueMicrotask(() => {
312
+ scheduled = false;
313
+ w.run();
314
+ });
315
+ }, false);
316
+ watch.notify();
317
+ return { destroy: () => watch.destroy() };
318
+ };
300
319
  function workerStore(worker, key, opt) {
301
320
  const injector = opt?.injector ?? inject(Injector);
302
321
  return runInInjectionContext(injector, () => build(worker, key, opt));
303
322
  }
304
323
  function build(worker, key, opt) {
305
- const injector = inject(Injector); // captured for use in write() (called outside injection context)
324
+ const injector = inject(Injector);
306
325
  const root = signal(opt?.defaultValue, ...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
307
- // shape-adaptive: the store reflects `root` even as it goes undefined → object on first snapshot
308
- const replica = toStore(root, {
326
+ const s = toStore(root, {
309
327
  injector,
310
328
  });
329
+ // The replica runs its own opSync over the worker transport: local writes emit stamped envelopes
330
+ // routed to the owner, owner envelopes fold in convergently. A driver (not the injector) so
331
+ // emission stays synchronous under an explicit `flush()`, matching the DI-free worker model.
332
+ const sync = opSync(root, {
333
+ writer: worker.clientId,
334
+ driver: microtaskDriver(),
335
+ onGap: () => resync(),
336
+ });
311
337
  const status = signal('loading', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
312
338
  const error = signal(undefined, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
313
339
  const isLoading = computed(() => {
314
- const s = status();
315
- return s === 'loading' || s === 'reloading';
340
+ const st = status();
341
+ return st === 'loading' || st === 'reloading';
316
342
  }, ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
317
343
  let hasSnapshot = false;
318
- let resyncing = false; // awaiting a fresh snapshot after a detected gap
319
- let lastVersion = 0;
320
- let writeSeq = 0;
344
+ let lastEmitted = 0; // highest own-origin version emitted; the write() ack key
345
+ // own writes awaiting the owner's echo (resolve), plus the raw envelopes to resend after a resync
321
346
  const writePending = new Map();
347
+ const unacked = new Map();
322
348
  const hasValue = () => hasSnapshot;
323
- // resolve any write whose acked authoritative version has now been applied to this replica
324
- const settleWrites = () => {
325
- for (const [id, p] of writePending) {
326
- if (p.version !== null && lastVersion >= p.version) {
327
- writePending.delete(id);
349
+ const isOwned = () => worker.manifest()?.stores.includes(key) ?? false;
350
+ const ackUpTo = (version) => {
351
+ for (const [v, p] of writePending) {
352
+ if (v <= version) {
353
+ writePending.delete(v);
328
354
  p.resolve();
329
355
  }
330
356
  }
357
+ for (const v of [...unacked.keys()])
358
+ if (v <= version)
359
+ unacked.delete(v);
331
360
  };
361
+ const shipUnsub = sync.subscribe((env) => {
362
+ lastEmitted = env.version;
363
+ unacked.set(env.version, env);
364
+ if (isOwned() && untracked(worker.connected)) {
365
+ worker._send({ type: 'store:sync', store: key, env });
366
+ }
367
+ });
332
368
  const onStoreMessage = (msg) => {
333
369
  if (!('store' in msg) || msg.store !== key)
334
370
  return;
335
371
  switch (msg.type) {
336
- case 'store:snapshot': {
337
- root.set(msg.value);
338
- lastVersion = msg.version;
372
+ case 'store:checkpoint': {
373
+ // rebase from the full unacked outbox, not opSync's bounded recent-local ring, so a burst
374
+ // of writes larger than that ring is never dropped from the local rebase on re-hydrate
375
+ sync.hydrate(msg.checkpoint, [
376
+ ...unacked.values(),
377
+ ]);
339
378
  hasSnapshot = true;
340
- resyncing = false; // a fresh snapshot ends any resync
341
379
  status.set('resolved');
342
380
  error.set(undefined);
343
- settleWrites();
344
- return;
345
- }
346
- case 'store:ops': {
347
- if (!hasSnapshot || resyncing)
348
- return; // pre-hydration, or dropping until a fresh snapshot
349
- if (msg.batch.version <= lastVersion)
350
- return; // stale/duplicate (at-least-once transport)
351
- if (msg.batch.version > lastVersion + 1) {
352
- // GAP: a batch was lost — re-hydrate from a fresh snapshot rather than apply out of order
353
- resync();
354
- return;
381
+ // writes the owner already applied are covered by the checkpoint watermark; resolve them,
382
+ // then resend any still-unacked tail so a write made before the (re)hydrate is never lost
383
+ ackUpTo(msg.checkpoint.wm?.[sync.origin] ?? 0);
384
+ if (isOwned() && untracked(worker.connected)) {
385
+ for (const env of [...unacked.values()].sort((a, b) => a.version - b.version)) {
386
+ worker._send({ type: 'store:sync', store: key, env });
387
+ }
355
388
  }
356
- root.set(applyOps(untracked(root), msg.batch.ops));
357
- lastVersion = msg.batch.version;
358
- settleWrites();
359
389
  return;
360
390
  }
361
- case 'store:write:ack': {
362
- const p = writePending.get(msg.writeId);
363
- if (!p)
391
+ case 'store:sync': {
392
+ const env = msg.env;
393
+ if (env.origin === sync.origin) {
394
+ ackUpTo(env.version); // the owner echoed our own write back: acknowledgement
364
395
  return;
365
- if (lastVersion >= msg.version) {
366
- writePending.delete(msg.writeId);
367
- p.resolve(); // the authoritative batch already landed (FIFO: ops precede the ack)
368
- }
369
- else {
370
- p.version = msg.version; // resolve once a batch reaches this version
371
- }
372
- return;
373
- }
374
- case 'store:write:error': {
375
- const p = writePending.get(msg.writeId);
376
- if (p) {
377
- writePending.delete(msg.writeId);
378
- p.reject(deserializeError(msg.error));
379
396
  }
397
+ sync.receive(env); // owner or peer envelope; a version gap triggers onGap -> resync
380
398
  return;
381
399
  }
382
400
  case 'store:status': {
383
- // rung 3: a published derivation's remote status → this replica's status, so an in-flight
384
- // worker computation shows as pending on the main thread (holding the last value)
385
401
  switch (msg.status) {
386
402
  case 'error':
387
- error.set(msg.error ? deserializeError(msg.error) : new Error('remote computation error'));
403
+ error.set(msg.error
404
+ ? deserializeError(msg.error)
405
+ : new Error('remote computation error'));
388
406
  status.set('error');
389
407
  return;
390
408
  case 'loading':
391
409
  case 'reloading':
392
410
  error.set(undefined);
393
- status.set(hasSnapshot ? 'reloading' : 'loading'); // hold the value while recomputing
411
+ status.set(hasSnapshot ? 'reloading' : 'loading');
394
412
  return;
395
413
  case 'resolved':
396
414
  error.set(undefined);
@@ -405,29 +423,33 @@ function build(worker, key, opt) {
405
423
  };
406
424
  const subscribe = () => {
407
425
  if (hasSnapshot)
408
- status.set('reloading'); // holding stale while the fresh snapshot lands
409
- worker._send({ type: 'store:subscribe', store: key, clientId: worker.clientId });
426
+ status.set('reloading');
427
+ worker._send({
428
+ type: 'store:subscribe',
429
+ store: key,
430
+ clientId: worker.clientId,
431
+ });
410
432
  };
411
- // a lost batch left a version gap — hold the stale value and re-subscribe for a fresh snapshot
412
433
  const resync = () => {
413
- resyncing = true;
414
434
  status.set('reloading');
415
- worker._send({ type: 'store:subscribe', store: key, clientId: worker.clientId });
435
+ worker._send({
436
+ type: 'store:subscribe',
437
+ store: key,
438
+ clientId: worker.clientId,
439
+ });
416
440
  };
417
441
  const unsub = worker._subscribe(onStoreMessage);
418
- // subscribe on each `ready` — the first connection and every auto-restart re-hydration; if the
419
- // connection is ALREADY up (created post-handshake), `_onReady` won't fire, so subscribe now
420
442
  const offReady = worker._onReady(subscribe);
421
443
  if (untracked(worker.connected))
422
- subscribe();
423
- // a crash can't be delivered — pending writes reject (the caller holds the value + recipe to retry)
444
+ subscribe(); // already up (created post-handshake): _onReady won't fire
424
445
  const offDisconnect = worker._onDisconnect(() => {
425
446
  for (const [, p] of writePending)
426
447
  p.reject(new WorkerCrashedError());
427
448
  writePending.clear();
449
+ unacked.clear();
428
450
  });
429
451
  const self = {
430
- store: replica,
452
+ store: s,
431
453
  value: root.asReadonly(),
432
454
  status,
433
455
  error,
@@ -438,54 +460,58 @@ function build(worker, key, opt) {
438
460
  const base = untracked(root);
439
461
  if (base === undefined)
440
462
  return Promise.reject(new Error('[@mmstack/worker] cannot write before the replica has hydrated'));
441
- // hydrated but disconnected = the worker crashed (or is restarting) — a write posted now
442
- // vanishes into a dead port and its echo can never arrive. Reject; the caller retries.
443
463
  if (!untracked(worker.connected))
444
464
  return Promise.reject(new WorkerCrashedError('worker is not connected'));
445
- // fork-diff: a scratch store seeded with the CURRENT value (same ref → copy-on-write keeps
446
- // untouched subtrees shared), so diffOps against the base is O(changed paths)
447
- const scratch = signal(base, ...(ngDevMode ? [{ debugName: "scratch" }] : /* istanbul ignore next */ []));
448
- const scratchStore = toStore(scratch, {
449
- injector,
450
- });
451
- recipe(scratchStore);
452
- const ops = diffOps(base, untracked(scratch));
453
- if (!ops.length)
454
- return Promise.resolve();
455
- const writeId = ++writeSeq;
465
+ if (!isOwned())
466
+ return Promise.reject(new Error(`[@mmstack/worker] store is read-only (published): ${key}`));
467
+ recipe(s);
468
+ const before = lastEmitted;
469
+ sync.flush(); // emit the routed write synchronously (driver-backed opSync)
470
+ if (lastEmitted === before)
471
+ return Promise.resolve(); // no-op recipe: nothing shipped
472
+ const version = lastEmitted;
456
473
  return new Promise((resolve, reject) => {
457
- writePending.set(writeId, { resolve, reject, version: null });
458
- worker._send({
459
- type: 'store:write',
460
- store: key,
461
- writeId,
462
- clientId: worker.clientId,
463
- ops,
464
- });
474
+ writePending.set(version, { resolve, reject });
465
475
  });
466
476
  },
467
477
  destroy: () => {
478
+ shipUnsub();
479
+ sync.destroy();
468
480
  unsub();
469
481
  offReady();
470
482
  offDisconnect();
471
- // acks can no longer be received — settle any in-flight write instead of dangling it
472
483
  for (const [, p] of writePending)
473
484
  p.reject(new WorkerAbortError('worker store destroyed'));
474
485
  writePending.clear();
475
- worker._send({ type: 'store:unsubscribe', store: key, clientId: worker.clientId });
486
+ unacked.clear();
487
+ worker._send({
488
+ type: 'store:unsubscribe',
489
+ store: key,
490
+ clientId: worker.clientId,
491
+ });
476
492
  },
477
493
  };
478
494
  inject(DestroyRef).onDestroy(() => self.destroy());
479
495
  if (opt?.register) {
480
496
  const scope = injectTransitionScope();
481
497
  scope.add(self, { suspends: opt.register === 'suspend' });
482
- inject(DestroyRef).onDestroy(() => scope.remove(self));
498
+ let removed = false;
499
+ const remove = () => {
500
+ if (removed)
501
+ return;
502
+ removed = true;
503
+ scope.remove(self);
504
+ };
505
+ inject(DestroyRef).onDestroy(remove);
506
+ const destroy = self.destroy;
507
+ self.destroy = () => {
508
+ remove();
509
+ destroy();
510
+ };
483
511
  }
484
512
  return self;
485
513
  }
486
514
 
487
- // shared wire types, re-exported so main-thread consumers import only from '@mmstack/worker'
488
-
489
515
  /**
490
516
  * Generated bundle index. Do not edit.
491
517
  */