@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 +20 -0
- package/README.md +75 -0
- package/RECOVERY.md +3 -0
- package/dist/browserReadLifecycle.d.ts +5 -0
- package/dist/browserReadLifecycle.js +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/pendingWrites.d.ts +3 -3
- package/dist/pendingWrites.js +20 -15
- package/dist/readCache.d.ts +8 -1
- package/dist/readCache.js +109 -7
- package/dist/readResourceBatch.d.ts +13 -0
- package/dist/readResourceBatch.js +116 -0
- package/dist/readResourceObserver.d.ts +57 -0
- package/dist/readResourceObserver.js +108 -0
- package/dist-cjs/browserReadLifecycle.d.ts +5 -0
- package/dist-cjs/browserReadLifecycle.js +27 -0
- package/dist-cjs/index.d.ts +3 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWrites.d.ts +3 -3
- package/dist-cjs/pendingWrites.js +20 -15
- package/dist-cjs/readCache.d.ts +8 -1
- package/dist-cjs/readCache.js +109 -7
- package/dist-cjs/readResourceBatch.d.ts +13 -0
- package/dist-cjs/readResourceBatch.js +119 -0
- package/dist-cjs/readResourceObserver.d.ts +57 -0
- package/dist-cjs/readResourceObserver.js +111 -0
- package/package.json +1 -1
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,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";
|
package/dist/pendingWrites.d.ts
CHANGED
|
@@ -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
|
|
155
|
-
*
|
|
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;
|
package/dist/pendingWrites.js
CHANGED
|
@@ -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
|
|
289
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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.
|
|
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 = {
|
package/dist/readCache.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|