@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.
- package/CHANGELOG.md +22 -0
- package/README.md +61 -11
- package/RECOVERY.md +64 -0
- package/dist/browserFallbackRecovery.d.ts +10 -9
- package/dist/browserFallbackRecovery.js +100 -30
- package/dist/idbDriver.d.ts +3 -1
- package/dist/idbDriver.js +29 -14
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/pendingWriteSupersession.d.ts +11 -0
- package/dist/pendingWriteSupersession.js +25 -0
- package/dist/pendingWrites.js +3 -2
- package/dist/readBoundary.d.ts +13 -0
- package/dist/readBoundary.js +23 -0
- package/dist/recoveryPolicy.d.ts +20 -0
- package/dist/recoveryPolicy.js +73 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +10 -9
- package/dist-cjs/browserFallbackRecovery.js +100 -30
- package/dist-cjs/idbDriver.d.ts +3 -1
- package/dist-cjs/idbDriver.js +29 -14
- package/dist-cjs/index.d.ts +3 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWriteSupersession.d.ts +11 -0
- package/dist-cjs/pendingWriteSupersession.js +28 -0
- package/dist-cjs/pendingWrites.js +3 -2
- package/dist-cjs/readBoundary.d.ts +13 -0
- package/dist-cjs/readBoundary.js +26 -0
- package/dist-cjs/recoveryPolicy.d.ts +20 -0
- package/dist-cjs/recoveryPolicy.js +78 -0
- package/package.json +4 -3
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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 ?
|
|
16
|
-
function
|
|
17
|
-
if (
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
control
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
103
|
+
touchY - pullStartY >= touchThreshold)
|
|
104
|
+
refresh();
|
|
105
|
+
pullStartY = null;
|
|
106
|
+
}
|
|
107
|
+
function handleTouchCancel() {
|
|
47
108
|
pullStartY = null;
|
|
48
109
|
}
|
|
49
110
|
return {
|
|
50
|
-
|
|
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",
|
|
56
|
-
environment.addEventListener(retryEvent,
|
|
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",
|
|
67
|
-
environment.removeEventListener(retryEvent,
|
|
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
|
},
|
package/dist-cjs/idbDriver.d.ts
CHANGED
|
@@ -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;
|
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/dist-cjs/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
427
|
+
throw unavailable();
|
|
427
428
|
},
|
|
428
429
|
async list() {
|
|
429
|
-
|
|
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.
|
|
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.
|
|
67
|
+
"fake-indexeddb": "^6.2.6",
|
|
67
68
|
"typescript": "^6.0.3",
|
|
68
69
|
"vitest": "^5.0.1"
|
|
69
70
|
}
|