@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/CHANGELOG.md +38 -1
- package/README.md +81 -8
- package/dist/browserFallbackRecovery.js +7 -2
- package/dist/browserRecovery.d.ts +2 -2
- package/dist/durableCommandStore.d.ts +9 -3
- package/dist/durableCommandStore.js +115 -50
- package/dist/idbDriver.d.ts +3 -0
- package/dist/idbDriver.js +52 -44
- package/dist/index.d.ts +7 -6
- package/dist/index.js +1 -0
- package/dist/pendingWrites.d.ts +16 -6
- package/dist/pendingWrites.js +70 -21
- package/dist/readCache.d.ts +27 -6
- package/dist/readCache.js +134 -23
- package/dist-cjs/browserCoordination.d.ts +23 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +32 -0
- package/dist-cjs/browserFallbackRecovery.js +7 -2
- package/dist-cjs/browserRecovery.d.ts +42 -0
- package/dist-cjs/durableCommandStore.d.ts +49 -0
- package/dist-cjs/durableCommandStore.js +115 -50
- package/dist-cjs/idbDriver.d.ts +26 -0
- package/dist-cjs/idbDriver.js +54 -44
- package/dist-cjs/index.d.ts +7 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWrites.d.ts +179 -0
- package/dist-cjs/pendingWrites.js +69 -20
- package/dist-cjs/readCache.d.ts +112 -0
- package/dist-cjs/readCache.js +134 -23
- package/package.json +15 -7
|
@@ -7,24 +7,59 @@ const pendingWrites_1 = require("./pendingWrites");
|
|
|
7
7
|
* durable command lifecycle. A command becomes visible immediately, but is
|
|
8
8
|
* never sent remotely until IndexedDB has accepted it.
|
|
9
9
|
*/
|
|
10
|
-
function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, store, }) {
|
|
10
|
+
function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, reconcile, shouldRetry = () => true, store, }) {
|
|
11
11
|
let baseState = initialState;
|
|
12
|
-
let
|
|
12
|
+
let baseStateGeneration = 0;
|
|
13
13
|
let savedAtMillis = 0;
|
|
14
|
+
let publishGeneration = 0;
|
|
15
|
+
let storedRecords = [];
|
|
14
16
|
let deferredRecoveryTimer = null;
|
|
17
|
+
const provisionalRecords = new Map();
|
|
18
|
+
const localStorageFailures = new Map();
|
|
15
19
|
let snapshot = {
|
|
16
20
|
operations: [],
|
|
17
21
|
state: initialState,
|
|
18
22
|
};
|
|
19
23
|
const listeners = new Set();
|
|
20
24
|
const executor = (0, pendingWrites_1.createOrderedPendingWriteExecutor)({
|
|
25
|
+
now,
|
|
21
26
|
scopeKey: "durable-command-store",
|
|
27
|
+
select: isActiveCommand,
|
|
22
28
|
store,
|
|
23
|
-
sync:
|
|
29
|
+
sync: async (command, { recordId }) => {
|
|
30
|
+
const result = await execute(command, { operationId: recordId });
|
|
31
|
+
// A completed remote write is newer than any read already in flight.
|
|
32
|
+
// Reconciliation is local-only: a projector bug must never retry a
|
|
33
|
+
// command whose remote effect may already have committed.
|
|
34
|
+
baseStateGeneration += 1;
|
|
35
|
+
if (reconcile) {
|
|
36
|
+
try {
|
|
37
|
+
baseState = reconcile(baseState, command, result);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error("[Bayon offline] Unable to reconcile a confirmed durable command.", { error, operationId: recordId });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
24
44
|
});
|
|
25
45
|
function notify() {
|
|
26
|
-
for (const listener of listeners)
|
|
27
|
-
|
|
46
|
+
for (const listener of listeners) {
|
|
47
|
+
try {
|
|
48
|
+
listener();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error("[Bayon offline] Durable-command listener failed.", {
|
|
52
|
+
error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function classifyStoredError(error) {
|
|
58
|
+
return classifyError(new Error(error ?? "Unable to sync durable command."));
|
|
59
|
+
}
|
|
60
|
+
function isActiveCommand(record) {
|
|
61
|
+
return (record.syncStatus !== "failed" ||
|
|
62
|
+
shouldRetry(classifyStoredError(record.error), record.payload));
|
|
28
63
|
}
|
|
29
64
|
function scheduleDeferredRecovery(records) {
|
|
30
65
|
if (deferredRecoveryTimer) {
|
|
@@ -40,32 +75,62 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
40
75
|
}, null);
|
|
41
76
|
if (nextDeadline === null)
|
|
42
77
|
return;
|
|
43
|
-
deferredRecoveryTimer = setTimeout(
|
|
78
|
+
deferredRecoveryTimer = setTimeout(recoverInBackground, Math.max(0, nextDeadline - currentTime));
|
|
44
79
|
}
|
|
45
|
-
|
|
46
|
-
const records =
|
|
80
|
+
function currentRecords() {
|
|
81
|
+
const records = new Map(storedRecords.map((record) => [record.id, record]));
|
|
82
|
+
for (const record of provisionalRecords.values()) {
|
|
83
|
+
records.set(record.id, record);
|
|
84
|
+
}
|
|
85
|
+
return [...records.values()].sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
86
|
+
left.id.localeCompare(right.id));
|
|
87
|
+
}
|
|
88
|
+
function applySnapshot(records) {
|
|
47
89
|
snapshot = {
|
|
48
|
-
operations:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
90
|
+
operations: [
|
|
91
|
+
...records.map((record) => ({
|
|
92
|
+
command: record.payload,
|
|
93
|
+
...(record.syncStatus === "failed"
|
|
94
|
+
? { error: classifyStoredError(record.error) }
|
|
95
|
+
: {}),
|
|
96
|
+
id: record.id,
|
|
97
|
+
status: record.syncStatus === "failed"
|
|
98
|
+
? "failed"
|
|
99
|
+
: record.syncStatus === "syncing"
|
|
100
|
+
? "syncing"
|
|
101
|
+
: "accepted",
|
|
102
|
+
})),
|
|
103
|
+
...[...localStorageFailures].map(([id, command]) => ({
|
|
104
|
+
command,
|
|
105
|
+
error: "local-storage",
|
|
106
|
+
id,
|
|
107
|
+
status: "failed",
|
|
108
|
+
})),
|
|
109
|
+
],
|
|
110
|
+
state: project(baseState, records.filter(isActiveCommand)),
|
|
61
111
|
};
|
|
62
112
|
scheduleDeferredRecovery(records);
|
|
113
|
+
}
|
|
114
|
+
async function publish() {
|
|
115
|
+
const generation = ++publishGeneration;
|
|
116
|
+
const records = await store.list();
|
|
117
|
+
if (generation !== publishGeneration)
|
|
118
|
+
return;
|
|
119
|
+
storedRecords = records;
|
|
120
|
+
applySnapshot(currentRecords());
|
|
63
121
|
notify();
|
|
64
122
|
}
|
|
65
123
|
async function recover() {
|
|
66
124
|
await executor.recover();
|
|
67
125
|
await publish();
|
|
68
126
|
}
|
|
127
|
+
function recoverInBackground() {
|
|
128
|
+
void recover().catch((error) => {
|
|
129
|
+
console.error("[Bayon offline] Unable to recover durable commands.", {
|
|
130
|
+
error,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
69
134
|
const unsubscribe = store.subscribe(() => {
|
|
70
135
|
void publish().catch((error) => {
|
|
71
136
|
console.error("[Bayon offline] Unable to publish durable commands.", {
|
|
@@ -75,6 +140,11 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
75
140
|
});
|
|
76
141
|
return {
|
|
77
142
|
async cancel(operationId) {
|
|
143
|
+
if (localStorageFailures.delete(operationId)) {
|
|
144
|
+
applySnapshot(currentRecords());
|
|
145
|
+
notify();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
78
148
|
const record = await store.get(operationId);
|
|
79
149
|
if (!record ||
|
|
80
150
|
record.syncStatus === "syncing" ||
|
|
@@ -89,6 +159,7 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
89
159
|
close() {
|
|
90
160
|
if (deferredRecoveryTimer)
|
|
91
161
|
clearTimeout(deferredRecoveryTimer);
|
|
162
|
+
publishGeneration += 1;
|
|
92
163
|
unsubscribe();
|
|
93
164
|
},
|
|
94
165
|
dispatch(command, options = {}) {
|
|
@@ -103,36 +174,20 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
103
174
|
snapshotId: operationId,
|
|
104
175
|
syncStatus: "pending",
|
|
105
176
|
};
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
],
|
|
112
|
-
state: project(baseState, [
|
|
113
|
-
...snapshot.operations
|
|
114
|
-
.filter((operation) => operation.status !== "failed")
|
|
115
|
-
.map((operation) => ({
|
|
116
|
-
id: operation.id,
|
|
117
|
-
payload: operation.command,
|
|
118
|
-
savedAt: record.savedAt,
|
|
119
|
-
snapshotId: operation.id,
|
|
120
|
-
syncStatus: "pending",
|
|
121
|
-
})),
|
|
122
|
-
record,
|
|
123
|
-
]),
|
|
124
|
-
};
|
|
177
|
+
// Product decision: provisional records remain part of the same ordered
|
|
178
|
+
// projection as persisted retryable failures while storage settles.
|
|
179
|
+
provisionalRecords.set(operationId, record);
|
|
180
|
+
localStorageFailures.delete(operationId);
|
|
181
|
+
applySnapshot(currentRecords());
|
|
125
182
|
notify();
|
|
126
183
|
const accepted = store.set(record).then(async () => {
|
|
184
|
+
provisionalRecords.delete(operationId);
|
|
127
185
|
await publish();
|
|
128
|
-
|
|
186
|
+
recoverInBackground();
|
|
129
187
|
}, async (error) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: operation),
|
|
134
|
-
state: baseState,
|
|
135
|
-
};
|
|
188
|
+
provisionalRecords.delete(operationId);
|
|
189
|
+
localStorageFailures.set(operationId, command);
|
|
190
|
+
applySnapshot(currentRecords());
|
|
136
191
|
notify();
|
|
137
192
|
throw error;
|
|
138
193
|
});
|
|
@@ -143,9 +198,9 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
143
198
|
await recover();
|
|
144
199
|
},
|
|
145
200
|
async refresh(scope) {
|
|
146
|
-
const generation = ++
|
|
201
|
+
const generation = ++baseStateGeneration;
|
|
147
202
|
const loaded = await load(scope);
|
|
148
|
-
if (generation !==
|
|
203
|
+
if (generation !== baseStateGeneration)
|
|
149
204
|
return;
|
|
150
205
|
baseState = loaded;
|
|
151
206
|
await publish();
|
|
@@ -157,5 +212,15 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
157
212
|
};
|
|
158
213
|
}
|
|
159
214
|
function createDurableCommandId() {
|
|
160
|
-
return globalThis.crypto?.randomUUID?.() ??
|
|
215
|
+
return globalThis.crypto?.randomUUID?.() ?? createFallbackDurableCommandId();
|
|
216
|
+
}
|
|
217
|
+
let fallbackDurableCommandSequence = 0;
|
|
218
|
+
function createFallbackDurableCommandId() {
|
|
219
|
+
fallbackDurableCommandSequence += 1;
|
|
220
|
+
return [
|
|
221
|
+
"command",
|
|
222
|
+
Date.now().toString(36),
|
|
223
|
+
fallbackDurableCommandSequence.toString(36),
|
|
224
|
+
Math.random().toString(36).slice(2),
|
|
225
|
+
].join("-");
|
|
161
226
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type BrowserKeyValueDriver = {
|
|
2
|
+
clear: () => Promise<void>;
|
|
3
|
+
close: () => Promise<void>;
|
|
4
|
+
delete: (key: string) => Promise<void>;
|
|
5
|
+
deleteIf: (key: string, predicate: KeyValuePredicate) => Promise<boolean>;
|
|
6
|
+
get: (key: string) => Promise<unknown | null>;
|
|
7
|
+
list: () => Promise<unknown[]>;
|
|
8
|
+
listEntries: () => Promise<Array<{
|
|
9
|
+
key: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
}>>;
|
|
12
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
13
|
+
setIf: (key: string, value: unknown, predicate: KeyValuePredicate) => Promise<boolean>;
|
|
14
|
+
};
|
|
15
|
+
export type KeyValuePredicate = (value: unknown | null) => boolean;
|
|
16
|
+
export type BrowserKeyValueDriverOptions = {
|
|
17
|
+
databaseName: string;
|
|
18
|
+
databaseVersion?: number;
|
|
19
|
+
indexedDB?: IDBFactory;
|
|
20
|
+
operationTimeoutMs: number;
|
|
21
|
+
storeName: string;
|
|
22
|
+
};
|
|
23
|
+
export declare class OfflineStorageError extends Error {
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
|
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OfflineStorageError = void 0;
|
|
3
4
|
exports.createIdbKeyValueDriver = createIdbKeyValueDriver;
|
|
4
5
|
const idb_1 = require("idb");
|
|
6
|
+
class OfflineStorageError extends Error {
|
|
7
|
+
name = "OfflineStorageError";
|
|
8
|
+
}
|
|
9
|
+
exports.OfflineStorageError = OfflineStorageError;
|
|
5
10
|
function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
|
|
6
11
|
if (!indexedDB) {
|
|
7
12
|
return null;
|
|
@@ -12,25 +17,33 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
12
17
|
indexedDB,
|
|
13
18
|
storeName,
|
|
14
19
|
});
|
|
20
|
+
function runMutation(operationName, mutate) {
|
|
21
|
+
return runTransactionalStorageOperation({
|
|
22
|
+
failureMode: "throw",
|
|
23
|
+
fallback: undefined,
|
|
24
|
+
operationName,
|
|
25
|
+
storeName,
|
|
26
|
+
operation: async (setTransaction, isCancelled) => {
|
|
27
|
+
const database = await databasePromise;
|
|
28
|
+
if (isCancelled())
|
|
29
|
+
return;
|
|
30
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
31
|
+
setTransaction(transaction);
|
|
32
|
+
await mutate(transaction.objectStore(storeName));
|
|
33
|
+
await transaction.done;
|
|
34
|
+
},
|
|
35
|
+
timeoutMs: operationTimeoutMs,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
15
38
|
return {
|
|
16
39
|
async clear() {
|
|
17
|
-
await
|
|
18
|
-
fallback: undefined,
|
|
19
|
-
operationName: "clear object store",
|
|
20
|
-
operation: async (setTransaction) => {
|
|
21
|
-
const database = await databasePromise;
|
|
22
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
23
|
-
setTransaction(transaction);
|
|
24
|
-
await transaction.objectStore(storeName).clear();
|
|
25
|
-
await transaction.done;
|
|
26
|
-
},
|
|
27
|
-
timeoutMs: operationTimeoutMs,
|
|
28
|
-
});
|
|
40
|
+
await runMutation("clear object store", (objectStore) => objectStore.clear());
|
|
29
41
|
},
|
|
30
42
|
async close() {
|
|
31
43
|
await runBrowserStorageOperation({
|
|
32
44
|
fallback: undefined,
|
|
33
45
|
operationName: "close database",
|
|
46
|
+
storeName,
|
|
34
47
|
operation: async () => {
|
|
35
48
|
const database = await databasePromise;
|
|
36
49
|
database.close();
|
|
@@ -39,18 +52,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
39
52
|
});
|
|
40
53
|
},
|
|
41
54
|
async delete(key) {
|
|
42
|
-
await
|
|
43
|
-
fallback: undefined,
|
|
44
|
-
operationName: "delete key",
|
|
45
|
-
operation: async (setTransaction) => {
|
|
46
|
-
const database = await databasePromise;
|
|
47
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
48
|
-
setTransaction(transaction);
|
|
49
|
-
await transaction.objectStore(storeName).delete(key);
|
|
50
|
-
await transaction.done;
|
|
51
|
-
},
|
|
52
|
-
timeoutMs: operationTimeoutMs,
|
|
53
|
-
});
|
|
55
|
+
await runMutation("delete key", (objectStore) => objectStore.delete(key));
|
|
54
56
|
},
|
|
55
57
|
async deleteIf(key, predicate) {
|
|
56
58
|
return runConditionalStorageOperation({
|
|
@@ -71,6 +73,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
71
73
|
return runBrowserStorageOperation({
|
|
72
74
|
fallback: null,
|
|
73
75
|
operationName: "read key",
|
|
76
|
+
storeName,
|
|
74
77
|
operation: async () => {
|
|
75
78
|
const database = await databasePromise;
|
|
76
79
|
return (await database.get(storeName, key)) ?? null;
|
|
@@ -82,6 +85,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
82
85
|
return runBrowserStorageOperation({
|
|
83
86
|
fallback: [],
|
|
84
87
|
operationName: "list object store",
|
|
88
|
+
storeName,
|
|
85
89
|
operation: async () => {
|
|
86
90
|
const database = await databasePromise;
|
|
87
91
|
return database.getAll(storeName);
|
|
@@ -93,6 +97,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
93
97
|
return runBrowserStorageOperation({
|
|
94
98
|
fallback: [],
|
|
95
99
|
operationName: "list object-store entries",
|
|
100
|
+
storeName,
|
|
96
101
|
operation: async () => {
|
|
97
102
|
const database = await databasePromise;
|
|
98
103
|
// Keys and values must come from one transaction. Separate database
|
|
@@ -114,18 +119,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
114
119
|
});
|
|
115
120
|
},
|
|
116
121
|
async set(key, value) {
|
|
117
|
-
await
|
|
118
|
-
fallback: undefined,
|
|
119
|
-
operationName: "write key",
|
|
120
|
-
operation: async (setTransaction) => {
|
|
121
|
-
const database = await databasePromise;
|
|
122
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
123
|
-
setTransaction(transaction);
|
|
124
|
-
await transaction.objectStore(storeName).put(value, key);
|
|
125
|
-
await transaction.done;
|
|
126
|
-
},
|
|
127
|
-
timeoutMs: operationTimeoutMs,
|
|
128
|
-
});
|
|
122
|
+
await runMutation("write key", (objectStore) => objectStore.put(value, key));
|
|
129
123
|
},
|
|
130
124
|
async setIf(key, value, predicate) {
|
|
131
125
|
return runConditionalStorageOperation({
|
|
@@ -158,18 +152,27 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
158
152
|
}
|
|
159
153
|
return database;
|
|
160
154
|
}
|
|
161
|
-
async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
|
|
155
|
+
async function runBrowserStorageOperation({ cancelOnTimeout, failureMode = "fallback", fallback, operation, operationName, storeName, timeoutMs, }) {
|
|
162
156
|
let timeoutId = null;
|
|
157
|
+
const startedAt = Date.now();
|
|
163
158
|
try {
|
|
164
159
|
return await Promise.race([
|
|
165
160
|
operation(),
|
|
166
|
-
new Promise((resolve) => {
|
|
161
|
+
new Promise((resolve, reject) => {
|
|
167
162
|
timeoutId = setTimeout(() => {
|
|
168
|
-
console.
|
|
163
|
+
console.warn("[Bayon offline] IndexedDB operation timed out.", {
|
|
164
|
+
elapsedMs: Date.now() - startedAt,
|
|
165
|
+
operationName,
|
|
166
|
+
storeName,
|
|
169
167
|
timeoutMs,
|
|
170
168
|
});
|
|
171
169
|
if (!cancelOnTimeout || cancelOnTimeout()) {
|
|
172
|
-
|
|
170
|
+
if (failureMode === "throw") {
|
|
171
|
+
reject(new OfflineStorageError(`IndexedDB timed out while attempting to ${operationName} in ${storeName}.`));
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
resolve(fallback);
|
|
175
|
+
}
|
|
173
176
|
}
|
|
174
177
|
}, timeoutMs);
|
|
175
178
|
}),
|
|
@@ -177,6 +180,11 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
|
|
|
177
180
|
}
|
|
178
181
|
catch (error) {
|
|
179
182
|
console.error(`IndexedDB failed to ${operationName}.`, { error });
|
|
183
|
+
if (failureMode === "throw") {
|
|
184
|
+
if (error instanceof OfflineStorageError)
|
|
185
|
+
throw error;
|
|
186
|
+
throw new OfflineStorageError(`IndexedDB failed to ${operationName} in ${storeName}.`, { cause: error });
|
|
187
|
+
}
|
|
180
188
|
return fallback;
|
|
181
189
|
}
|
|
182
190
|
finally {
|
|
@@ -185,12 +193,14 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
|
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
195
|
}
|
|
188
|
-
async function runTransactionalStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
196
|
+
async function runTransactionalStorageOperation({ failureMode, fallback, operation, operationName, storeName, timeoutMs, }) {
|
|
189
197
|
let transaction;
|
|
190
198
|
let cancelled = false;
|
|
191
199
|
return runBrowserStorageOperation({
|
|
200
|
+
failureMode,
|
|
192
201
|
fallback,
|
|
193
202
|
operationName,
|
|
203
|
+
storeName,
|
|
194
204
|
timeoutMs,
|
|
195
205
|
cancelOnTimeout() {
|
|
196
206
|
cancelled = true;
|
|
@@ -206,14 +216,12 @@ async function runTransactionalStorageOperation({ fallback, operation, operation
|
|
|
206
216
|
}
|
|
207
217
|
},
|
|
208
218
|
operation: async () => {
|
|
209
|
-
if (cancelled)
|
|
210
|
-
return fallback;
|
|
211
219
|
return operation((nextTransaction) => {
|
|
212
220
|
transaction = nextTransaction;
|
|
213
221
|
// Aborting can reject transaction.done. The operation also awaits it,
|
|
214
222
|
// but observing it here prevents a race from becoming unhandled.
|
|
215
223
|
void transaction.done.catch(() => undefined);
|
|
216
|
-
});
|
|
224
|
+
}, () => cancelled);
|
|
217
225
|
},
|
|
218
226
|
});
|
|
219
227
|
}
|
|
@@ -221,8 +229,10 @@ async function runConditionalStorageOperation({ apply, databasePromise, operatio
|
|
|
221
229
|
let transaction;
|
|
222
230
|
let cancelled = false;
|
|
223
231
|
return runBrowserStorageOperation({
|
|
232
|
+
failureMode: "throw",
|
|
224
233
|
fallback: false,
|
|
225
234
|
operationName,
|
|
235
|
+
storeName,
|
|
226
236
|
timeoutMs,
|
|
227
237
|
cancelOnTimeout() {
|
|
228
238
|
cancelled = true;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./readCache.js";
|
|
2
|
+
export * from "./pendingWrites.js";
|
|
3
|
+
export * from "./browserRecovery.js";
|
|
4
|
+
export * from "./browserCoordination.js";
|
|
5
|
+
export * from "./browserFallbackRecovery.js";
|
|
6
|
+
export * from "./durableCommandStore.js";
|
|
7
|
+
export { OfflineStorageError } from "./idbDriver.js";
|
package/dist-cjs/index.js
CHANGED
|
@@ -14,9 +14,12 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.OfflineStorageError = void 0;
|
|
17
18
|
__exportStar(require("./readCache"), exports);
|
|
18
19
|
__exportStar(require("./pendingWrites"), exports);
|
|
19
20
|
__exportStar(require("./browserRecovery"), exports);
|
|
20
21
|
__exportStar(require("./browserCoordination"), exports);
|
|
21
22
|
__exportStar(require("./browserFallbackRecovery"), exports);
|
|
22
23
|
__exportStar(require("./durableCommandStore"), exports);
|
|
24
|
+
var idbDriver_1 = require("./idbDriver");
|
|
25
|
+
Object.defineProperty(exports, "OfflineStorageError", { enumerable: true, get: function () { return idbDriver_1.OfflineStorageError; } });
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
|
|
2
|
+
export declare const pendingWriteSchemaVersion = 1;
|
|
3
|
+
export declare const pendingWriteOperationTimeoutMs = 2000;
|
|
4
|
+
export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
|
|
5
|
+
export type PendingWriteStatus = "pending" | "syncing" | "failed";
|
|
6
|
+
export type PendingWriteRecord<Payload = unknown> = {
|
|
7
|
+
error?: string;
|
|
8
|
+
id: string;
|
|
9
|
+
/** Do not execute this accepted command before this ISO timestamp. */
|
|
10
|
+
notBefore?: string;
|
|
11
|
+
payload: Payload;
|
|
12
|
+
savedAt: string;
|
|
13
|
+
snapshotId: string;
|
|
14
|
+
/** Timestamp of the active remote-sync claim, absent when not owned. */
|
|
15
|
+
syncingAt?: string;
|
|
16
|
+
syncStatus: PendingWriteStatus;
|
|
17
|
+
};
|
|
18
|
+
export type PendingWriteRecordInput<Payload = unknown> = Omit<PendingWriteRecord<Payload>, "snapshotId"> & {
|
|
19
|
+
snapshotId?: string;
|
|
20
|
+
};
|
|
21
|
+
export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown) => Payload | null;
|
|
22
|
+
export type PendingWriteDriver = {
|
|
23
|
+
clear: (key: string) => Promise<void>;
|
|
24
|
+
clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
|
|
25
|
+
clearAll?: () => Promise<void>;
|
|
26
|
+
close?: () => Promise<void>;
|
|
27
|
+
get: (key: string) => Promise<unknown | null>;
|
|
28
|
+
list: () => Promise<unknown[]>;
|
|
29
|
+
listEntries?: () => Promise<Array<{
|
|
30
|
+
key: string;
|
|
31
|
+
value: unknown;
|
|
32
|
+
}>>;
|
|
33
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
34
|
+
setIf: (key: string, value: unknown, predicate: PendingWritePredicate) => Promise<boolean>;
|
|
35
|
+
};
|
|
36
|
+
export type PendingWritePredicate = (value: unknown | null) => boolean;
|
|
37
|
+
export type PendingWriteStoreChange<Payload = unknown> = {
|
|
38
|
+
record: PendingWriteRecord<Payload>;
|
|
39
|
+
type: "set";
|
|
40
|
+
} | {
|
|
41
|
+
id: string;
|
|
42
|
+
type: "clear";
|
|
43
|
+
} | {
|
|
44
|
+
type: "clearAll";
|
|
45
|
+
} | {
|
|
46
|
+
record: PendingWriteRecord<Payload>;
|
|
47
|
+
type: "replace";
|
|
48
|
+
};
|
|
49
|
+
export type PendingWriteStoreChangeListener<Payload = unknown> = (change: PendingWriteStoreChange<Payload>) => void;
|
|
50
|
+
export type PendingWriteStore<Payload = unknown> = {
|
|
51
|
+
claimIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>, now: string) => Promise<boolean>;
|
|
52
|
+
clear: (id: string) => Promise<void>;
|
|
53
|
+
clearAll: () => Promise<void>;
|
|
54
|
+
clearIfSnapshotId: (id: string, snapshotId: string) => Promise<boolean>;
|
|
55
|
+
close: () => Promise<void>;
|
|
56
|
+
get: (id: string) => Promise<PendingWriteRecord<Payload> | null>;
|
|
57
|
+
list: () => Promise<Array<PendingWriteRecord<Payload>>>;
|
|
58
|
+
replaceIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>) => Promise<boolean>;
|
|
59
|
+
set: (record: PendingWriteRecordInput<Payload>) => Promise<PendingWriteRecord<Payload>>;
|
|
60
|
+
/**
|
|
61
|
+
* Observes changes made through this in-memory store instance. It deliberately
|
|
62
|
+
* does not claim cross-tab/process delivery.
|
|
63
|
+
*/
|
|
64
|
+
subscribe: (listener: PendingWriteStoreChangeListener<Payload>) => () => void;
|
|
65
|
+
};
|
|
66
|
+
export type PendingWriteStoreOptions<Payload = unknown> = {
|
|
67
|
+
keyPrefix: string;
|
|
68
|
+
normalizeLegacyRecord?: PendingWritePayloadNormalizer<PendingWriteRecordInput<Payload>>;
|
|
69
|
+
normalizePayload?: PendingWritePayloadNormalizer<Payload>;
|
|
70
|
+
schemaVersion?: number;
|
|
71
|
+
};
|
|
72
|
+
export type BrowserPendingWriteStoreOptions<Payload = unknown> = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & PendingWriteStoreOptions<Payload> & {
|
|
73
|
+
databaseName: string;
|
|
74
|
+
operationTimeoutMs?: number;
|
|
75
|
+
storeName: string;
|
|
76
|
+
};
|
|
77
|
+
export type PendingWriteKeyOptions = {
|
|
78
|
+
id: string;
|
|
79
|
+
prefix: string;
|
|
80
|
+
};
|
|
81
|
+
export type SyncPendingWriteResult = {
|
|
82
|
+
status: "idle";
|
|
83
|
+
} | {
|
|
84
|
+
status: "synced";
|
|
85
|
+
} | {
|
|
86
|
+
status: "superseded";
|
|
87
|
+
} | {
|
|
88
|
+
error: string;
|
|
89
|
+
failure: unknown;
|
|
90
|
+
status: "failed";
|
|
91
|
+
};
|
|
92
|
+
export type SyncPendingWritesResult = {
|
|
93
|
+
attempted: number;
|
|
94
|
+
failed: number;
|
|
95
|
+
idle: number;
|
|
96
|
+
/** Records still selected after this attempt, including newer superseding writes. */
|
|
97
|
+
remaining: number;
|
|
98
|
+
superseded: number;
|
|
99
|
+
synced: number;
|
|
100
|
+
};
|
|
101
|
+
export type PendingWriteSyncContext = {
|
|
102
|
+
/** Stable durable-record identity; safe to use as a remote idempotency key. */
|
|
103
|
+
recordId: string;
|
|
104
|
+
};
|
|
105
|
+
export type PendingWriteSync<Payload, Result = unknown> = (payload: Payload, context: PendingWriteSyncContext) => Promise<Result>;
|
|
106
|
+
export type RunPendingWriteResult<TResult, TQueued> = {
|
|
107
|
+
result: TResult;
|
|
108
|
+
status: "synced";
|
|
109
|
+
} | {
|
|
110
|
+
queued: TQueued;
|
|
111
|
+
status: "queued";
|
|
112
|
+
};
|
|
113
|
+
export type PendingWriteRunOptions<TResult, TQueued> = {
|
|
114
|
+
enqueue: () => Promise<TQueued>;
|
|
115
|
+
isOnline: () => boolean;
|
|
116
|
+
shouldQueue: (error: unknown) => boolean;
|
|
117
|
+
write: () => Promise<TResult>;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Serializes recovery and new writes for one caller-defined scope.
|
|
121
|
+
*
|
|
122
|
+
* A queued write that cannot be replayed stays ahead of later writes. This
|
|
123
|
+
* preserves remote write order; callers retain ownership of scope selection
|
|
124
|
+
* and idempotent remote mutation semantics.
|
|
125
|
+
*/
|
|
126
|
+
export type OrderedPendingWriteExecutor<Payload> = {
|
|
127
|
+
recover: () => Promise<SyncPendingWritesResult>;
|
|
128
|
+
run: <TResult, TQueued>(options: PendingWriteRunOptions<TResult, TQueued>) => Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
129
|
+
};
|
|
130
|
+
export type PendingWriteSummary = {
|
|
131
|
+
failedCount: number;
|
|
132
|
+
pendingCount: number;
|
|
133
|
+
syncingCount: number;
|
|
134
|
+
totalCount: number;
|
|
135
|
+
};
|
|
136
|
+
export declare function createPendingWriteStore<Payload = unknown>(driver: PendingWriteDriver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion, }: PendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
137
|
+
export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
|
|
138
|
+
export declare function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: Omit<BrowserPendingWriteStoreOptions, "keyPrefix" | "normalizePayload" | "schemaVersion">): PendingWriteDriver | null;
|
|
139
|
+
export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
140
|
+
export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
|
|
141
|
+
export declare function createPendingWriteSnapshotId(): string;
|
|
142
|
+
export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
|
|
143
|
+
id: string;
|
|
144
|
+
now?: () => string;
|
|
145
|
+
store: PendingWriteStore<Payload>;
|
|
146
|
+
sync: PendingWriteSync<Payload>;
|
|
147
|
+
}): Promise<SyncPendingWriteResult>;
|
|
148
|
+
/**
|
|
149
|
+
* Replays selected writes oldest first and coalesces overlapping runs for the
|
|
150
|
+
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
151
|
+
* mutation semantics.
|
|
152
|
+
*/
|
|
153
|
+
export declare function syncPendingWrites<Payload>({ now, scopeKey, select, stopOnFailure, store, sync, }: {
|
|
154
|
+
now?: () => string;
|
|
155
|
+
scopeKey?: string;
|
|
156
|
+
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
157
|
+
/** Preserve caller ordering by leaving later records for a future retry. */
|
|
158
|
+
stopOnFailure?: boolean;
|
|
159
|
+
store: PendingWriteStore<Payload>;
|
|
160
|
+
sync: PendingWriteSync<Payload>;
|
|
161
|
+
}): Promise<SyncPendingWritesResult>;
|
|
162
|
+
/**
|
|
163
|
+
* Attempts a remote write now and persists a caller-created durable fallback
|
|
164
|
+
* only when offline or when the supplied retry classifier accepts the error.
|
|
165
|
+
*/
|
|
166
|
+
export declare function runPendingWrite<TResult, TQueued>({ enqueue, isOnline, shouldQueue, write, }: PendingWriteRunOptions<TResult, TQueued>): Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
167
|
+
export declare function summarizePendingWrites<Payload>(records: Array<PendingWriteRecord<Payload>>): PendingWriteSummary;
|
|
168
|
+
/**
|
|
169
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
170
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
171
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
172
|
+
*/
|
|
173
|
+
export declare function createOrderedPendingWriteExecutor<Payload>({ now, scopeKey, select, store, sync, }: {
|
|
174
|
+
now?: () => string;
|
|
175
|
+
scopeKey?: string;
|
|
176
|
+
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
177
|
+
store: PendingWriteStore<Payload>;
|
|
178
|
+
sync: PendingWriteSync<Payload>;
|
|
179
|
+
}): OrderedPendingWriteExecutor<Payload>;
|