@bayonai/offline 0.13.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-11-07
4
+
5
+ - Add framework-neutral mounted read observation, atomic batch presentation and
6
+ native browser wake binding, with host-owned authorization, transport, cache
7
+ policy and exact-snapshot retirement. Preserve original source age, independent
8
+ source promises, scoped disposal and initial-only refresh intent.
9
+ - Add opt-in canonical cache delivery with atomic commit receipts. Newer
10
+ cross-instance confirmations and local invalidation fence obsolete presentation;
11
+ default immediate callbacks and nonblocking writes remain unchanged.
12
+ - Preserve canonical updates separately from raw child results and retire denied
13
+ groups immediately, including before initial settlement. Handle reentrant wakes,
14
+ undefined positions/errors and listener cleanup without duplicate reads.
15
+
16
+ ## 0.14.0 - 2026-11-07
17
+
18
+ - Serialize overlapping replay calls with different selection or execution
19
+ policies per store and scope. Identical calls still coalesce; each distinct
20
+ caller receives its own results. Callers that depended on scope-only
21
+ coalescing should use stable callback references or handle separate summaries.
22
+
3
23
  ## 0.13.0 - 2026-11-06
4
24
 
5
25
  - Add opt-in host-classified read failure handling, conditional terminal
package/README.md CHANGED
@@ -78,6 +78,21 @@ An opt-in callback can classify the original failure as `"use-retained"`,
78
78
  `"throw"`, or `"retire"`. Retire propagates the original failure and
79
79
  conditionally removes the captured snapshot; a later confirmation is preserved.
80
80
  Built-in memory and IndexedDB drivers support atomic snapshot comparison.
81
+ `loadReadCacheResource({ deliveryPolicy: "cache-current", ... })` opts into
82
+ awaiting an atomic commit receipt before presenting the canonical retained
83
+ cache snapshot. Canonical updates carry `cache-hit` provenance and their stored
84
+ age, including when this call wrote that snapshot. The returned promise still
85
+ settles with this call's source result. A lost ownership comparison may adopt
86
+ another instance's eligible confirmation; local invalidation suppresses delivery.
87
+ This fences confirmations committed before the comparison, not future writes or
88
+ cross-tab notifications. The default `immediate` policy keeps nonblocking writes.
89
+ With `requireSource`, only the successfully committed source result can be
90
+ presented as source-confirmed; a lost ownership comparison suppresses delivery.
91
+ Opted-in presentation callback errors are logged independently of source success.
92
+ Custom caches must implement `commitIfVersion`, returning `{ written, version }`
93
+ with the generation captured inside the atomic commit after its own invalidation.
94
+ Built-in caches require the driver's atomic `setIf` for this mode. Missing atomic
95
+ support or storage failure logs an error and suppresses uncertain presentation.
81
96
  Custom adapters must implement `deleteIfVersion` with equivalent atomic
82
97
  ownership checks; without it, retirement is logged and cannot be guaranteed.
83
98
  New envelopes carry an opaque snapshot identifier. Existing version-one
@@ -279,6 +294,17 @@ the later write queued until the predecessor is delivered. This protects remote
279
294
  write order; it does not replace idempotent server-side mutation handling for
280
295
  ambiguous request outcomes.
281
296
 
297
+ Overlapping `syncPendingWrites` calls for the same store and scope share a
298
+ single replay only when their selection and sync callbacks, clock, and
299
+ stop-on-failure setting are identical. Different requests run in arrival order
300
+ and each receives results for its own selection. Use stable callback references
301
+ when callers intentionally share a run. Separate scopes remain independent.
302
+ A failed storage operation rejects its own call and does not prevent a later
303
+ queued recovery from attempting a fresh read. Migration: callers that previously
304
+ relied on scope-only coalescing with newly created inline callbacks should either
305
+ keep stable callback references or expect each serialized call to report its own
306
+ attempts and remaining count.
307
+
282
308
  ## Durable Command Store
283
309
 
284
310
  Use `createDurableCommandStore` when a host needs one client-side owner for
@@ -507,3 +533,52 @@ Read failures retain the current account's last known rows and expose `readError
507
533
  provide `onError` for host diagnostics (otherwise the tracker logs to console).
508
534
  Call `setOffline` when connectivity changes. The tracker never persists, executes,
509
535
  consolidates or retires operations, and adds no schema, jobs or provider imports.
