@bayonai/offline 0.9.0 → 0.10.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.
@@ -0,0 +1,73 @@
1
+ /** Shared product decision: recovery is application work, never a retry prompt. */
2
+ export const offlineRecoveryCopy = {
3
+ reload: "Reload",
4
+ reloading: "Reconnecting…",
5
+ preservedChanges: "Some changes could not be saved. Your edits are preserved.",
6
+ };
7
+ /** Hosts project domain records to flags; payloads never enter shared guidance. */
8
+ export function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }) {
9
+ if (!hasReadyChanges)
10
+ return hasDrafts
11
+ ? "Finish editing these drafts to save them."
12
+ : offlineRecoveryCopy.preservedChanges;
13
+ if (!hasDrafts && !hasErrors)
14
+ return isOffline
15
+ ? "We’ll sync when you’re back online."
16
+ : "We’ll sync automatically.";
17
+ const sync = isOffline
18
+ ? "Changes ready to send will sync when you’re back online."
19
+ : "Changes ready to send will sync automatically.";
20
+ return hasDrafts ? `${sync} Finish editing drafts to save them.` : sync;
21
+ }
22
+ const terminalCodes = new Set([
23
+ "unauthenticated",
24
+ "permission-denied",
25
+ "resource-exhausted",
26
+ "invalid-argument",
27
+ "failed-precondition",
28
+ "not-found",
29
+ "already-exists",
30
+ ]);
31
+ const transientCodes = new Set([
32
+ "unavailable",
33
+ "deadline-exceeded",
34
+ "cancelled",
35
+ "network-request-failed",
36
+ "internal",
37
+ "aborted",
38
+ ]);
39
+ // Exact transport diagnostics are safe fallbacks only when no structured code
40
+ // exists. Domain messages mentioning a network/offline setting are not signals.
41
+ const connectivityMessages = new Set([
42
+ "failed to fetch",
43
+ "fetch failed",
44
+ "network request failed",
45
+ "load failed",
46
+ "offline",
47
+ "network unavailable",
48
+ "failed to get documents from server. these documents may exist in the local cache",
49
+ "failed to get document because the client is offline",
50
+ ]);
51
+ /**
52
+ * Recognize known transient transport/service failures without a provider SDK.
53
+ * Hosts still own domain validation, idempotency, account scope and replay selection.
54
+ * Unknown failures are not permission to blindly repeat a mutation.
55
+ */
56
+ export function isTransientOfflineError(error) {
57
+ const record = error && typeof error === "object" ? error : {};
58
+ const rawCode = "code" in record ? record.code : undefined;
59
+ const code = typeof rawCode === "string" ? rawCode.toLowerCase().split("/").at(-1) : "";
60
+ if (terminalCodes.has(code))
61
+ return false;
62
+ if (transientCodes.has(code))
63
+ return true;
64
+ if (code)
65
+ return false;
66
+ const rawMessage = typeof error === "string"
67
+ ? error
68
+ : "message" in record
69
+ ? record.message
70
+ : undefined;
71
+ const message = typeof rawMessage === "string" ? rawMessage.toLowerCase() : "";
72
+ return connectivityMessages.has(message.trim().replace(/[.!]$/, ""));
73
+ }
@@ -11,22 +11,23 @@ export type OfflineFallbackRetryControl = {
11
11
  textContent: string | null;
12
12
  };
13
13
  export type BrowserOfflineFallbackRecovery = {
14
- /** Attempts recovery once. Repeated calls are ignored while navigation starts. */
14
+ /** Requests one safe refresh; unsafe or failed proof is rechecked automatically. */
15
+ refresh: () => void;
16
+ /** Compatibility for hosts that label navigation as Reload. */
17
+ reload: () => void;
18
+ /** @deprecated Internal compatibility alias; never a user retry instruction. */
15
19
  retry: () => void;
16
20
  start: () => void;
17
21
  stop: () => void;
18
22
  };
