@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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounted presentation only: hosts retain cache, transport, identity and initial
|
|
3
|
+
* read policy. Accepting a snapshot preserves its source age; a successful
|
|
4
|
+
* refresh does not require persistence to have survived an unrelated write.
|
|
5
|
+
*/
|
|
6
|
+
export function createReadResourceObserver({ enabled = true, current, now, freshForMs, canRetain, isConnected = () => true, canonical, captureSnapshot, read, retireSnapshot, classifyFailure, subscribeWake, onUpdate, onData, onError, }) {
|
|
7
|
+
let disposed = false;
|
|
8
|
+
let retired = false;
|
|
9
|
+
let confirmedAt;
|
|
10
|
+
let retainable = true;
|
|
11
|
+
let pending;
|
|
12
|
+
let stop = () => { };
|
|
13
|
+
const detach = () => { const cleanup = stop; stop = () => { }; cleanup(); };
|
|
14
|
+
const active = () => !disposed && !retired && current();
|
|
15
|
+
const canRefresh = () => enabled && active() && retainable && confirmedAt !== undefined;
|
|
16
|
+
const accept = (update, age = now()) => {
|
|
17
|
+
if (!active())
|
|
18
|
+
return;
|
|
19
|
+
if ("data" in update) {
|
|
20
|
+
confirmedAt = age;
|
|
21
|
+
retainable = canRetain(update.data);
|
|
22
|
+
onData?.(update.data);
|
|
23
|
+
}
|
|
24
|
+
if (active())
|
|
25
|
+
onUpdate(update);
|
|
26
|
+
};
|
|
27
|
+
const adoptCanonical = () => {
|
|
28
|
+
const snapshot = canonical();
|
|
29
|
+
if (!snapshot || !active() || now() - snapshot.confirmedAt >= freshForMs)
|
|
30
|
+
return false;
|
|
31
|
+
accept({ status: "fresh", data: snapshot.data }, snapshot.confirmedAt);
|
|
32
|
+
return true;
|
|
33
|
+
};
|
|
34
|
+
const run = async (force) => {
|
|
35
|
+
if (!canRefresh())
|
|
36
|
+
return;
|
|
37
|
+
if (!force && now() - confirmedAt < freshForMs)
|
|
38
|
+
return;
|
|
39
|
+
if (!isConnected()) {
|
|
40
|
+
accept({ status: "retry", error: new Error("Resource unavailable while disconnected") });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!force && adoptCanonical())
|
|
44
|
+
return;
|
|
45
|
+
accept({ status: "refreshing" });
|
|
46
|
+
if (!canRefresh())
|
|
47
|
+
return;
|
|
48
|
+
const snapshot = captureSnapshot();
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
result = await read();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (!active())
|
|
55
|
+
return;
|
|
56
|
+
const failure = classifyFailure(error);
|
|
57
|
+
if (failure === "superseded") {
|
|
58
|
+
adoptCanonical();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (failure === "retired")
|
|
62
|
+
return;
|
|
63
|
+
if (onError)
|
|
64
|
+
onError(error);
|
|
65
|
+
else
|
|
66
|
+
console.error("[Bayon offline] Mounted resource refresh failed.", { error });
|
|
67
|
+
if (failure === "transient")
|
|
68
|
+
accept({ status: "retry", error });
|
|
69
|
+
else {
|
|
70
|
+
try {
|
|
71
|
+
await retireSnapshot(snapshot);
|
|
72
|
+
}
|
|
73
|
+
catch (storageError) {
|
|
74
|
+
// Storage cleanup cannot turn a known denial into retained presentation.
|
|
75
|
+
console.error("[Bayon offline] Unable to retire a mounted read snapshot.", { error: storageError });
|
|
76
|
+
}
|
|
77
|
+
if (!active())
|
|
78
|
+
return;
|
|
79
|
+
confirmedAt = undefined;
|
|
80
|
+
accept({ status: "failed", error });
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
accept({ status: "fresh", data: result.data }, result.confirmedAt);
|
|
85
|
+
};
|
|
86
|
+
const refresh = ({ force = true } = {}) => {
|
|
87
|
+
if (!canRefresh())
|
|
88
|
+
return;
|
|
89
|
+
// Install ownership before callbacks can synchronously request another read.
|
|
90
|
+
if (!pending)
|
|
91
|
+
pending = Promise.resolve().then(() => run(force)).finally(() => { pending = undefined; });
|
|
92
|
+
return pending;
|
|
93
|
+
};
|
|
94
|
+
if (enabled && subscribeWake)
|
|
95
|
+
stop = subscribeWake(() => refresh({ force: false }) ?? Promise.resolve());
|
|
96
|
+
return {
|
|
97
|
+
accept, canRefresh, refresh,
|
|
98
|
+
retire() {
|
|
99
|
+
if (disposed || retired)
|
|
100
|
+
return;
|
|
101
|
+
retired = true;
|
|
102
|
+
detach();
|
|
103
|
+
// Clearing protected presentation is safe even after the host changed scope.
|
|
104
|
+
onUpdate({ status: "invalidated" });
|
|
105
|
+
},
|
|
106
|
+
dispose() { disposed = true; detach(); },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bindBrowserReadLifecycle = bindBrowserReadLifecycle;
|
|
4
|
+
/** Native wake binding only; hosts own visibility, eligibility and request sharing. */
|
|
5
|
+
function bindBrowserReadLifecycle(targets, notify) {
|
|
6
|
+
const report = (error) => console.error("[Bayon offline] Browser read refresh failed.", { error });
|
|
7
|
+
const wake = () => {
|
|
8
|
+
try {
|
|
9
|
+
void notify().catch(report);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
report(error);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
targets.window.addEventListener("focus", wake);
|
|
16
|
+
targets.window.addEventListener("online", wake);
|
|
17
|
+
targets.document.addEventListener("visibilitychange", wake);
|
|
18
|
+
let stopped = false;
|
|
19
|
+
return () => {
|
|
20
|
+
if (stopped)
|
|
21
|
+
return;
|
|
22
|
+
stopped = true;
|
|
23
|
+
targets.window.removeEventListener("focus", wake);
|
|
24
|
+
targets.window.removeEventListener("online", wake);
|
|
25
|
+
targets.document.removeEventListener("visibilitychange", wake);
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist-cjs/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-cjs/index.js
CHANGED
|
@@ -16,6 +16,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.projectPendingWriteFinalState = exports.OfflineStorageError = void 0;
|
|
18
18
|
__exportStar(require("./readCache"), exports);
|
|
19
|
+
__exportStar(require("./readResourceObserver"), exports);
|
|
20
|
+
__exportStar(require("./readResourceBatch"), exports);
|
|
21
|
+
__exportStar(require("./browserReadLifecycle"), exports);
|
|
19
22
|
__exportStar(require("./pendingWrites"), exports);
|
|
20
23
|
__exportStar(require("./browserRecovery"), exports);
|
|
21
24
|
__exportStar(require("./browserCoordination"), exports);
|
|
@@ -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;
|
|
@@ -299,30 +299,34 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
|
|
|
299
299
|
: { status: "superseded" };
|
|
300
300
|
}
|
|
301
301
|
/**
|
|
302
|
-
* Replays selected writes oldest first
|
|
303
|
-
*
|
|
304
|
-
* mutation semantics.
|
|
302
|
+
* Replays selected writes oldest first. Identical overlapping calls coalesce;
|
|
303
|
+
* calls with different options run serially for the same store and scope.
|
|
304
|
+
* Callers own the selection predicate and idempotent remote mutation semantics.
|
|
305
305
|
*/
|
|
306
|
-
function syncPendingWrites({ now, scopeKey = "all", select =
|
|
306
|
+
function syncPendingWrites({ now, scopeKey = "all", select = selectAllPendingWrites, stopOnFailure = false, store, sync, }) {
|
|
307
307
|
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
308
308
|
if (!inFlightByScope) {
|
|
309
309
|
inFlightByScope = new Map();
|
|
310
310
|
pendingWriteSyncs.set(store, inFlightByScope);
|
|
311
311
|
}
|
|
312
312
|
const existing = inFlightByScope.get(scopeKey);
|
|
313
|
-
if (existing
|
|
314
|
-
|
|
313
|
+
if (existing &&
|
|
314
|
+
existing.select === select &&
|
|
315
|
+
existing.sync === sync &&
|
|
316
|
+
existing.now === now &&
|
|
317
|
+
existing.stopOnFailure === stopOnFailure) {
|
|
318
|
+
return existing.promise;
|
|
315
319
|
}
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
});
|
|
323
|
-
inFlightByScope.set(scopeKey, syncRun);
|
|
320
|
+
const run = () => performPendingWriteSync({ now, select, stopOnFailure, store, sync });
|
|
321
|
+
// Product decision: distinct filters must never share counts or run in
|
|
322
|
+
// parallel; each waits for the preceding scope run and uses its own policy.
|
|
323
|
+
const syncRun = existing ? existing.promise.then(run, run) : run();
|
|
324
|
+
const entry = { now, select, stopOnFailure, sync, promise: syncRun };
|
|
325
|
+
inFlightByScope.set(scopeKey, entry);
|
|
324
326
|
const clearInFlight = () => {
|
|
325
|
-
inFlightByScope.
|
|
327
|
+
if (inFlightByScope.get(scopeKey) === entry) {
|
|
328
|
+
inFlightByScope.delete(scopeKey);
|
|
329
|
+
}
|
|
326
330
|
};
|
|
327
331
|
void syncRun.then(clearInFlight, clearInFlight);
|
|
328
332
|
return syncRun;
|
|
@@ -360,6 +364,7 @@ function summarizePendingWrites(records) {
|
|
|
360
364
|
return summary;
|
|
361
365
|
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
362
366
|
}
|
|
367
|
+
const selectAllPendingWrites = () => true;
|
|
363
368
|
const pendingWriteSyncs = new WeakMap();
|
|
364
369
|
async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
|
|
365
370
|
const summary = {
|
package/dist-cjs/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-cjs/readCache.js
CHANGED
|
@@ -58,7 +58,7 @@ function createReadCache(driver) {
|
|
|
58
58
|
operationQueue = next.then(() => undefined, () => undefined);
|
|
59
59
|
return next;
|
|
60
60
|
}
|
|
61
|
-
async function writeIfVersion(key, value, version, snapshot, savedAt) {
|
|
61
|
+
async function writeIfVersion(key, value, version, snapshot, savedAt, receipt) {
|
|
62
62
|
if (getVersion(key) !== version)
|
|
63
63
|
return false;
|
|
64
64
|
return enqueue(async () => {
|
|
@@ -74,15 +74,28 @@ function createReadCache(driver) {
|
|
|
74
74
|
const written = await driver.setIf(key, next, (current) => getVersion(key) === version && ownsSnapshot(current, snapshot));
|
|
75
75
|
if (!written)
|
|
76
76
|
return false;
|
|
77
|
+
// A driver may finish after the caller has retired this generation.
|
|
78
|
+
// Keep that retirement visible instead of issuing a new owned receipt.
|
|
79
|
+
if (receipt && getVersion(key) !== version)
|
|
80
|
+
return false;
|
|
77
81
|
}
|
|
78
82
|
else {
|
|
79
83
|
await driver.set(key, next);
|
|
80
84
|
}
|
|
81
85
|
invalidate(key);
|
|
86
|
+
if (receipt)
|
|
87
|
+
receipt.version = getVersion(key);
|
|
82
88
|
return true;
|
|
83
89
|
});
|
|
84
90
|
}
|
|
85
91
|
const cache = {
|
|
92
|
+
async commitIfVersion(key, value, version, snapshot, confirmedAt) {
|
|
93
|
+
if (!driver.setIf)
|
|
94
|
+
throw new Error("[Bayon offline] Canonical presentation requires atomic conditional cache writes.");
|
|
95
|
+
const receipt = { version };
|
|
96
|
+
const written = await writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString(), receipt);
|
|
97
|
+
return { written, version: receipt.version };
|
|
98
|
+
},
|
|
86
99
|
async clear() {
|
|
87
100
|
pendingLoads.clear();
|
|
88
101
|
globalVersion += 1;
|
|
@@ -349,7 +362,7 @@ async function readThroughCache({ cache, forceFresh = false, includeProvenance =
|
|
|
349
362
|
throw error;
|
|
350
363
|
}
|
|
351
364
|
}
|
|
352
|
-
async function loadReadCacheResource({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
|
|
365
|
+
async function loadReadCacheResource({ cache, deliveryPolicy = "immediate", forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
|
|
353
366
|
const resourceCache = cache.capture?.() ?? cache;
|
|
354
367
|
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
355
368
|
const capturedVersion = resourceCache.getVersion(key);
|
|
@@ -377,11 +390,56 @@ async function loadReadCacheResource({ cache, forceFresh = false, includeProvena
|
|
|
377
390
|
// Capture ownership even when presentation skips the cached value.
|
|
378
391
|
const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
|
|
379
392
|
const cached = skipCache ? null : writeSnapshot;
|
|
380
|
-
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy)
|
|
393
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy)
|
|
394
|
+
&& (deliveryPolicy === "immediate" || resourceCache.getVersion(key) === version);
|
|
395
|
+
function publishUpdate(update) {
|
|
396
|
+
if (deliveryPolicy === "immediate") {
|
|
397
|
+
onUpdate(update);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
onUpdate(update);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
// Opted-in presentation is independent of the caller's source outcome.
|
|
405
|
+
console.error("[Bayon offline] Read-cache presentation callback failed.", {
|
|
406
|
+
error: normalizeError(error), key,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function reportPresentationStorageError(error) {
|
|
411
|
+
const normalizedError = normalizeError(error);
|
|
412
|
+
console.error("[Bayon offline] Unable to confirm current read-cache presentation.", {
|
|
413
|
+
error: normalizedError, key,
|
|
414
|
+
});
|
|
415
|
+
onCacheWriteError?.(normalizedError);
|
|
416
|
+
}
|
|
417
|
+
async function canonicalUpdate(expectedVersion) {
|
|
418
|
+
if (resourceCache.getVersion(key) !== expectedVersion)
|
|
419
|
+
return;
|
|
420
|
+
let canonical;
|
|
421
|
+
try {
|
|
422
|
+
canonical = await resourceCache.get(key);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
reportPresentationStorageError(error);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (!canonical || resourceCache.getVersion(key) !== expectedVersion
|
|
429
|
+
|| !isCacheRetained(canonical.savedAt, resolvedPolicy))
|
|
430
|
+
return;
|
|
431
|
+
return {
|
|
432
|
+
data: canonical.value, fromCache: true,
|
|
433
|
+
status: isCacheFresh(canonical.savedAt, resolvedPolicy) ? "fresh" : "stale",
|
|
434
|
+
...(includeProvenance ? {
|
|
435
|
+
provenance: "cache-hit", savedAt: canonical.savedAt,
|
|
436
|
+
} : {}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
381
439
|
if (retained && !requireSource) {
|
|
382
440
|
const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
|
|
383
441
|
reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
|
|
384
|
-
|
|
442
|
+
publishUpdate({
|
|
385
443
|
data: cached.value,
|
|
386
444
|
fromCache: true,
|
|
387
445
|
status: fresh && !forceFresh ? "fresh" : "stale",
|
|
@@ -403,8 +461,14 @@ async function loadReadCacheResource({ cache, forceFresh = false, includeProvena
|
|
|
403
461
|
throw (source.originalError ??
|
|
404
462
|
source.error ??
|
|
405
463
|
new Error("[Bayon offline] Source confirmation was required."));
|
|
464
|
+
if (deliveryPolicy === "cache-current") {
|
|
465
|
+
const update = await canonicalUpdate(version);
|
|
466
|
+
if (update)
|
|
467
|
+
publishUpdate(update);
|
|
468
|
+
return source.data;
|
|
469
|
+
}
|
|
406
470
|
if (resourceCache.getVersion(key) === version)
|
|
407
|
-
|
|
471
|
+
publishUpdate({
|
|
408
472
|
data: source.data,
|
|
409
473
|
fromCache: source.fromCache,
|
|
410
474
|
status: source.status,
|
|
@@ -421,8 +485,35 @@ async function loadReadCacheResource({ cache, forceFresh = false, includeProvena
|
|
|
421
485
|
}
|
|
422
486
|
const fresh = source.data;
|
|
423
487
|
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
488
|
+
if (deliveryPolicy === "cache-current") {
|
|
489
|
+
let update;
|
|
490
|
+
try {
|
|
491
|
+
if (!resourceCache.commitIfVersion)
|
|
492
|
+
throw new Error("[Bayon offline] Canonical presentation requires an atomic commit receipt.");
|
|
493
|
+
const receipt = await resourceCache.commitIfVersion(key, fresh, version, writeSnapshot, source.confirmedAt);
|
|
494
|
+
if (requireSource) {
|
|
495
|
+
// Source-required consumers may never mistake a readback for proof.
|
|
496
|
+
if (receipt.written && resourceCache.getVersion(key) === receipt.version)
|
|
497
|
+
update = {
|
|
498
|
+
data: fresh, fromCache: false, status: retained ? "fresh" : "miss",
|
|
499
|
+
...(includeProvenance ? {
|
|
500
|
+
provenance: "source-confirmed", confirmedAt: source.confirmedAt,
|
|
501
|
+
} : {}),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
else
|
|
505
|
+
update = await canonicalUpdate(receipt.version);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
reportPresentationStorageError(error);
|
|
509
|
+
}
|
|
510
|
+
// Keep observer exceptions outside the storage diagnostic boundary.
|
|
511
|
+
if (update)
|
|
512
|
+
publishUpdate(update);
|
|
513
|
+
return fresh;
|
|
514
|
+
}
|
|
424
515
|
if (resourceCache.getVersion(key) === version) {
|
|
425
|
-
|
|
516
|
+
publishUpdate({
|
|
426
517
|
data: fresh,
|
|
427
518
|
fromCache: false,
|
|
428
519
|
status: retained ? "fresh" : "miss",
|
|
@@ -452,7 +543,18 @@ async function loadReadCacheResource({ cache, forceFresh = false, includeProvena
|
|
|
452
543
|
if (requireSource || !retained || failure !== "use-retained") {
|
|
453
544
|
throw error;
|
|
454
545
|
}
|
|
455
|
-
|
|
546
|
+
if (deliveryPolicy === "cache-current") {
|
|
547
|
+
const update = await canonicalUpdate(version);
|
|
548
|
+
if (update)
|
|
549
|
+
publishUpdate({
|
|
550
|
+
...update, status: "stale", error: normalizeError(error),
|
|
551
|
+
...(includeProvenance ? {
|
|
552
|
+
provenance: "retained-fallback", originalError: error,
|
|
553
|
+
} : {}),
|
|
554
|
+
});
|
|
555
|
+
return cached.value;
|
|
556
|
+
}
|
|
557
|
+
publishUpdate({
|
|
456
558
|
data: cached.value,
|
|
457
559
|
error: normalizeError(error),
|
|
458
560
|
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,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.observeReadResourceBatch = observeReadResourceBatch;
|
|
4
|
+
/** A mounted presentation barrier; child tasks retain sole network/cache ownership. */
|
|
5
|
+
function observeReadResourceBatch({ count, observe, freshForMs, now, current, canRetain, retiredError, subscribe, onUpdate }) {
|
|
6
|
+
let disposed = false;
|
|
7
|
+
let retired = false;
|
|
8
|
+
let presented = false;
|
|
9
|
+
let strict = false;
|
|
10
|
+
let initialRetry;
|
|
11
|
+
let pending;
|
|
12
|
+
let failure;
|
|
13
|
+
let stop = () => { };
|
|
14
|
+
const children = [];
|
|
15
|
+
const disposedChildren = new Set();
|
|
16
|
+
const detach = () => { const cleanup = stop; stop = () => { }; cleanup(); };
|
|
17
|
+
const disposeChildren = () => children.forEach(child => {
|
|
18
|
+
if (!disposedChildren.has(child)) {
|
|
19
|
+
disposedChildren.add(child);
|
|
20
|
+
child.dispose();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const values = new Array(count);
|
|
24
|
+
const received = new Set();
|
|
25
|
+
const ages = new Array(count).fill(-Infinity);
|
|
26
|
+
const active = () => !disposed && !retired && current();
|
|
27
|
+
const retire = () => { retired = true; detach(); disposeChildren(); };
|
|
28
|
+
for (let index = 0; index < count; index++) {
|
|
29
|
+
const child = observe(index, update => {
|
|
30
|
+
if (disposed || retired)
|
|
31
|
+
return;
|
|
32
|
+
if (update.status === "invalidated" || update.status === "expired") {
|
|
33
|
+
onUpdate({ status: "invalidated" });
|
|
34
|
+
retire();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!current())
|
|
38
|
+
return;
|
|
39
|
+
if ("data" in update) {
|
|
40
|
+
received.add(index);
|
|
41
|
+
values[index] = update.data;
|
|
42
|
+
if (!canRetain(update.data))
|
|
43
|
+
strict = true;
|
|
44
|
+
}
|
|
45
|
+
// Denial retires the group even before every initial source has settled.
|
|
46
|
+
if (update.status === "failed") {
|
|
47
|
+
onUpdate(update);
|
|
48
|
+
presented = false;
|
|
49
|
+
retire();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (update.status === "retry") {
|
|
53
|
+
if (presented)
|
|
54
|
+
failure = update;
|
|
55
|
+
else
|
|
56
|
+
initialRetry = { error: update.error };
|
|
57
|
+
}
|
|
58
|
+
}, createdAt => { ages[index] = createdAt; });
|
|
59
|
+
children.push(child);
|
|
60
|
+
if (retired) {
|
|
61
|
+
disposeChildren();
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const refresh = (force = false) => {
|
|
66
|
+
if (!active() || !presented || strict)
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
if (pending)
|
|
69
|
+
return pending;
|
|
70
|
+
if (!force && ages.every((age, index) => now() - age < freshForMs[index]))
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
// Claim the barrier before a presentation callback can request it again.
|
|
73
|
+
pending = Promise.resolve().then(async () => {
|
|
74
|
+
if (!active() || strict)
|
|
75
|
+
return;
|
|
76
|
+
failure = undefined;
|
|
77
|
+
onUpdate({ status: "refreshing" });
|
|
78
|
+
if (!active() || strict)
|
|
79
|
+
return;
|
|
80
|
+
await Promise.all(children.map(child => child.refresh?.({ force })));
|
|
81
|
+
if (!active())
|
|
82
|
+
return;
|
|
83
|
+
if (failure)
|
|
84
|
+
onUpdate(failure);
|
|
85
|
+
else {
|
|
86
|
+
// Never expose a successful sibling beside an older failed member.
|
|
87
|
+
onUpdate({ status: "fresh", data: [...values] });
|
|
88
|
+
if (strict)
|
|
89
|
+
detach();
|
|
90
|
+
}
|
|
91
|
+
}).finally(() => { pending = undefined; });
|
|
92
|
+
return pending;
|
|
93
|
+
};
|
|
94
|
+
if (!retired)
|
|
95
|
+
stop = subscribe(() => refresh());
|
|
96
|
+
const promise = Promise.allSettled(children.map(child => child.promise)).then(results => {
|
|
97
|
+
if (!active())
|
|
98
|
+
throw retiredError();
|
|
99
|
+
const rejected = results.find(result => result.status === "rejected");
|
|
100
|
+
if (rejected?.status === "rejected")
|
|
101
|
+
throw rejected.reason;
|
|
102
|
+
const data = results.map(result => result.value);
|
|
103
|
+
data.forEach((value, index) => { if (!received.has(index))
|
|
104
|
+
values[index] = value; });
|
|
105
|
+
presented = true;
|
|
106
|
+
// Presentation follows accepted canonical updates, while the promise keeps
|
|
107
|
+
// each shared source caller's original result (including duplicate positions).
|
|
108
|
+
onUpdate({ status: initialRetry === undefined ? "fresh" : "retained", data: [...values] });
|
|
109
|
+
if (initialRetry !== undefined && active())
|
|
110
|
+
onUpdate({ status: "retry", error: initialRetry.error });
|
|
111
|
+
if (strict)
|
|
112
|
+
detach();
|
|
113
|
+
return data;
|
|
114
|
+
});
|
|
115
|
+
return { promise,
|
|
116
|
+
refresh: ({ force = true } = {}) => active() && presented && !strict ? refresh(force) : undefined,
|
|
117
|
+
dispose() { disposed = true; detach(); disposeChildren(); },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -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
|
+
};
|