536
+
537
+ ## Mounted read presentation
538
+
539
+ `createReadResourceObserver<T, Snapshot>` owns mounted refresh presentation over
540
+ the host's existing cache and shared source reader. The host calls
541
+ `accept(update, confirmedAt)` for its initial delivery; the timestamp must be
542
+ the original cache/source confirmation time. The controller stores age and
543
+ eligibility, not another copy of the value. `canRetain(data)` excludes mutation
544
+ affordances or other values requiring a strict contract.
545
+
546
+ `refresh()` explicitly forces a confirmation; wake subscriptions invoke ordinary
547
+ freshness checks. A fresh canonical host snapshot may satisfy ordinary recovery
548
+ without a source request. A host-classified superseded read adopts that snapshot
549
+ immediately. Transient failures preserve the mounted value and original age;
550
+ terminal failures call `retireSnapshot` with the exact token captured before the
551
+ read, clear confirmation, and emit `failed`. The host must condition retirement
552
+ on token ownership and current authority. Failed cleanup is logged separately
553
+ and does not hide the terminal failure.
554
+
555
+ The host owns initial force versus persistent strict mode, identity, authorization,
556
+ connectivity/visibility, read eligibility, TTLs and transport sharing. Set
557
+ `enabled: false` for strict/noncacheable/command observations; their initial data
558
+ can still be accepted but they never subscribe or refresh. The controller uses
559
+ no timer or retry engine. `subscribeWake` returns an unsubscribe function;
560
+ `dispose()` silently fences callbacks and detaches it. `retire()` emits one safe
561
+ invalidation even after scope changes. Neither cancels an already-running shared
562
+ source promise.
563
+
564
+ `observeReadResourceBatch<T>` composes host child observations into one mounted
565
+ presentation barrier. It retains child order, duplicate positions and explicit
566
+ `undefined` results. Children retain all source/cache ownership; the barrier
567
+ records only their current presentation values and source ages. During refresh,
568
+ a temporary failure keeps the last complete array visible. A terminal child
569
+ failure retires the whole presentation immediately, including while another
570
+ child stalls, and later sibling completions cannot revive it.
571
+
572
+ The initial presentation follows accepted child callbacks, filling positions
573
+ without a callback from settled child promises. The batch promise preserves the
574
+ original child promise results, which may differ from newer canonical callback
575
+ values. Use child callbacks for presentation and promise results for source-call
576
+ settlement. Forced refresh, ordinary freshness checks, strict-value exclusion,
577
+ scope checks and disposal follow the scalar contract. No second cache, automatic
578
+ write replay, schema change or scheduling service is introduced.
579
+
580
+ `bindBrowserReadLifecycle({ window, document }, notify)` forwards native focus,
581
+ online and visibility-change events and returns idempotent cleanup. Hosts keep
582
+ visibility/connectivity checks and transport coordination inside `notify`.
583
+ Synchronous throws and rejected wake promises are logged without escaping the
584
+ native event callback. This binding does not acquire pending-write replay ownership.
package/RECOVERY.md CHANGED
@@ -10,6 +10,9 @@ This is the package-owned product contract for all offline consumers, establishe
10
10
  - `createBrowserPendingWriteRecovery` owns silent startup, focus, reconnect and
11
11
  capped exponential-backoff recovery. Reuse its coordination and ordered executor;
12
12
  never add a second replay owner, a billable job or a recurring server service.
13
+ - Concurrent replay for one store and scope serializes different selection and
14
+ execution policies; only identical requests share results. Each caller must
15
+ see its own selection and failure policy, including after a rejected read.
13
16
  - `getPendingChangesGuidance` supplies common guidance without domain payloads.
14
17
  Ready work syncs automatically; unfinished drafts need editing before submission.
15
18
  Rejected work stays preserved. No retry button is part of pending inspection.