19
23
  export type BrowserOfflineFallbackRecoveryOptions = {
20
- control: OfflineFallbackRetryControl;
24
+ /** Optional legacy UI; new hosts use pull-down without a refresh button. */
25
+ control?: OfflineFallbackRetryControl;
26
+ canReload?: () => boolean | Promise<boolean>;
21
27
  environment?: BrowserOfflineFallbackRecoveryEnvironment;
22
- /** A host-native validation callback may dispatch this event after connectivity returns. */
23
28
  retryEvent?: string;
24
29
  retryingText?: string;
25
- /** Set to `null` to omit touch pull-to-retry. */
26
30
  pullThreshold?: number | null;
27
31
  };
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;
32
+ /** Owns pull/reconnect recovery, including safe asynchronous durability proof. */
33
+ export declare function createBrowserOfflineFallbackRecovery({ control, canReload, environment, pullThreshold, retryEvent, retryingText, }: BrowserOfflineFallbackRecoveryOptions): BrowserOfflineFallbackRecovery;
@@ -2,72 +2,142 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defaultOfflineFallbackPullThreshold = exports.defaultOfflineFallbackRetryEvent = void 0;
4
4
  exports.createBrowserOfflineFallbackRecovery = createBrowserOfflineFallbackRecovery;
5
+ const recoveryPolicy_1 = require("./recoveryPolicy");
6
+ const browserRecovery_1 = require("./browserRecovery");
5
7
  exports.defaultOfflineFallbackRetryEvent = "app:network-restored";
6
8
  exports.defaultOfflineFallbackPullThreshold = 72;
