@bayonai/offline 0.7.0 → 0.9.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/dist/readCache.js CHANGED
@@ -3,7 +3,9 @@ export const readCacheSchemaVersion = 1;
3
3
  export const readCacheFreshnessMaxAgeMs = 30_000;
4
4
  export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
5
5
  export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
6
- export const browserReadCacheOperationTimeoutMs = 2_000;
6
+ // Cache reads are optional and should yield to the fresh loader quickly when
7
+ // IndexedDB is blocked by a browser/WebView lifecycle or another tab.
8
+ export const browserReadCacheOperationTimeoutMs = 1_000;
7
9
  const pendingFreshLoads = new WeakMap();
8
10
  const cachePruners = new WeakMap();
9
11
  export function createReadCache(driver) {
@@ -14,6 +16,10 @@ export function createReadCache(driver) {
14
16
  function getVersion(key) {
15
17
  return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
16
18
  }
19
+ function invalidate(key) {
20
+ pendingLoads.delete(key);
21
+ keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
22
+ }
17
23
  function enqueue(operation) {
18
24
  const next = operationQueue.then(operation, operation);
19
25
  operationQueue = next.then(() => undefined, () => undefined);
@@ -30,10 +36,10 @@ export function createReadCache(driver) {
30
36
  await enqueue(async () => driver.close?.());
31
37
  },
32
38
  async delete(key) {
33
- pendingLoads.delete(key);
34
- keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
39
+ invalidate(key);
35
40
  await enqueue(() => driver.delete(key));
36
41
  },
42
+ invalidate,
37
43
  async get(key) {
38
44
  const envelope = toReadCacheEnvelope(await driver.get(key));
39
45
  return envelope
@@ -133,8 +139,7 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
133
139
  */
134
140
  export function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
135
141
  const resourceCache = cache.capture?.() ?? cache;
136
- return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
137
- Promise.resolve(0);
142
+ return (cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ?? Promise.resolve(0));
138
143
  }
139
144
  export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
140
145
  const serializedParams = Object.entries(params)
@@ -151,58 +156,128 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
151
156
  .filter(Boolean)
152
157
  .join(":");
153
158
  }
154
- export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
159
+ export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
155
160
  const resourceCache = cache.capture?.() ?? cache;
161
+ const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
162
+ const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
156
163
  if (forceFresh) {
157
- // Deletion advances the cache version, so a request started before this
158
- // post-write refresh cannot restore stale data after it finishes.
159
- await resourceCache.delete(key);
164
+ reportReadCacheOutcome(onOutcome, "forced-refresh");
165
+ // Built-in caches advance generation without deleting fallback data.
166
+ // Legacy custom adapters retain their old delete behavior.
167
+ try {
168
+ if (resourceCache.invalidate) {
169
+ resourceCache.invalidate(key);
170
+ }
171
+ else {
172
+ await resourceCache.delete(key);
173
+ }
174
+ }
175
+ catch (error) {
176
+ console.error("[Bayon offline] Unable to invalidate the read cache.", {
177
+ error: normalizeError(error),
178
+ key,
179
+ });
180
+ }
160
181
  }
161
182
  const version = resourceCache.getVersion(key);
162
- const cached = await resourceCache.get(key);
163
- if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
183
+ const cached = forcedFallback ?? (await resourceCache.get(key));
184
+ const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
185
+ if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
186
+ reportReadCacheOutcome(onOutcome, "fresh-hit");
164
187
  return {
165
188
  data: cached.value,
166
189
  error: null,
167
190
  fromCache: true,
168
191
  stale: false,
192
+ status: "fresh",
169
193
  };
170
194
  }
195
+ reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
171
196
  try {
172
197
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
173
- await resourceCache.setIfVersion(key, fresh, version);
198
+ reportReadCacheOutcome(onOutcome, "loader-success");
199
+ try {
200
+ await resourceCache.setIfVersion(key, fresh, version);
201
+ }
202
+ catch (error) {
203
+ console.error("[Bayon offline] Unable to refresh the read cache.", {
204
+ error: normalizeError(error),
205
+ key,
206
+ });
207
+ }
174
208
  return {
175
209
  data: fresh,
176
210
  error: null,
177
211
  fromCache: false,
178
212
  stale: false,
213
+ status: retained ? "fresh" : "miss",
179
214
  };
180
215
  }
181
216
  catch (error) {
182
- if (cached) {
217
+ reportReadCacheOutcome(onOutcome, "loader-failure");
218
+ if (retained && resolvedPolicy.refreshFailure === "use-retained") {
183
219
  return {
184
220
  data: cached.value,
185
221
  error: normalizeError(error),
186
222
  fromCache: true,
187
223
  stale: true,
224
+ status: "stale",
188
225
  };
189
226
  }
190
227
  throw error;
191
228
  }
192
229
  }
193
- export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
230
+ export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
194
231
  const resourceCache = cache.capture?.() ?? cache;
232
+ const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
233
+ const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
234
+ if (forceFresh) {
235
+ reportReadCacheOutcome(onOutcome, "forced-refresh");
236
+ try {
237
+ if (resourceCache.invalidate) {
238
+ resourceCache.invalidate(key);
239
+ }
240
+ else {
241
+ await resourceCache.delete(key);
242
+ }
243
+ }
244
+ catch (error) {
245
+ console.error("[Bayon offline] Unable to invalidate the read cache.", {
246
+ error: normalizeError(error),
247
+ key,
248
+ });
249
+ }
250
+ }
195
251
  const version = resourceCache.getVersion(key);
196
- const cached = skipCache ? null : await resourceCache.get(key);
197
- if (cached) {
198
- onUpdate({ data: cached.value, fromCache: true });
199
- if (isCacheFresh(cached.savedAt, maxAgeMs)) {
252
+ const cached = skipCache
253
+ ? null
254
+ : (forcedFallback ?? (await resourceCache.get(key)));
255
+ const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
256
+ if (retained) {
257
+ const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
258
+ reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
259
+ onUpdate({
260
+ data: cached.value,
261
+ fromCache: true,
262
+ status: fresh && !forceFresh ? "fresh" : "stale",
263
+ });
264
+ if (fresh && !forceFresh) {
200
265
  return cached.value;
201
266
  }
202
267
  }
268
+ else {
269
+ reportReadCacheOutcome(onOutcome, "cache-miss");
270
+ }
203
271
  try {
204
272
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
205
- onUpdate({ data: fresh, fromCache: false });
273
+ reportReadCacheOutcome(onOutcome, "loader-success");
274
+ if (resourceCache.getVersion(key) === version) {
275
+ onUpdate({
276
+ data: fresh,
277
+ fromCache: false,
278
+ status: retained ? "fresh" : "miss",
279
+ });
280
+ }
206
281
  void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
207
282
  const normalizedError = normalizeError(error);
208
283
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -214,17 +289,32 @@ export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, o
214
289
  return fresh;
215
290
  }
216
291
  catch (error) {
217
- if (!cached) {
292
+ reportReadCacheOutcome(onOutcome, "loader-failure");
293
+ if (!retained || resolvedPolicy.refreshFailure === "throw") {
218
294
  throw error;
219
295
  }
220
296
  onUpdate({
221
297
  data: cached.value,
222
298
  error: normalizeError(error),
223
299
  fromCache: true,
300
+ status: "stale",
224
301
  });
225
302
  return cached.value;
226
303
  }
227
304
  }
305
+ function reportReadCacheOutcome(onOutcome, outcome) {
306
+ if (!onOutcome)
307
+ return;
308
+ try {
309
+ onOutcome(outcome);
310
+ }
311
+ catch (error) {
312
+ console.error("[Bayon offline] Read-cache outcome callback failed.", {
313
+ error: normalizeError(error),
314
+ outcome,
315
+ });
316
+ }
317
+ }
228
318
  function createNoopReadCacheDriver() {
229
319
  return {
230
320
  async clear() { },
@@ -239,12 +329,33 @@ function createNoopReadCacheDriver() {
239
329
  async set() { },
240
330
  };
241
331
  }
242
- function isCacheFresh(savedAt, maxAgeMs) {
243
- if (!maxAgeMs || maxAgeMs <= 0) {
332
+ function isCacheFresh(savedAt, policy) {
333
+ if (policy.freshForMs <= 0) {
244
334
  return false;
245
335
  }
246
336
  const savedTime = Date.parse(savedAt);
247
- return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
337
+ return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.freshForMs);
338
+ }
339
+ function resolvePolicy(policy, maxAgeMs) {
340
+ if (policy) {
341
+ if (!Number.isFinite(policy.freshForMs) ||
342
+ policy.freshForMs < 0 ||
343
+ !Number.isFinite(policy.retainForMs) ||
344
+ policy.retainForMs < policy.freshForMs ||
345
+ !["use-retained", "throw"].includes(policy.refreshFailure)) {
346
+ throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
347
+ }
348
+ return policy;
349
+ }
350
+ return {
351
+ freshForMs: maxAgeMs ?? 0,
352
+ retainForMs: Number.POSITIVE_INFINITY,
353
+ refreshFailure: "use-retained",
354
+ };
355
+ }
356
+ function isCacheRetained(savedAt, policy) {
357
+ const savedTime = Date.parse(savedAt);
358
+ return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.retainForMs);
248
359
  }
249
360
  async function loadSharedFreshResource(cache, key, loadFresh) {
250
361
  const pendingLoads = getPendingFreshLoads(cache);
@@ -0,0 +1,23 @@
1
+ export type BrowserRecoveryMessageChannel = {
2
+ addEventListener: (type: "message", listener: () => void) => void;
3
+ close: () => void;
4
+ postMessage: (message: "pending-write-recovery") => void;
5
+ removeEventListener: (type: "message", listener: () => void) => void;
6
+ };
7
+ export type BrowserRecoveryLockManager = {
8
+ request: <T>(name: string, options: {
9
+ ifAvailable: true;
10
+ }, callback: (lock: unknown | null) => Promise<T | null>) => Promise<T | null>;
11
+ };
12
+ export type BrowserPendingWriteRecoveryCoordination = {
13
+ close: () => void;
14
+ notify: () => void;
15
+ runExclusive: <T>(attempt: () => Promise<T>) => Promise<T | null>;
16
+ subscribe: (listener: () => void) => () => void;
17
+ };
18
+ export declare function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel, locks, }: {
19
+ channel?: BrowserRecoveryMessageChannel | null;
20
+ channelName: string;
21
+ leaseName: string;
22
+ locks?: BrowserRecoveryLockManager | null;
23
+ }): BrowserPendingWriteRecoveryCoordination;
@@ -0,0 +1,32 @@
1
+ export declare const defaultOfflineFallbackRetryEvent = "app:network-restored";
2
+ export declare const defaultOfflineFallbackPullThreshold = 72;
3
+ export type BrowserOfflineFallbackRecoveryEnvironment = {
4
+ addEventListener: (type: string, listener: (event: unknown) => void) => void;
5
+ getScrollY: () => number;
6
+ reload: () => void;
7
+ removeEventListener: (type: string, listener: (event: unknown) => void) => void;
8
+ };
9
+ export type OfflineFallbackRetryControl = {
10
+ disabled: boolean;
11
+ textContent: string | null;
12
+ };
13
+ export type BrowserOfflineFallbackRecovery = {
14
+ /** Attempts recovery once. Repeated calls are ignored while navigation starts. */
15
+ retry: () => void;
16
+ start: () => void;
17
+ stop: () => void;
18
+ };
19
+ export type BrowserOfflineFallbackRecoveryOptions = {
20
+ control: OfflineFallbackRetryControl;
21
+ environment?: BrowserOfflineFallbackRecoveryEnvironment;
22
+ /** A host-native validation callback may dispatch this event after connectivity returns. */
23
+ retryEvent?: string;
24
+ retryingText?: string;
25
+ /** Set to `null` to omit touch pull-to-retry. */
26
+ pullThreshold?: number | null;
27
+ };
28
+ /**
29
+ * Owns generic recovery mechanics for a static offline fallback. Hosts retain
30
+ * all markup, copy, styling, and the choice of a native reconnect event.
31
+ */
32
+ export declare function createBrowserOfflineFallbackRecovery({ control, environment, pullThreshold, retryEvent, retryingText, }: BrowserOfflineFallbackRecoveryOptions): BrowserOfflineFallbackRecovery;
@@ -12,9 +12,11 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
12
12
  let active = false;
13
13
  let retrying = false;
14
14
  let pullStartY = null;
15
+ const touchThreshold = pullThreshold === null ? Number.POSITIVE_INFINITY : pullThreshold;
15
16
  function retry() {
16
17
  if (retrying)
17
18
  return;
19
+ const previousText = control.textContent;
18
20
  retrying = true;
19
21
  control.disabled = true;
20
22
  control.textContent = retryingText;
@@ -23,9 +25,12 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
23
25
  }
24
26
  catch (error) {
25
27
  // A failed navigation must leave the recovery action visible and usable.
26
- console.error("[Bayon offline] Offline fallback reload failed.", { error });
28
+ console.error("[Bayon offline] Offline fallback reload failed.", {
29
+ error,
30
+ });
27
31
  retrying = false;
28
32
  control.disabled = false;
33
+ control.textContent = previousText;
29
34
  }
30
35
  }
31
36
  function handleTouchStart(event) {
@@ -36,7 +41,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
36
41
  const touchY = getSingleTouchY(event, "changedTouches");
37
42
  if (pullStartY !== null &&
38
43
  touchY !== null &&
39
- touchY - pullStartY >= (pullThreshold ?? Number.POSITIVE_INFINITY)) {
44
+ touchY - pullStartY >= touchThreshold) {
40
45
  retry();
41
46
  }
42
47
  pullStartY = null;
@@ -0,0 +1,42 @@
1
+ import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites.js";
2
+ import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination.js";
3
+ export declare const initialPendingWriteRetryDelayMs = 2000;
4
+ export declare const maxPendingWriteRetryDelayMs = 60000;
5
+ export type BrowserPendingWriteRecoveryEnvironment = {
6
+ addEventListener: (type: "focus" | "offline" | "online", listener: () => void) => void;
7
+ clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
8
+ isOnline: () => boolean;
9
+ removeEventListener: (type: "focus" | "offline" | "online", listener: () => void) => void;
10
+ setTimeout: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
11
+ };
12
+ export type BrowserPendingWriteRecovery<Payload> = {
13
+ /** Signals that a caller has persisted a record outside the store subscription. */
14
+ notifyQueued: () => void;
15
+ /** Runs one immediate, silent recovery attempt when the browser is online. */
16
+ recoverNow: () => Promise<SyncPendingWritesResult | null>;
17
+ start: () => void;
18
+ stop: () => void;
19
+ };
20
+ type BrowserPendingWriteRecoveryOptions<Payload> = {
21
+ /** Optional BroadcastChannel/Web Locks coordination supplied by the package factory. */
22
+ coordination?: BrowserPendingWriteRecoveryCoordination;
23
+ environment?: BrowserPendingWriteRecoveryEnvironment;
24
+ initialRetryDelayMs?: number;
25
+ maxRetryDelayMs?: number;
26
+ /** Diagnostic callback only. Recovery never renders or presents product UI. */
27
+ onError?: (error: unknown) => void;
28
+ /** Diagnostic callback only. Recovery never renders or presents product UI. */
29
+ onResult?: (result: SyncPendingWritesResult) => void;
30
+ scopeKey?: string;
31
+ select?: (record: PendingWriteRecord<Payload>) => boolean;
32
+ store: PendingWriteStore<Payload>;
33
+ } & ({
34
+ /** Runs one complete host-owned recovery pass, such as through an ordered executor. */
35
+ recover: () => Promise<SyncPendingWritesResult>;
36
+ sync?: never;
37
+ } | {
38
+ recover?: never;
39
+ sync: PendingWriteSync<Payload>;
40
+ });
41
+ export declare function createBrowserPendingWriteRecovery<Payload>(options: BrowserPendingWriteRecoveryOptions<Payload>): BrowserPendingWriteRecovery<Payload>;
42
+ export {};
@@ -0,0 +1,49 @@
1
+ import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites.js";
2
+ export type DurableCommandErrorCategory = "auth" | "conflict" | "connectivity" | "local-storage" | "permission" | "unexpected" | "validation";
3
+ export type DurableCommandOperation<Command> = {
4
+ command: Command;
5
+ error?: DurableCommandErrorCategory;
6
+ id: string;
7
+ status: "accepted" | "failed" | "syncing";
8
+ };
9
+ export type DurableCommandSnapshot<State, Command> = {
10
+ operations: DurableCommandOperation<Command>[];
11
+ state: State;
12
+ };
13
+ export type DurableCommandDispatch = {
14
+ accepted: Promise<void>;
15
+ operationId: string;
16
+ };
17
+ export type DurableCommandExecutionContext = {
18
+ /** Stable across retries so the remote command can be idempotent. */
19
+ operationId: string;
20
+ };
21
+ export type DurableCommandStore<State, Command, Scope> = {
22
+ cancel: (operationId: string) => Promise<boolean>;
23
+ close: () => void;
24
+ dispatch: (command: Command, options?: {
25
+ notBefore?: string;
26
+ }) => DurableCommandDispatch;
27
+ getSnapshot: () => DurableCommandSnapshot<State, Command>;
28
+ recover: () => Promise<void>;
29
+ refresh: (scope: Scope) => Promise<void>;
30
+ subscribe: (listener: () => void) => () => void;
31
+ };
32
+ /**
33
+ * Hosts provide domain projection and transport; this package only owns the
34
+ * durable command lifecycle. A command becomes visible immediately, but is
35
+ * never sent remotely until IndexedDB has accepted it.
36
+ */
37
+ export declare function createDurableCommandStore<State, Command, Scope, Result = unknown>({ canCancel, classifyError, createId, execute, initialState, load, now, project, reconcile, shouldRetry, store, }: {
38
+ canCancel?: (command: Command, now: string) => boolean;
39
+ classifyError?: (error: unknown) => DurableCommandErrorCategory;
40
+ createId?: () => string;
41
+ execute: (command: Command, context: DurableCommandExecutionContext) => Promise<Result>;
42
+ initialState: State;
43
+ load: (scope: Scope) => Promise<State>;
44
+ now?: () => string;
45
+ project: (state: State, commands: readonly PendingWriteRecord<Command>[]) => State;
46
+ reconcile?: (state: State, command: Command, result: Result) => State;
47
+ shouldRetry?: (category: DurableCommandErrorCategory, command: Command) => boolean;
48
+ store: PendingWriteStore<Command>;
49
+ }): DurableCommandStore<State, Command, Scope>;