@@ -0,0 +1,5 @@
1
+ /** Native wake binding only; hosts own visibility, eligibility and request sharing. */
2
+ export declare function bindBrowserReadLifecycle(targets: {
3
+ window: EventTarget;
4
+ document: EventTarget;
5
+ }, notify: () => Promise<void>): () => void;
@@ -0,0 +1,24 @@
1
+ /** Native wake binding only; hosts own visibility, eligibility and request sharing. */
2
+ export function bindBrowserReadLifecycle(targets, notify) {
3
+ const report = (error) => console.error("[Bayon offline] Browser read refresh failed.", { error });
4
+ const wake = () => {
5
+ try {
6
+ void notify().catch(report);
7
+ }
8
+ catch (error) {
9
+ report(error);
10
+ }
11
+ };
12
+ targets.window.addEventListener("focus", wake);
13
+ targets.window.addEventListener("online", wake);
14
+ targets.document.addEventListener("visibilitychange", wake);
15
+ let stopped = false;
16
+ return () => {
17
+ if (stopped)
18
+ return;
19
+ stopped = true;
20
+ targets.window.removeEventListener("focus", wake);
21
+ targets.window.removeEventListener("online", wake);
22
+ targets.document.removeEventListener("visibilitychange", wake);
23
+ };
24
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from "./readCache.js";
2
+ export * from "./readResourceObserver.js";
3
+ export * from "./readResourceBatch.js";
4
+ export * from "./browserReadLifecycle.js";
2
5
  export * from "./pendingWrites.js";
3
6
  export * from "./browserRecovery.js";
4
7
  export * from "./browserCoordination.js";
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from "./readCache.js";
2
+ export * from "./readResourceObserver.js";
3
+ export * from "./readResourceBatch.js";
4
+ export * from "./browserReadLifecycle.js";
2
5
  export * from "./pendingWrites.js";
3
6
  export * from "./browserRecovery.js";
4
7
  export * from "./browserCoordination.js";
@@ -151,9 +151,9 @@ export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
151
151
  sync: PendingWriteSync<Payload>;
152
152
  }): Promise<SyncPendingWriteResult>;
153
153
  /**
154
- * Replays selected writes oldest first and coalesces overlapping runs for the
155
- * same store/scope. Callers own the selection predicate and idempotent remote
156
- * mutation semantics.
154
+ * Replays selected writes oldest first. Identical overlapping calls coalesce;
155
+ * calls with different options run serially for the same store and scope.
156
+ * Callers own the selection predicate and idempotent remote mutation semantics.
157
157
  */