7
- /**
8
- * Owns generic recovery mechanics for a static offline fallback. Hosts retain
9
- * all markup, copy, styling, and the choice of a native reconnect event.
10
- */
11
- function createBrowserOfflineFallbackRecovery({ control, environment = createDefaultBrowserOfflineFallbackRecoveryEnvironment(), pullThreshold = exports.defaultOfflineFallbackPullThreshold, retryEvent = exports.defaultOfflineFallbackRetryEvent, retryingText = "Reconnecting…", }) {
9
+ /** Owns pull/reconnect recovery, including safe asynchronous durability proof. */
10
+ function createBrowserOfflineFallbackRecovery({ control, canReload, environment = createDefaultBrowserOfflineFallbackRecoveryEnvironment(), pullThreshold = exports.defaultOfflineFallbackPullThreshold, retryEvent = exports.defaultOfflineFallbackRetryEvent, retryingText = recoveryPolicy_1.offlineRecoveryCopy.reloading, }) {
11
+ if (control)
12
+ control.textContent = recoveryPolicy_1.offlineRecoveryCopy.reload;
12
13
  let active = false;
13
- let retrying = false;
14
+ let navigating = false;
15
+ let checking = false;
16
+ let requested = false;
17
+ let generation = 0;
18
+ let timer;
19
+ let delay = browserRecovery_1.initialPendingWriteRetryDelayMs;
14
20
  let pullStartY = null;
15
- const touchThreshold = pullThreshold === null ? Number.POSITIVE_INFINITY : pullThreshold;
16
- function retry() {
17
- if (retrying)
21
+ const touchThreshold = pullThreshold === null ? Infinity : pullThreshold;
22
+ function clearTimer() {
23
+ if (timer !== undefined)
24
+ clearTimeout(timer);
25
+ timer = undefined;
26
+ }
27
+ function schedule() {
28
+ if (!active || !requested || timer !== undefined)
29
+ return;
30
+ timer = setTimeout(() => {
31
+ timer = undefined;
32
+ attempt();
33
+ }, delay);
34
+ delay = Math.min(delay * 2, browserRecovery_1.maxPendingWriteRetryDelayMs);
35
+ }
36
+ function failed(error) {
37
+ console.error("[Bayon offline] Offline fallback refresh could not complete; local work retained.", { error });
38
+ navigating = false;
39
+ if (control) {
40
+ control.disabled = false;
41
+ control.textContent = recoveryPolicy_1.offlineRecoveryCopy.reload;
42
+ }
43
+ schedule();
44
+ }
45
+ function complete(safe, currentGeneration) {
46
+ if (currentGeneration !== generation)
47
+ return;
48
+ checking = false;
49
+ if (!safe) {
50
+ schedule();
18
51
  return;
19
- const previousText = control.textContent;
20
- retrying = true;
21
- control.disabled = true;
22
- control.textContent = retryingText;
52
+ }
53
+ clearTimer();
54
+ navigating = true;
55
+ if (control) {
56
+ control.disabled = true;
57
+ control.textContent = retryingText;
58
+ }
23
59
  try {
24
60
  environment.reload();
61
+ requested = false;
25
62
  }
26
63
  catch (error) {
27
- // A failed navigation must leave the recovery action visible and usable.
28
- console.error("[Bayon offline] Offline fallback reload failed.", {
29
- error,
30
- });
31
- retrying = false;
32
- control.disabled = false;
33
- control.textContent = previousText;
64
+ failed(error);
34
65
  }
35
66
  }
67
+ function attempt() {
68
+ if (!requested || checking || navigating)
69
+ return;
70
+ checking = true;
71
+ const currentGeneration = generation;
72
+ try {
73
+ const proof = canReload ? canReload() : true;
74
+ if (typeof proof === "boolean")
75
+ complete(proof, currentGeneration);
76
+ else
77
+ void proof.then((safe) => complete(safe, currentGeneration), (error) => {
78
+ if (currentGeneration !== generation)
79
+ return;
80
+ checking = false;
81
+ failed(error);
82
+ });
83
+ }
84
+ catch (error) {
85
+ checking = false;
86
+ failed(error);
87
+ }
88
+ }
89
+ function refresh() {
90
+ requested = true;
91
+ clearTimer();
92
+ delay = browserRecovery_1.initialPendingWriteRetryDelayMs;
93
+ attempt();
94
+ }
36
95
  function handleTouchStart(event) {
37
- const touchY = getSingleTouchY(event, "touches");
38
- pullStartY = environment.getScrollY() === 0 ? touchY : null;
96
+ pullStartY =
97
+ environment.getScrollY() === 0 ? getSingleTouchY(event, "touches") : null;
39
98
  }
40
99
  function handleTouchEnd(event) {
41
100
  const touchY = getSingleTouchY(event, "changedTouches");
42
101
  if (pullStartY !== null &&
43
102
  touchY !== null &&
44
- touchY - pullStartY >= touchThreshold) {
45
- retry();
46
- }
103
+ touchY - pullStartY >= touchThreshold)
104
+ refresh();
105
+ pullStartY = null;
106
+ }
107
+ function handleTouchCancel() {
47
108
  pullStartY = null;
48
109
  }
49
110
  return {
50
- retry,
111
+ refresh,
112
+ reload: refresh,
113
+ retry: refresh,
51
114
  start() {
52
115
  if (active)
53
116
  return;
54
117
  active = true;
55
- environment.addEventListener("online", retry);
56
- environment.addEventListener(retryEvent, retry);
118
+ environment.addEventListener("online", refresh);
119
+ environment.addEventListener(retryEvent, refresh);
57
120
  if (pullThreshold !== null) {
58
121
  environment.addEventListener("touchstart", handleTouchStart);
59
122
  environment.addEventListener("touchend", handleTouchEnd);
123
+ environment.addEventListener("touchcancel", handleTouchCancel);
60
124
  }
61
125
  },
62
126
  stop() {
127
+ // Fence a proof completing after unmount, including manual pre-start calls.
128
+ generation++;
129
+ requested = false;
130
+ checking = false;
131
+ clearTimer();
63
132
  if (!active)
64
133
  return;
65
134
  active = false;
66
- environment.removeEventListener("online", retry);
67
- environment.removeEventListener(retryEvent, retry);
135
+ environment.removeEventListener("online", refresh);
136
+ environment.removeEventListener(retryEvent, refresh);
68
137
  if (pullThreshold !== null) {
69
138
  environment.removeEventListener("touchstart", handleTouchStart);
70
139
  environment.removeEventListener("touchend", handleTouchEnd);
140
+ environment.removeEventListener("touchcancel", handleTouchCancel);
71
141
  }
72
142
  pullStartY = null;
73
143
  },
@@ -18,9 +18,11 @@ export type BrowserKeyValueDriverOptions = {
18
18
  databaseVersion?: number;
19
19
  indexedDB?: IDBFactory;
20
20
  operationTimeoutMs: number;
21
+ /** Queue reads must throw; read caches may retain best-effort fallback. */
22
+ readFailureMode?: "fallback" | "throw";
21
23
  storeName: string;
22
24
  };
23
25
  export declare class OfflineStorageError extends Error {
24
26
  name: string;
25
27
  }
26
- export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
28
+ export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, readFailureMode, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
@@ -7,16 +7,28 @@ class OfflineStorageError extends Error {
7
7
  name = "OfflineStorageError";
8
8
  }
9
9
  exports.OfflineStorageError = OfflineStorageError;
10
- function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
10
+ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, readFailureMode = "fallback", storeName, }) {
11
11
  if (!indexedDB) {
12
12
  return null;
13
13
  }
14
- const databasePromise = openKeyValueDatabase({
15
- databaseName,
16
- databaseVersion,
17
- indexedDB,
18
- storeName,
19
- });
14
+ let databasePromise = null;
15
+ function getDatabase() {
16
+ if (!databasePromise) {
17
+ const attempt = openKeyValueDatabase({
18
+ databaseName,
19
+ databaseVersion,
20
+ indexedDB: indexedDB,
21
+ storeName,
22
+ });
23
+ databasePromise = attempt;
24
+ // A transient failed open must not poison every later recovery read.
25
+ void attempt.catch(() => {
26
+ databasePromise = null;
27
+ });
28
+ }
29
+ return databasePromise;
30
+ }
31
+ getDatabase();
20
32
  function runMutation(operationName, mutate) {
21
33
  return runTransactionalStorageOperation({
22
34
  failureMode: "throw",
@@ -24,7 +36,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
24
36
  operationName,
25
37
  storeName,
26
38
  operation: async (setTransaction, isCancelled) => {
27
- const database = await databasePromise;
39
+ const database = await getDatabase();
28
40
  if (isCancelled())
29
41
  return;
30
42
  const transaction = database.transaction(storeName, "readwrite");
@@ -45,7 +57,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
45
57
  operationName: "close database",
46
58
  storeName,
47
59
  operation: async () => {
48
- const database = await databasePromise;
60
+ const database = await getDatabase();
49
61
  database.close();
50
62
  },
51
63
  timeoutMs: operationTimeoutMs,
@@ -56,7 +68,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
56
68
  },
57
69
  async deleteIf(key, predicate) {
58
70
  return runConditionalStorageOperation({
59
- databasePromise,
71
+ databasePromise: getDatabase(),
60
72
  operationName: "conditionally delete key",
61
73
  storeName,
62
74
  timeoutMs: operationTimeoutMs,
@@ -71,11 +83,12 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
71
83
  },
72
84
  async get(key) {
73
85
  return runBrowserStorageOperation({
86
+ failureMode: readFailureMode,
74
87
  fallback: null,
75
88
  operationName: "read key",
76
89
  storeName,
77
90
  operation: async () => {
78
- const database = await databasePromise;
91
+ const database = await getDatabase();
79
92
  return (await database.get(storeName, key)) ?? null;
80
93
  },
81
94
  timeoutMs: operationTimeoutMs,
@@ -83,11 +96,12 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
83
96
  },
84
97
  async list() {
85
98
  return runBrowserStorageOperation({
99
+ failureMode: readFailureMode,
86
100
  fallback: [],
87
101
  operationName: "list object store",
88
102
  storeName,
89
103
  operation: async () => {
90
- const database = await databasePromise;
104
+ const database = await getDatabase();
91
105
  return database.getAll(storeName);
92
106
  },
93
107
  timeoutMs: operationTimeoutMs,
@@ -95,11 +109,12 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
95
109
  },
96
110
  async listEntries() {
97
111
  return runBrowserStorageOperation({
112
+ failureMode: readFailureMode,
98
113
  fallback: [],
99
114
  operationName: "list object-store entries",
100
115
  storeName,
101
116
  operation: async () => {
102
- const database = await databasePromise;
117
+ const database = await getDatabase();
103
118
  // Keys and values must come from one transaction. Separate database
104
119
  // reads could pair a key from one cross-tab storage state with a
105
120
  // value from another, causing retention code to delete the wrong key.
@@ -123,7 +138,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
123
138
  },
124
139
  async setIf(key, value, predicate) {
125
140
  return runConditionalStorageOperation({
126
- databasePromise,
141
+ databasePromise: getDatabase(),
127
142
  operationName: "conditionally replace key",
128
143
  storeName,
129
144
  timeoutMs: operationTimeoutMs,
@@ -5,3 +5,6 @@ export * from "./browserCoordination.js";
5
5
  export * from "./browserFallbackRecovery.js";
6
6
  export * from "./durableCommandStore.js";
7
7
  export { OfflineStorageError } from "./idbDriver.js";
8
+ export * from "./readBoundary.js";
9
+ export * from "./recoveryPolicy.js";
10
+ export * from "./pendingWriteSupersession.js";
package/dist-cjs/index.js CHANGED
@@ -23,3 +23,6 @@ __exportStar(require("./browserFallbackRecovery"), exports);
23
23
  __exportStar(require("./durableCommandStore"), exports);
24
24
  var idbDriver_1 = require("./idbDriver");
25
25
  Object.defineProperty(exports, "OfflineStorageError", { enumerable: true, get: function () { return idbDriver_1.OfflineStorageError; } });
26
+ __exportStar(require("./readBoundary"), exports);
27
+ __exportStar(require("./recoveryPolicy"), exports);
28
+ __exportStar(require("./pendingWriteSupersession"), exports);
@@ -0,0 +1,11 @@
1
+ import type { PendingWriteRecord, PendingWriteStore } from "./pendingWrites.js";
2
+ /**
3
+ * After remote acknowledgment, retire only rejected snapshots superseded by an
4
+ * explicit host-owned account/entity/field key. Never infer keys from payloads.
5
+ */
6
+ export declare function clearSupersededPendingWriteFailures<Payload>({ acknowledged, getSupersessionKey, isRejected, store, }: {
7
+ acknowledged: PendingWriteRecord<Payload>;
8
+ getSupersessionKey: (record: PendingWriteRecord<Payload>) => string | null;
9
+ isRejected: (record: PendingWriteRecord<Payload>) => boolean;
10
+ store: PendingWriteStore<Payload>;
11
+ }): Promise<number>;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clearSupersededPendingWriteFailures = clearSupersededPendingWriteFailures;
4
+ /**
5
+ * After remote acknowledgment, retire only rejected snapshots superseded by an
6
+ * explicit host-owned account/entity/field key. Never infer keys from payloads.
7
+ */
8
+ async function clearSupersededPendingWriteFailures({ acknowledged, getSupersessionKey, isRejected, store, }) {
9
+ const key = getSupersessionKey(acknowledged);
10
+ if (key === null)
11
+ return 0;
12
+ let cleared = 0;
13
+ for (const previous of await store.list()) {
14
+ const older = previous.savedAt.localeCompare(acknowledged.savedAt) ||
15
+ previous.id.localeCompare(acknowledged.id);
16
+ if (previous.id === acknowledged.id ||
17
+ older >= 0 ||
18
+ previous.syncStatus !== "failed" ||
19
+ previous.syncingAt ||
20
+ !isRejected(previous) ||
21
+ getSupersessionKey(previous) !== key)
22
+ continue;
23
+ // Snapshot fencing protects a correction or claim accepted during the scan.
24
+ if (await store.clearIfSnapshotId(previous.id, previous.snapshotId))
25
+ cleared++;
26
+ }
27
+ return cleared;
28
+ }
@@ -184,6 +184,7 @@ function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexe
184
184
  indexedDB,
185
185
  operationTimeoutMs,
186
186
  storeName,
187
+ readFailureMode: "throw",
187
188
  });
188
189
  return driver ? toPendingWriteDriver(driver) : null;
189
190
  }
@@ -423,10 +424,10 @@ function createUnavailablePendingWriteDriver() {
423
424
  },
424
425
  async close() { },
425
426
  async get() {
426
- return null;
427
+ throw unavailable();
427
428
  },
428
429
  async list() {
429
- return [];
430
+ throw unavailable();
430
431
  },
431
432
  async set() {
432
433
  throw unavailable();
@@ -0,0 +1,13 @@
1
+ export type ReadBoundaryOptions = {
2
+ modules: Readonly<Record<string, readonly string[]>>;
3
+ roots: readonly string[];
4
+ allowedAdapters: readonly string[];
5
+ isForbidden: (moduleId: string) => boolean;
6
+ };
7
+ /**
8
+ * Host contract: supply every reachable local runtime dependency, including
9
+ * re-exports and dynamic imports. Absent nodes are external leaves. Exact
10
+ * trusted adapter IDs stop traversal; hosts verify their cache policy separately.
11
+ * This test utility deliberately owns no parser, framework, or provider policy.
12
+ */
13
+ export declare function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }: ReadBoundaryOptions): void;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertReadBoundary = assertReadBoundary;
4
+ /**
5
+ * Host contract: supply every reachable local runtime dependency, including
6
+ * re-exports and dynamic imports. Absent nodes are external leaves. Exact
7
+ * trusted adapter IDs stop traversal; hosts verify their cache policy separately.
8
+ * This test utility deliberately owns no parser, framework, or provider policy.
9
+ */
10
+ function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }) {
11
+ const adapters = new Set(allowedAdapters);
12
+ const visited = new Set();
13
+ function visit(id, path) {
14
+ if (adapters.has(id) || visited.has(id))
15
+ return;
16
+ const nextPath = [...path, id];
17
+ if (isForbidden(id)) {
18
+ throw new Error(`Read boundary violation: ${nextPath.join(" -> ")}`);
19
+ }
20
+ visited.add(id);
21
+ for (const dependency of modules[id] ?? [])
22
+ visit(dependency, nextPath);
23
+ }
24
+ for (const root of roots)
25
+ visit(root, []);
26
+ }
@@ -0,0 +1,20 @@
1
+ /** Shared product decision: recovery is application work, never a retry prompt. */
2
+ export declare const offlineRecoveryCopy: {
3
+ readonly reload: "Reload";
4
+ readonly reloading: "Reconnecting…";
5
+ readonly preservedChanges: "Some changes could not be saved. Your edits are preserved.";
6
+ };
7
+ export type PendingChangesGuidanceState = {
8
+ hasReadyChanges: boolean;
9
+ hasDrafts: boolean;
10
+ hasErrors: boolean;
11
+ isOffline: boolean;
12
+ };
13
+ /** Hosts project domain records to flags; payloads never enter shared guidance. */
14
+ export declare function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }: PendingChangesGuidanceState): string;
15
+ /**
16
+ * Recognize known transient transport/service failures without a provider SDK.
17
+ * Hosts still own domain validation, idempotency, account scope and replay selection.
18
+ * Unknown failures are not permission to blindly repeat a mutation.
19
+ */
20
+ export declare function isTransientOfflineError(error: unknown): boolean;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.offlineRecoveryCopy = void 0;
4
+ exports.getPendingChangesGuidance = getPendingChangesGuidance;
5
+ exports.isTransientOfflineError = isTransientOfflineError;
6
+ /** Shared product decision: recovery is application work, never a retry prompt. */
7
+ exports.offlineRecoveryCopy = {
8
+ reload: "Reload",
9
+ reloading: "Reconnecting…",
10
+ preservedChanges: "Some changes could not be saved. Your edits are preserved.",
11
+ };
12
+ /** Hosts project domain records to flags; payloads never enter shared guidance. */
13
+ function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }) {
14
+ if (!hasReadyChanges)
15
+ return hasDrafts
16
+ ? "Finish editing these drafts to save them."
17
+ : exports.offlineRecoveryCopy.preservedChanges;
18
+ if (!hasDrafts && !hasErrors)
19
+ return isOffline
20
+ ? "We’ll sync when you’re back online."
21
+ : "We’ll sync automatically.";
22
+ const sync = isOffline
23
+ ? "Changes ready to send will sync when you’re back online."
24
+ : "Changes ready to send will sync automatically.";
25
+ return hasDrafts ? `${sync} Finish editing drafts to save them.` : sync;
26
+ }
27
+ const terminalCodes = new Set([
28
+ "unauthenticated",
29
+ "permission-denied",
30
+ "resource-exhausted",
31
+ "invalid-argument",
32
+ "failed-precondition",
33
+ "not-found",
34
+ "already-exists",
35
+ ]);
36
+ const transientCodes = new Set([
37
+ "unavailable",
38
+ "deadline-exceeded",
39
+ "cancelled",
40
+ "network-request-failed",
41
+ "internal",
42
+ "aborted",
43
+ ]);
44
+ // Exact transport diagnostics are safe fallbacks only when no structured code
45
+ // exists. Domain messages mentioning a network/offline setting are not signals.
46
+ const connectivityMessages = new Set([
47
+ "failed to fetch",
48
+ "fetch failed",
49
+ "network request failed",
50
+ "load failed",
51
+ "offline",
52
+ "network unavailable",
53
+ "failed to get documents from server. these documents may exist in the local cache",
54
+ "failed to get document because the client is offline",
55
+ ]);
56
+ /**
57
+ * Recognize known transient transport/service failures without a provider SDK.
58
+ * Hosts still own domain validation, idempotency, account scope and replay selection.
59
+ * Unknown failures are not permission to blindly repeat a mutation.
60
+ */
61
+ function isTransientOfflineError(error) {
62
+ const record = error && typeof error === "object" ? error : {};
63
+ const rawCode = "code" in record ? record.code : undefined;
64
+ const code = typeof rawCode === "string" ? rawCode.toLowerCase().split("/").at(-1) : "";
65
+ if (terminalCodes.has(code))
66
+ return false;
67
+ if (transientCodes.has(code))
68
+ return true;
69
+ if (code)
70
+ return false;
71
+ const rawMessage = typeof error === "string"
72
+ ? error
73
+ : "message" in record
74
+ ? record.message
75
+ : undefined;
76
+ const message = typeof rawMessage === "string" ? rawMessage.toLowerCase() : "";
77
+ return connectivityMessages.has(message.trim().replace(/[.!]$/, ""));
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",
@@ -46,7 +46,8 @@
46
46
  "dist-cjs",
47
47
  "CHANGELOG.md",
48
48
  "LICENSE",
49
- "README.md"
49
+ "README.md",
50
+ "RECOVERY.md"
50
51
  ],
51
52
  "publishConfig": {
52
53
  "access": "public"
@@ -63,7 +64,7 @@
63
64
  "idb": "^8.0.3"
64
65
  },
65
66
  "devDependencies": {
66
- "fake-indexeddb": "^6.2.5",
67
+ "fake-indexeddb": "^6.2.6",
67
68
  "typescript": "^6.0.3",
68
69
  "vitest": "^5.0.1"
69
70
  }