158
158
  export declare function syncPendingWrites<Payload>({ now, scopeKey, select, stopOnFailure, store, sync, }: {
159
159
  now?: () => string;
@@ -285,30 +285,34 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
285
285
  : { status: "superseded" };
286
286
  }
287
287
  /**
288
- * Replays selected writes oldest first and coalesces overlapping runs for the
289
- * same store/scope. Callers own the selection predicate and idempotent remote
290
- * mutation semantics.
288
+ * Replays selected writes oldest first. Identical overlapping calls coalesce;
289
+ * calls with different options run serially for the same store and scope.
290
+ * Callers own the selection predicate and idempotent remote mutation semantics.
291
291
  */
292
- export function syncPendingWrites({ now, scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
292
+ export function syncPendingWrites({ now, scopeKey = "all", select = selectAllPendingWrites, stopOnFailure = false, store, sync, }) {
293
293
  let inFlightByScope = pendingWriteSyncs.get(store);
294
294
  if (!inFlightByScope) {
295
295
  inFlightByScope = new Map();
296
296
  pendingWriteSyncs.set(store, inFlightByScope);
297
297
  }
298
298
  const existing = inFlightByScope.get(scopeKey);
299
- if (existing) {
300
- return existing;
299
+ if (existing &&
300
+ existing.select === select &&
301
+ existing.sync === sync &&
302
+ existing.now === now &&
303
+ existing.stopOnFailure === stopOnFailure) {
304
+ return existing.promise;
301
305
  }
302
- const syncRun = performPendingWriteSync({
303
- now,
304
- select,
305
- stopOnFailure,
306
- store,
307
- sync,
308
- });
309
- inFlightByScope.set(scopeKey, syncRun);
306
+ const run = () => performPendingWriteSync({ now, select, stopOnFailure, store, sync });
307
+ // Product decision: distinct filters must never share counts or run in
308
+ // parallel; each waits for the preceding scope run and uses its own policy.
309
+ const syncRun = existing ? existing.promise.then(run, run) : run();
310
+ const entry = { now, select, stopOnFailure, sync, promise: syncRun };
311
+ inFlightByScope.set(scopeKey, entry);
310
312
  const clearInFlight = () => {
311
- inFlightByScope.delete(scopeKey);
313
+ if (inFlightByScope.get(scopeKey) === entry) {
314
+ inFlightByScope.delete(scopeKey);
315
+ }
312
316
  };
313
317
  void syncRun.then(clearInFlight, clearInFlight);
314
318
  return syncRun;
@@ -346,6 +350,7 @@ export function summarizePendingWrites(records) {
346
350
  return summary;
347
351
  }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
348
352
  }
353
+ const selectAllPendingWrites = () => true;
349
354
  const pendingWriteSyncs = new WeakMap();
350
355
  async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
351
356
  const summary = {
@@ -23,6 +23,11 @@ export type ReadCacheDriver = {
23
23
  setIf?: (key: string, value: unknown, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
24
24
  };
25
25
  export type ReadCache = {
26
+ /** Atomic ownership proof for opt-in canonical presentation; version is captured at commit. */
27
+ commitIfVersion?: <T>(key: string, value: T, version: string, snapshot: ReadCacheHit<unknown> | null, confirmedAt?: string) => Promise<{
28
+ written: boolean;
29
+ version: string;
30
+ }>;
26
31
  /**
27
32
  * Resolves a stable cache for the duration of a resource load. Dynamic
28
33
  * adapters use this to pin the active account before asynchronous work.
@@ -119,8 +124,10 @@ export declare function readThroughCache<T>({ cache, forceFresh, includeProvenan
119
124
  policy?: ReadCachePolicy;
120
125
  requireSource?: boolean;
121
126
  } & ReadCacheLoader<T>): Promise<ReadThroughCacheResult<T>>;
122
- export declare function loadReadCacheResource<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, requireSource, }: {
127
+ export declare function loadReadCacheResource<T>({ cache, deliveryPolicy, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, requireSource, }: {
123
128
  cache: ReadCache;
129
+ /** Opt in to presenting the eligible canonical cache after conditional persistence. */
130
+ deliveryPolicy?: "immediate" | "cache-current";
124
131
  /** Revalidate while retaining eligible data as fallback on loader failure. */
125
132
  forceFresh?: boolean;
126
133
  includeProvenance?: boolean;
package/dist/readCache.js CHANGED
@@ -48,7 +48,7 @@ export function createReadCache(driver) {
48
48
  operationQueue = next.then(() => undefined, () => undefined);
49
49
  return next;
50
50
  }
51
- async function writeIfVersion(key, value, version, snapshot, savedAt) {
51
+ async function writeIfVersion(key, value, version, snapshot, savedAt, receipt) {
52
52
  if (getVersion(key) !== version)
53
53
  return false;
54
54
  return enqueue(async () => {
@@ -64,15 +64,28 @@ export function createReadCache(driver) {
64
64
  const written = await driver.setIf(key, next, (current) => getVersion(key) === version && ownsSnapshot(current, snapshot));
65
65
  if (!written)
66
66
  return false;
67
+ // A driver may finish after the caller has retired this generation.
68
+ // Keep that retirement visible instead of issuing a new owned receipt.
69
+ if (receipt && getVersion(key) !== version)
70
+ return false;
67
71
  }
68
72
  else {
69
73
  await driver.set(key, next);
70
74
  }
71
75
  invalidate(key);
76
+ if (receipt)
77
+ receipt.version = getVersion(key);
72
78
  return true;
73
79
  });
74
80
  }
75
81
  const cache = {
82
+ async commitIfVersion(key, value, version, snapshot, confirmedAt) {
83
+ if (!driver.setIf)
84
+ throw new Error("[Bayon offline] Canonical presentation requires atomic conditional cache writes.");
85
+ const receipt = { version };
86
+ const written = await writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString(), receipt);
87
+ return { written, version: receipt.version };
88
+ },
76
89
  async clear() {
77
90
  pendingLoads.clear();
78
91
  globalVersion += 1;
@@ -339,7 +352,7 @@ export async function readThroughCache({ cache, forceFresh = false, includeProve
339
352
  throw error;
340
353
  }
341
354
  }
342
- export async function loadReadCacheResource({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
355
+ export async function loadReadCacheResource({ cache, deliveryPolicy = "immediate", forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
343
356
  const resourceCache = cache.capture?.() ?? cache;
344
357
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
345
358
  const capturedVersion = resourceCache.getVersion(key);
@@ -367,11 +380,56 @@ export async function loadReadCacheResource({ cache, forceFresh = false, include
367
380
  // Capture ownership even when presentation skips the cached value.
368
381
  const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
369
382
  const cached = skipCache ? null : writeSnapshot;
370
- const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
383
+ const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy)
384
+ && (deliveryPolicy === "immediate" || resourceCache.getVersion(key) === version);
385
+ function publishUpdate(update) {
386
+ if (deliveryPolicy === "immediate") {
387
+ onUpdate(update);
388
+ return;
389
+ }
390
+ try {
391
+ onUpdate(update);
392
+ }
393
+ catch (error) {
394
+ // Opted-in presentation is independent of the caller's source outcome.
395
+ console.error("[Bayon offline] Read-cache presentation callback failed.", {
396
+ error: normalizeError(error), key,
397
+ });
398
+ }
399
+ }
400
+ function reportPresentationStorageError(error) {
401
+ const normalizedError = normalizeError(error);
402
+ console.error("[Bayon offline] Unable to confirm current read-cache presentation.", {
403
+ error: normalizedError, key,
404
+ });
405
+ onCacheWriteError?.(normalizedError);
406
+ }
407
+ async function canonicalUpdate(expectedVersion) {
408
+ if (resourceCache.getVersion(key) !== expectedVersion)
409
+ return;
410
+ let canonical;
411
+ try {
412
+ canonical = await resourceCache.get(key);
413
+ }
414
+ catch (error) {
415
+ reportPresentationStorageError(error);
416
+ return;
417
+ }
418
+ if (!canonical || resourceCache.getVersion(key) !== expectedVersion
419
+ || !isCacheRetained(canonical.savedAt, resolvedPolicy))
420
+ return;
421
+ return {
422
+ data: canonical.value, fromCache: true,
423
+ status: isCacheFresh(canonical.savedAt, resolvedPolicy) ? "fresh" : "stale",
424
+ ...(includeProvenance ? {
425
+ provenance: "cache-hit", savedAt: canonical.savedAt,
426
+ } : {}),
427
+ };
428
+ }
371
429
  if (retained && !requireSource) {
372
430
  const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
373
431
  reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
374
- onUpdate({
432
+ publishUpdate({
375
433
  data: cached.value,
376
434
  fromCache: true,
377
435
  status: fresh && !forceFresh ? "fresh" : "stale",
@@ -393,8 +451,14 @@ export async function loadReadCacheResource({ cache, forceFresh = false, include
393
451
  throw (source.originalError ??
394
452
  source.error ??
395
453
  new Error("[Bayon offline] Source confirmation was required."));
454
+ if (deliveryPolicy === "cache-current") {
455
+ const update = await canonicalUpdate(version);
456
+ if (update)
457
+ publishUpdate(update);
458
+ return source.data;
459
+ }
396
460
  if (resourceCache.getVersion(key) === version)
397
- onUpdate({
461
+ publishUpdate({
398
462
  data: source.data,
399
463
  fromCache: source.fromCache,
400
464
  status: source.status,
@@ -411,8 +475,35 @@ export async function loadReadCacheResource({ cache, forceFresh = false, include
411
475
  }
412
476
  const fresh = source.data;
413
477
  reportReadCacheOutcome(onOutcome, "loader-success");
478
+ if (deliveryPolicy === "cache-current") {
479
+ let update;
480
+ try {
481
+ if (!resourceCache.commitIfVersion)
482
+ throw new Error("[Bayon offline] Canonical presentation requires an atomic commit receipt.");
483
+ const receipt = await resourceCache.commitIfVersion(key, fresh, version, writeSnapshot, source.confirmedAt);
484
+ if (requireSource) {
485
+ // Source-required consumers may never mistake a readback for proof.
486
+ if (receipt.written && resourceCache.getVersion(key) === receipt.version)
487
+ update = {
488
+ data: fresh, fromCache: false, status: retained ? "fresh" : "miss",
489
+ ...(includeProvenance ? {
490
+ provenance: "source-confirmed", confirmedAt: source.confirmedAt,
491
+ } : {}),
492
+ };
493
+ }
494
+ else
495
+ update = await canonicalUpdate(receipt.version);
496
+ }
497
+ catch (error) {
498
+ reportPresentationStorageError(error);
499
+ }
500
+ // Keep observer exceptions outside the storage diagnostic boundary.
501
+ if (update)
502
+ publishUpdate(update);
503
+ return fresh;
504
+ }
414
505
  if (resourceCache.getVersion(key) === version) {
415
- onUpdate({
506
+ publishUpdate({
416
507
  data: fresh,
417
508
  fromCache: false,
418
509
  status: retained ? "fresh" : "miss",
@@ -442,7 +533,18 @@ export async function loadReadCacheResource({ cache, forceFresh = false, include
442
533
  if (requireSource || !retained || failure !== "use-retained") {
443
534
  throw error;
444
535
  }
445
- onUpdate({
536
+ if (deliveryPolicy === "cache-current") {
537
+ const update = await canonicalUpdate(version);
538
+ if (update)
539
+ publishUpdate({
540
+ ...update, status: "stale", error: normalizeError(error),
541
+ ...(includeProvenance ? {
542
+ provenance: "retained-fallback", originalError: error,
543
+ } : {}),
544
+ });
545
+ return cached.value;
546
+ }
547
+ publishUpdate({
446
548
  data: cached.value,
447
549
  error: normalizeError(error),
448
550
  fromCache: true,
@@ -0,0 +1,13 @@
1
+ import type { ReadResourceObservation, ReadResourceUpdate } from "./readResourceObserver.js";
2
+ /** A mounted presentation barrier; child tasks retain sole network/cache ownership. */
3
+ export declare function observeReadResourceBatch<T>({ count, observe, freshForMs, now, current, canRetain, retiredError, subscribe, onUpdate }: {
4
+ count: number;
5
+ observe: (index: number, update: (value: ReadResourceUpdate<T>) => void, age: (createdAt: number) => void) => ReadResourceObservation<T>;
6
+ freshForMs: number[];
7
+ now: () => number;
8
+ current: () => boolean;
9
+ canRetain: (value: T) => boolean;
10
+ retiredError: () => Error;
11
+ subscribe: (listener: () => Promise<void>) => () => void;
12
+ onUpdate: (update: ReadResourceUpdate<T[]>) => void;
13
+ }): ReadResourceObservation<T[]>;
@@ -0,0 +1,116 @@
1
+ /** A mounted presentation barrier; child tasks retain sole network/cache ownership. */
2
+ export function observeReadResourceBatch({ count, observe, freshForMs, now, current, canRetain, retiredError, subscribe, onUpdate }) {
3
+ let disposed = false;
4
+ let retired = false;
5
+ let presented = false;
6
+ let strict = false;
7
+ let initialRetry;
8
+ let pending;
9
+ let failure;
10
+ let stop = () => { };
11
+ const children = [];
12
+ const disposedChildren = new Set();
13
+ const detach = () => { const cleanup = stop; stop = () => { }; cleanup(); };
14
+ const disposeChildren = () => children.forEach(child => {
15
+ if (!disposedChildren.has(child)) {
16
+ disposedChildren.add(child);
17
+ child.dispose();
18
+ }
19
+ });
20
+ const values = new Array(count);
21
+ const received = new Set();
22
+ const ages = new Array(count).fill(-Infinity);
23
+ const active = () => !disposed && !retired && current();
24
+ const retire = () => { retired = true; detach(); disposeChildren(); };
25
+ for (let index = 0; index < count; index++) {
26
+ const child = observe(index, update => {
27
+ if (disposed || retired)
28
+ return;
29
+ if (update.status === "invalidated" || update.status === "expired") {
30
+ onUpdate({ status: "invalidated" });
31
+ retire();
32
+ return;
33
+ }
34
+ if (!current())
35
+ return;
36
+ if ("data" in update) {
37
+ received.add(index);
38
+ values[index] = update.data;
39
+ if (!canRetain(update.data))
40
+ strict = true;
41
+ }
42
+ // Denial retires the group even before every initial source has settled.
43
+ if (update.status === "failed") {
44
+ onUpdate(update);
45
+ presented = false;
46
+ retire();
47
+ return;
48
+ }
49
+ if (update.status === "retry") {
50
+ if (presented)
51
+ failure = update;
52
+ else
53
+ initialRetry = { error: update.error };
54
+ }
55
+ }, createdAt => { ages[index] = createdAt; });
56
+ children.push(child);
57
+ if (retired) {
58
+ disposeChildren();
59
+ break;
60
+ }
61
+ }
62
+ const refresh = (force = false) => {
63
+ if (!active() || !presented || strict)
64
+ return Promise.resolve();
65
+ if (pending)
66
+ return pending;
67
+ if (!force && ages.every((age, index) => now() - age < freshForMs[index]))
68
+ return Promise.resolve();
69
+ // Claim the barrier before a presentation callback can request it again.
70
+ pending = Promise.resolve().then(async () => {
71
+ if (!active() || strict)
72
+ return;
73
+ failure = undefined;
74
+ onUpdate({ status: "refreshing" });
75
+ if (!active() || strict)
76
+ return;
77
+ await Promise.all(children.map(child => child.refresh?.({ force })));
78
+ if (!active())
79
+ return;
80
+ if (failure)
81
+ onUpdate(failure);
82
+ else {
83
+ // Never expose a successful sibling beside an older failed member.
84
+ onUpdate({ status: "fresh", data: [...values] });
85
+ if (strict)
86
+ detach();
87
+ }
88
+ }).finally(() => { pending = undefined; });
89
+ return pending;
90
+ };
91
+ if (!retired)
92
+ stop = subscribe(() => refresh());
93
+ const promise = Promise.allSettled(children.map(child => child.promise)).then(results => {
94
+ if (!active())
95
+ throw retiredError();
96
+ const rejected = results.find(result => result.status === "rejected");
97
+ if (rejected?.status === "rejected")
98
+ throw rejected.reason;
99
+ const data = results.map(result => result.value);
100
+ data.forEach((value, index) => { if (!received.has(index))
101
+ values[index] = value; });
102
+ presented = true;
103
+ // Presentation follows accepted canonical updates, while the promise keeps
104
+ // each shared source caller's original result (including duplicate positions).
105
+ onUpdate({ status: initialRetry === undefined ? "fresh" : "retained", data: [...values] });
106
+ if (initialRetry !== undefined && active())
107
+ onUpdate({ status: "retry", error: initialRetry.error });
108
+ if (strict)
109
+ detach();
110
+ return data;
111
+ });
112
+ return { promise,
113
+ refresh: ({ force = true } = {}) => active() && presented && !strict ? refresh(force) : undefined,
114
+ dispose() { disposed = true; detach(); disposeChildren(); },
115
+ };
116
+ }
@@ -0,0 +1,57 @@
1
+ export type ReadResourceUpdate<T> = {
2
+ status: "retained" | "fresh";
3
+ data: T;
4
+ } | {
5
+ status: "refreshing";
6
+ } | {
7
+ status: "retry";
8
+ error: unknown;
9
+ } | {
10
+ status: "failed";
11
+ error: unknown;
12
+ } | {
13
+ status: "expired";
14
+ } | {
15
+ status: "invalidated";
16
+ };
17
+ export type ReadResourceSnapshot<T> = {
18
+ data: T;
19
+ confirmedAt: number;
20
+ };
21
+ export type ReadResourceObservation<T> = {
22
+ promise: Promise<T>;
23
+ refresh?: (options?: {
24
+ force?: boolean;
25
+ }) => Promise<void> | undefined;
26
+ dispose: () => void;
27
+ };
28
+ /**
29
+ * Mounted presentation only: hosts retain cache, transport, identity and initial
30
+ * read policy. Accepting a snapshot preserves its source age; a successful
31
+ * refresh does not require persistence to have survived an unrelated write.
32
+ */
33
+ export declare function createReadResourceObserver<T, Snapshot>({ enabled, current, now, freshForMs, canRetain, isConnected, canonical, captureSnapshot, read, retireSnapshot, classifyFailure, subscribeWake, onUpdate, onData, onError, }: {
34
+ enabled?: boolean;
35
+ current: () => boolean;
36
+ now: () => number;
37
+ freshForMs: number;
38
+ canRetain: (data: T) => boolean;
39
+ isConnected?: () => boolean;
40
+ canonical: () => ReadResourceSnapshot<T> | undefined;
41
+ captureSnapshot: () => Snapshot;
42
+ read: () => Promise<ReadResourceSnapshot<T>>;
43
+ retireSnapshot: (snapshot: Snapshot) => Promise<void>;
44
+ classifyFailure: (error: unknown) => "transient" | "terminal" | "superseded" | "retired";
45
+ subscribeWake?: (listener: () => Promise<void>) => () => void;
46
+ onUpdate: (update: ReadResourceUpdate<T>) => void;
47
+ onData?: (data: T) => void;
48
+ onError?: (error: unknown) => void;
49
+ }): {
50
+ accept: (update: ReadResourceUpdate<T>, age?: number) => void;
51
+ canRefresh: () => boolean;
52
+ refresh: ({ force }?: {
53
+ force?: boolean | undefined;
54
+ }) => Promise<void> | undefined;
55
+ retire(): void;
56
+ dispose(): void;
57
+ };