@bayonai/offline 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/idbDriver.d.ts +16 -0
- package/dist/idbDriver.js +107 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pendingWrites.d.ts +63 -0
- package/dist/pendingWrites.js +186 -0
- package/dist/readCache.d.ts +59 -0
- package/dist/readCache.js +171 -0
- package/dist-cjs/idbDriver.js +110 -0
- package/dist-cjs/index.js +18 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/pendingWrites.js +195 -0
- package/dist-cjs/readCache.js +180 -0
- package/package.json +59 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Added the initial workspace package with generic read-cache and pending-write
|
|
6
|
+
primitives backed by `idb`.
|
|
7
|
+
- Replaced app-specific test fixtures with neutral package examples.
|
|
8
|
+
- Added package-local license, metadata, publish configuration, and deployment
|
|
9
|
+
documentation.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bayon AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @bayonai/offline
|
|
2
|
+
|
|
3
|
+
Reusable offline read-cache and pending-write primitives.
|
|
4
|
+
|
|
5
|
+
This package owns generic browser and memory storage behavior. Host apps own
|
|
6
|
+
their domain records, remote APIs, cache prefixes, database names, and sync
|
|
7
|
+
functions.
|
|
8
|
+
|
|
9
|
+
The package uses `idb` internally for IndexedDB access, but consumers should use
|
|
10
|
+
the exported cache and pending-write APIs rather than depending on the storage
|
|
11
|
+
implementation.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @bayonai/offline
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Read Cache
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
createBrowserReadCache,
|
|
24
|
+
createReadCacheKey,
|
|
25
|
+
loadReadCacheResource,
|
|
26
|
+
} from "@bayonai/offline";
|
|
27
|
+
|
|
28
|
+
const cache = createBrowserReadCache({
|
|
29
|
+
databaseName: "my-app:v1",
|
|
30
|
+
storeName: "read-cache",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const key = createReadCacheKey({
|
|
34
|
+
namespace: "projects",
|
|
35
|
+
params: { limit: 20 },
|
|
36
|
+
prefix: "my-app:v1",
|
|
37
|
+
scope: "user-1",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const projects = await loadReadCacheResource({
|
|
41
|
+
cache,
|
|
42
|
+
key,
|
|
43
|
+
loadFresh: () => fetchProjects(),
|
|
44
|
+
maxAgeMs: 30_000,
|
|
45
|
+
onUpdate: ({ data, fromCache }) => {
|
|
46
|
+
renderProjects(data, { fromCache });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Pending Writes
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import {
|
|
55
|
+
createBrowserPendingWriteStore,
|
|
56
|
+
syncPendingWrite,
|
|
57
|
+
} from "@bayonai/offline";
|
|
58
|
+
|
|
59
|
+
type Draft = {
|
|
60
|
+
body: string;
|
|
61
|
+
title: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const store = createBrowserPendingWriteStore<Draft>({
|
|
65
|
+
databaseName: "my-app:pending-writes:v1",
|
|
66
|
+
keyPrefix: "my-app:v1:pending-draft",
|
|
67
|
+
storeName: "pending-writes",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await store.set({
|
|
71
|
+
id: "draft-1",
|
|
72
|
+
payload: { body: "Local text", title: "Local draft" },
|
|
73
|
+
savedAt: new Date().toISOString(),
|
|
74
|
+
syncStatus: "pending",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await syncPendingWrite({
|
|
78
|
+
id: "draft-1",
|
|
79
|
+
store,
|
|
80
|
+
sync: (draft) => saveDraftRemotely(draft),
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Host apps that change their pending-write payload shape can normalize current
|
|
85
|
+
records while keeping domain logic outside the package:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const store = createBrowserPendingWriteStore<Draft>({
|
|
89
|
+
databaseName: "my-app:pending-writes:v1",
|
|
90
|
+
keyPrefix: "my-app:v1:pending-draft",
|
|
91
|
+
storeName: "pending-writes",
|
|
92
|
+
normalizePayload(value) {
|
|
93
|
+
return isDraft(value) ? normalizeDraft(value) : null;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Boundary
|
|
99
|
+
|
|
100
|
+
`@bayonai/offline` has no Firebase, Firestore, Next.js, React, or application
|
|
101
|
+
domain dependencies. Host apps provide remote loaders and sync functions.
|
|
102
|
+
|
|
103
|
+
Host apps are also responsible for:
|
|
104
|
+
|
|
105
|
+
- Choosing database names, store names, and key prefixes.
|
|
106
|
+
- Scoping storage by user, tenant, workspace, or other app identity.
|
|
107
|
+
- Validating and migrating app-specific payloads.
|
|
108
|
+
- Deciding when to clear browser storage, such as on sign-out.
|
|
109
|
+
- Providing remote read and write functions.
|
|
110
|
+
|
|
111
|
+
## Package Scripts
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pnpm --filter @bayonai/offline test
|
|
115
|
+
pnpm --filter @bayonai/offline run build
|
|
116
|
+
pnpm --filter @bayonai/offline pack --dry-run
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Publish Checklist
|
|
120
|
+
|
|
121
|
+
1. Run the package tests and build.
|
|
122
|
+
2. Run `pnpm --filter @bayonai/offline pack --dry-run` and confirm the tarball
|
|
123
|
+
includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
|
|
124
|
+
`CHANGELOG.md`, and `LICENSE`.
|
|
125
|
+
3. Confirm `package.json` metadata, version, repository, license, and
|
|
126
|
+
`publishConfig.access` are correct.
|
|
127
|
+
4. Publish from the package directory or with the package filter after the
|
|
128
|
+
workspace lockfile is current.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type BrowserKeyValueDriver = {
|
|
2
|
+
clear: () => Promise<void>;
|
|
3
|
+
close: () => Promise<void>;
|
|
4
|
+
delete: (key: string) => Promise<void>;
|
|
5
|
+
get: (key: string) => Promise<unknown | null>;
|
|
6
|
+
list: () => Promise<unknown[]>;
|
|
7
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export type BrowserKeyValueDriverOptions = {
|
|
10
|
+
databaseName: string;
|
|
11
|
+
databaseVersion?: number;
|
|
12
|
+
indexedDB?: IDBFactory;
|
|
13
|
+
operationTimeoutMs: number;
|
|
14
|
+
storeName: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { wrap } from "idb";
|
|
2
|
+
export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
|
|
3
|
+
if (!indexedDB) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
const databasePromise = openKeyValueDatabase({
|
|
7
|
+
databaseName,
|
|
8
|
+
databaseVersion,
|
|
9
|
+
indexedDB,
|
|
10
|
+
storeName,
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
async clear() {
|
|
14
|
+
await runBrowserStorageOperation({
|
|
15
|
+
fallback: undefined,
|
|
16
|
+
operation: async () => {
|
|
17
|
+
const database = await databasePromise;
|
|
18
|
+
await database.clear(storeName);
|
|
19
|
+
},
|
|
20
|
+
timeoutMs: operationTimeoutMs,
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
async close() {
|
|
24
|
+
await runBrowserStorageOperation({
|
|
25
|
+
fallback: undefined,
|
|
26
|
+
operation: async () => {
|
|
27
|
+
const database = await databasePromise;
|
|
28
|
+
database.close();
|
|
29
|
+
},
|
|
30
|
+
timeoutMs: operationTimeoutMs,
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
async delete(key) {
|
|
34
|
+
await runBrowserStorageOperation({
|
|
35
|
+
fallback: undefined,
|
|
36
|
+
operation: async () => {
|
|
37
|
+
const database = await databasePromise;
|
|
38
|
+
await database.delete(storeName, key);
|
|
39
|
+
},
|
|
40
|
+
timeoutMs: operationTimeoutMs,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
async get(key) {
|
|
44
|
+
return runBrowserStorageOperation({
|
|
45
|
+
fallback: null,
|
|
46
|
+
operation: async () => {
|
|
47
|
+
const database = await databasePromise;
|
|
48
|
+
return (await database.get(storeName, key)) ?? null;
|
|
49
|
+
},
|
|
50
|
+
timeoutMs: operationTimeoutMs,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
async list() {
|
|
54
|
+
return runBrowserStorageOperation({
|
|
55
|
+
fallback: [],
|
|
56
|
+
operation: async () => {
|
|
57
|
+
const database = await databasePromise;
|
|
58
|
+
return database.getAll(storeName);
|
|
59
|
+
},
|
|
60
|
+
timeoutMs: operationTimeoutMs,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
async set(key, value) {
|
|
64
|
+
await runBrowserStorageOperation({
|
|
65
|
+
fallback: undefined,
|
|
66
|
+
operation: async () => {
|
|
67
|
+
const database = await databasePromise;
|
|
68
|
+
await database.put(storeName, value, key);
|
|
69
|
+
},
|
|
70
|
+
timeoutMs: operationTimeoutMs,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB, storeName, }) {
|
|
76
|
+
const request = indexedDB.open(databaseName, databaseVersion);
|
|
77
|
+
request.onupgradeneeded = () => {
|
|
78
|
+
const database = request.result;
|
|
79
|
+
if (!database.objectStoreNames.contains(storeName)) {
|
|
80
|
+
database.createObjectStore(storeName);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const database = await wrap(request);
|
|
84
|
+
if (!database) {
|
|
85
|
+
throw new Error(`Could not open IndexedDB database ${databaseName}.`);
|
|
86
|
+
}
|
|
87
|
+
return database;
|
|
88
|
+
}
|
|
89
|
+
async function runBrowserStorageOperation({ fallback, operation, timeoutMs, }) {
|
|
90
|
+
let timeoutId = null;
|
|
91
|
+
try {
|
|
92
|
+
return await Promise.race([
|
|
93
|
+
operation(),
|
|
94
|
+
new Promise((resolve) => {
|
|
95
|
+
timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return fallback;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (timeoutId) {
|
|
104
|
+
clearTimeout(timeoutId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver";
|
|
2
|
+
export declare const pendingWriteSchemaVersion = 1;
|
|
3
|
+
export declare const pendingWriteOperationTimeoutMs = 2000;
|
|
4
|
+
export type PendingWriteStatus = "pending" | "syncing" | "failed";
|
|
5
|
+
export type PendingWriteRecord<Payload = unknown> = {
|
|
6
|
+
error?: string;
|
|
7
|
+
id: string;
|
|
8
|
+
payload: Payload;
|
|
9
|
+
savedAt: string;
|
|
10
|
+
syncStatus: PendingWriteStatus;
|
|
11
|
+
};
|
|
12
|
+
export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown) => Payload | null;
|
|
13
|
+
export type PendingWriteDriver = {
|
|
14
|
+
clear: (key: string) => Promise<void>;
|
|
15
|
+
clearAll: () => Promise<void>;
|
|
16
|
+
close?: () => Promise<void>;
|
|
17
|
+
get: (key: string) => Promise<unknown | null>;
|
|
18
|
+
list: () => Promise<unknown[]>;
|
|
19
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export type PendingWriteStore<Payload = unknown> = {
|
|
22
|
+
clear: (id: string) => Promise<void>;
|
|
23
|
+
clearAll: () => Promise<void>;
|
|
24
|
+
clearIfSavedAt: (id: string, savedAt: string) => Promise<boolean>;
|
|
25
|
+
close: () => Promise<void>;
|
|
26
|
+
get: (id: string) => Promise<PendingWriteRecord<Payload> | null>;
|
|
27
|
+
list: () => Promise<Array<PendingWriteRecord<Payload>>>;
|
|
28
|
+
set: (record: PendingWriteRecord<Payload>) => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
export type PendingWriteStoreOptions<Payload = unknown> = {
|
|
31
|
+
keyPrefix: string;
|
|
32
|
+
normalizePayload?: PendingWritePayloadNormalizer<Payload>;
|
|
33
|
+
schemaVersion?: number;
|
|
34
|
+
};
|
|
35
|
+
export type BrowserPendingWriteStoreOptions<Payload = unknown> = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & PendingWriteStoreOptions<Payload> & {
|
|
36
|
+
databaseName: string;
|
|
37
|
+
operationTimeoutMs?: number;
|
|
38
|
+
storeName: string;
|
|
39
|
+
};
|
|
40
|
+
export type PendingWriteKeyOptions = {
|
|
41
|
+
id: string;
|
|
42
|
+
prefix: string;
|
|
43
|
+
};
|
|
44
|
+
export type SyncPendingWriteResult = {
|
|
45
|
+
status: "idle";
|
|
46
|
+
} | {
|
|
47
|
+
status: "synced";
|
|
48
|
+
} | {
|
|
49
|
+
status: "superseded";
|
|
50
|
+
} | {
|
|
51
|
+
error: string;
|
|
52
|
+
status: "failed";
|
|
53
|
+
};
|
|
54
|
+
export declare function createPendingWriteStore<Payload = unknown>(driver: PendingWriteDriver, { keyPrefix, normalizePayload, schemaVersion, }: PendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
55
|
+
export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
|
|
56
|
+
export declare function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: Omit<BrowserPendingWriteStoreOptions, "keyPrefix" | "normalizePayload" | "schemaVersion">): PendingWriteDriver | null;
|
|
57
|
+
export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
58
|
+
export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
|
|
59
|
+
export declare function syncPendingWrite<Payload>({ id, store, sync, }: {
|
|
60
|
+
id: string;
|
|
61
|
+
store: PendingWriteStore<Payload>;
|
|
62
|
+
sync: (payload: Payload) => Promise<unknown>;
|
|
63
|
+
}): Promise<SyncPendingWriteResult>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
2
|
+
export const pendingWriteSchemaVersion = 1;
|
|
3
|
+
export const pendingWriteOperationTimeoutMs = 2_000;
|
|
4
|
+
export function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVersion = pendingWriteSchemaVersion, }) {
|
|
5
|
+
return {
|
|
6
|
+
async clear(id) {
|
|
7
|
+
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
8
|
+
},
|
|
9
|
+
async clearAll() {
|
|
10
|
+
await driver.clearAll();
|
|
11
|
+
},
|
|
12
|
+
async clearIfSavedAt(id, savedAt) {
|
|
13
|
+
const current = await this.get(id);
|
|
14
|
+
if (!current || current.savedAt !== savedAt) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
await this.clear(id);
|
|
18
|
+
return true;
|
|
19
|
+
},
|
|
20
|
+
async close() {
|
|
21
|
+
await driver.close?.();
|
|
22
|
+
},
|
|
23
|
+
async get(id) {
|
|
24
|
+
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizePayload })?.record ?? null);
|
|
25
|
+
},
|
|
26
|
+
async list() {
|
|
27
|
+
return (await driver.list())
|
|
28
|
+
.map((value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
29
|
+
normalizePayload,
|
|
30
|
+
})?.record)
|
|
31
|
+
.filter((record) => Boolean(record))
|
|
32
|
+
.sort((left, right) => left.savedAt.localeCompare(right.savedAt));
|
|
33
|
+
},
|
|
34
|
+
async set(record) {
|
|
35
|
+
await driver.set(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), {
|
|
36
|
+
record,
|
|
37
|
+
schemaVersion,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function createMemoryPendingWriteDriver() {
|
|
43
|
+
const records = new Map();
|
|
44
|
+
return {
|
|
45
|
+
async clear(key) {
|
|
46
|
+
records.delete(key);
|
|
47
|
+
},
|
|
48
|
+
async clearAll() {
|
|
49
|
+
records.clear();
|
|
50
|
+
},
|
|
51
|
+
async close() { },
|
|
52
|
+
async get(key) {
|
|
53
|
+
return records.get(key) ?? null;
|
|
54
|
+
},
|
|
55
|
+
async list() {
|
|
56
|
+
return [...records.values()];
|
|
57
|
+
},
|
|
58
|
+
async set(key, value) {
|
|
59
|
+
records.set(key, value);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = pendingWriteOperationTimeoutMs, storeName, }) {
|
|
64
|
+
const driver = createIdbKeyValueDriver({
|
|
65
|
+
databaseName,
|
|
66
|
+
databaseVersion,
|
|
67
|
+
indexedDB,
|
|
68
|
+
operationTimeoutMs,
|
|
69
|
+
storeName,
|
|
70
|
+
});
|
|
71
|
+
return driver ? toPendingWriteDriver(driver) : null;
|
|
72
|
+
}
|
|
73
|
+
export function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizePayload, operationTimeoutMs = pendingWriteOperationTimeoutMs, schemaVersion = pendingWriteSchemaVersion, storeName, }) {
|
|
74
|
+
const driver = createBrowserPendingWriteDriver({
|
|
75
|
+
databaseName,
|
|
76
|
+
databaseVersion,
|
|
77
|
+
indexedDB,
|
|
78
|
+
operationTimeoutMs,
|
|
79
|
+
storeName,
|
|
80
|
+
});
|
|
81
|
+
return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizePayload, schemaVersion });
|
|
82
|
+
}
|
|
83
|
+
export function createPendingWriteKey({ id, prefix }) {
|
|
84
|
+
return `${prefix}:${encodeURIComponent(id)}`;
|
|
85
|
+
}
|
|
86
|
+
export async function syncPendingWrite({ id, store, sync, }) {
|
|
87
|
+
const pending = await store.get(id);
|
|
88
|
+
if (!pending) {
|
|
89
|
+
return { status: "idle" };
|
|
90
|
+
}
|
|
91
|
+
await markPendingSnapshot({
|
|
92
|
+
id,
|
|
93
|
+
nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
|
|
94
|
+
savedAt: pending.savedAt,
|
|
95
|
+
store,
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await sync(pending.payload);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
102
|
+
await markPendingSnapshot({
|
|
103
|
+
id,
|
|
104
|
+
nextRecord: { ...pending, error: message, syncStatus: "failed" },
|
|
105
|
+
savedAt: pending.savedAt,
|
|
106
|
+
store,
|
|
107
|
+
});
|
|
108
|
+
return { error: message, status: "failed" };
|
|
109
|
+
}
|
|
110
|
+
return (await store.clearIfSavedAt(id, pending.savedAt))
|
|
111
|
+
? { status: "synced" }
|
|
112
|
+
: { status: "superseded" };
|
|
113
|
+
}
|
|
114
|
+
function createNoopPendingWriteDriver() {
|
|
115
|
+
return {
|
|
116
|
+
async clear() { },
|
|
117
|
+
async clearAll() { },
|
|
118
|
+
async close() { },
|
|
119
|
+
async get() {
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
async list() {
|
|
123
|
+
return [];
|
|
124
|
+
},
|
|
125
|
+
async set() { },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function toPendingWriteDriver(driver) {
|
|
129
|
+
return {
|
|
130
|
+
clear: driver.delete,
|
|
131
|
+
clearAll: driver.clear,
|
|
132
|
+
close: driver.close,
|
|
133
|
+
get: driver.get,
|
|
134
|
+
list: driver.list,
|
|
135
|
+
set: driver.set,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function markPendingSnapshot({ id, nextRecord, savedAt, store, }) {
|
|
139
|
+
const current = await store.get(id);
|
|
140
|
+
if (current?.savedAt !== savedAt) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
await store.set(nextRecord);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
function toPendingWriteEnvelope(value, schemaVersion, { normalizePayload }) {
|
|
147
|
+
if (!value ||
|
|
148
|
+
typeof value !== "object" ||
|
|
149
|
+
!("schemaVersion" in value) ||
|
|
150
|
+
value.schemaVersion !== schemaVersion ||
|
|
151
|
+
!("record" in value) ||
|
|
152
|
+
!isPendingWriteRecord(value.record)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const record = normalizePendingWriteRecord(value.record, normalizePayload);
|
|
156
|
+
return record ? { record, schemaVersion } : null;
|
|
157
|
+
}
|
|
158
|
+
function normalizePendingWriteRecord(record, normalizePayload) {
|
|
159
|
+
if (!normalizePayload) {
|
|
160
|
+
return record;
|
|
161
|
+
}
|
|
162
|
+
const payload = normalizePayload(record.payload);
|
|
163
|
+
if (payload === null) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...(typeof record.error === "string" ? { error: record.error } : {}),
|
|
168
|
+
id: record.id,
|
|
169
|
+
payload,
|
|
170
|
+
savedAt: record.savedAt,
|
|
171
|
+
syncStatus: record.syncStatus,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function isPendingWriteRecord(value) {
|
|
175
|
+
return (value !== null &&
|
|
176
|
+
typeof value === "object" &&
|
|
177
|
+
"id" in value &&
|
|
178
|
+
typeof value.id === "string" &&
|
|
179
|
+
"savedAt" in value &&
|
|
180
|
+
typeof value.savedAt === "string" &&
|
|
181
|
+
"syncStatus" in value &&
|
|
182
|
+
(value.syncStatus === "pending" ||
|
|
183
|
+
value.syncStatus === "syncing" ||
|
|
184
|
+
value.syncStatus === "failed") &&
|
|
185
|
+
"payload" in value);
|
|
186
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver";
|
|
2
|
+
export declare const readCacheSchemaVersion = 1;
|
|
3
|
+
export declare const readCacheFreshnessMaxAgeMs = 30000;
|
|
4
|
+
export declare const browserReadCacheOperationTimeoutMs = 2000;
|
|
5
|
+
export type ReadCacheHit<T> = {
|
|
6
|
+
fromCache: true;
|
|
7
|
+
savedAt: string;
|
|
8
|
+
value: T;
|
|
9
|
+
};
|
|
10
|
+
export type ReadCacheDriver = {
|
|
11
|
+
clear: () => Promise<void>;
|
|
12
|
+
close?: () => Promise<void>;
|
|
13
|
+
get: (key: string) => Promise<unknown | null>;
|
|
14
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export type ReadCache = {
|
|
17
|
+
clear: () => Promise<void>;
|
|
18
|
+
close: () => Promise<void>;
|
|
19
|
+
get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
|
|
20
|
+
set: <T>(key: string, value: T) => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export type ReadThroughCacheResult<T> = {
|
|
23
|
+
data: T;
|
|
24
|
+
error: Error | null;
|
|
25
|
+
fromCache: boolean;
|
|
26
|
+
stale: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type ReadCacheResourceUpdate<T> = {
|
|
29
|
+
data: T;
|
|
30
|
+
error?: Error;
|
|
31
|
+
fromCache: boolean;
|
|
32
|
+
};
|
|
33
|
+
export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
|
|
34
|
+
databaseName: string;
|
|
35
|
+
operationTimeoutMs?: number;
|
|
36
|
+
storeName: string;
|
|
37
|
+
};
|
|
38
|
+
export type ReadCacheKeyOptions = {
|
|
39
|
+
namespace: string;
|
|
40
|
+
params?: Record<string, string | number | boolean | null | undefined>;
|
|
41
|
+
prefix: string;
|
|
42
|
+
scope: string;
|
|
43
|
+
};
|
|
44
|
+
export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
|
|
45
|
+
export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
|
|
46
|
+
export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
|
|
47
|
+
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
48
|
+
export declare function readThroughCache<T>({ cache, key, loadFresh, }: {
|
|
49
|
+
cache: ReadCache;
|
|
50
|
+
key: string;
|
|
51
|
+
loadFresh: () => Promise<T>;
|
|
52
|
+
}): Promise<ReadThroughCacheResult<T>>;
|
|
53
|
+
export declare function loadReadCacheResource<T>({ cache, key, loadFresh, maxAgeMs, onUpdate, }: {
|
|
54
|
+
cache: ReadCache;
|
|
55
|
+
key: string;
|
|
56
|
+
loadFresh: () => Promise<T>;
|
|
57
|
+
maxAgeMs?: number;
|
|
58
|
+
onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
|
|
59
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
2
|
+
export const readCacheSchemaVersion = 1;
|
|
3
|
+
export const readCacheFreshnessMaxAgeMs = 30_000;
|
|
4
|
+
export const browserReadCacheOperationTimeoutMs = 2_000;
|
|
5
|
+
const pendingFreshLoads = new Map();
|
|
6
|
+
export function createReadCache(driver) {
|
|
7
|
+
return {
|
|
8
|
+
async clear() {
|
|
9
|
+
pendingFreshLoads.clear();
|
|
10
|
+
await driver.clear();
|
|
11
|
+
},
|
|
12
|
+
async close() {
|
|
13
|
+
await driver.close?.();
|
|
14
|
+
},
|
|
15
|
+
async get(key) {
|
|
16
|
+
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
17
|
+
return envelope
|
|
18
|
+
? {
|
|
19
|
+
fromCache: true,
|
|
20
|
+
savedAt: envelope.savedAt,
|
|
21
|
+
value: envelope.value,
|
|
22
|
+
}
|
|
23
|
+
: null;
|
|
24
|
+
},
|
|
25
|
+
async set(key, value) {
|
|
26
|
+
await driver.set(key, {
|
|
27
|
+
savedAt: new Date().toISOString(),
|
|
28
|
+
schemaVersion: readCacheSchemaVersion,
|
|
29
|
+
value,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function createMemoryReadCacheDriver() {
|
|
35
|
+
const records = new Map();
|
|
36
|
+
return {
|
|
37
|
+
async clear() {
|
|
38
|
+
records.clear();
|
|
39
|
+
},
|
|
40
|
+
async close() { },
|
|
41
|
+
async get(key) {
|
|
42
|
+
return records.get(key) ?? null;
|
|
43
|
+
},
|
|
44
|
+
async set(key, value) {
|
|
45
|
+
records.set(key, value);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = browserReadCacheOperationTimeoutMs, storeName, }) {
|
|
50
|
+
const driver = createIdbKeyValueDriver({
|
|
51
|
+
databaseName,
|
|
52
|
+
databaseVersion,
|
|
53
|
+
indexedDB,
|
|
54
|
+
operationTimeoutMs,
|
|
55
|
+
storeName,
|
|
56
|
+
});
|
|
57
|
+
return createReadCache(driver ?? createNoopReadCacheDriver());
|
|
58
|
+
}
|
|
59
|
+
export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
60
|
+
const serializedParams = Object.entries(params)
|
|
61
|
+
.filter(([, value]) => value !== undefined)
|
|
62
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
63
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
64
|
+
.join("&");
|
|
65
|
+
return [
|
|
66
|
+
prefix,
|
|
67
|
+
encodeURIComponent(namespace),
|
|
68
|
+
encodeURIComponent(scope),
|
|
69
|
+
serializedParams,
|
|
70
|
+
]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(":");
|
|
73
|
+
}
|
|
74
|
+
export async function readThroughCache({ cache, key, loadFresh, }) {
|
|
75
|
+
const cached = await cache.get(key);
|
|
76
|
+
try {
|
|
77
|
+
const fresh = await loadFresh();
|
|
78
|
+
await cache.set(key, fresh);
|
|
79
|
+
return {
|
|
80
|
+
data: fresh,
|
|
81
|
+
error: null,
|
|
82
|
+
fromCache: false,
|
|
83
|
+
stale: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (cached) {
|
|
88
|
+
return {
|
|
89
|
+
data: cached.value,
|
|
90
|
+
error: normalizeError(error),
|
|
91
|
+
fromCache: true,
|
|
92
|
+
stale: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
|
|
99
|
+
const cached = await cache.get(key);
|
|
100
|
+
if (cached) {
|
|
101
|
+
onUpdate({ data: cached.value, fromCache: true });
|
|
102
|
+
if (isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
103
|
+
return cached.value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const fresh = await loadSharedFreshResource(key, loadFresh);
|
|
108
|
+
onUpdate({ data: fresh, fromCache: false });
|
|
109
|
+
void cache.set(key, fresh).catch(() => undefined);
|
|
110
|
+
return fresh;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!cached) {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
onUpdate({
|
|
117
|
+
data: cached.value,
|
|
118
|
+
error: normalizeError(error),
|
|
119
|
+
fromCache: true,
|
|
120
|
+
});
|
|
121
|
+
return cached.value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function createNoopReadCacheDriver() {
|
|
125
|
+
return {
|
|
126
|
+
async clear() { },
|
|
127
|
+
async close() { },
|
|
128
|
+
async get() {
|
|
129
|
+
return null;
|
|
130
|
+
},
|
|
131
|
+
async set() { },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function isCacheFresh(savedAt, maxAgeMs) {
|
|
135
|
+
if (!maxAgeMs || maxAgeMs <= 0) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const savedTime = Date.parse(savedAt);
|
|
139
|
+
return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
|
|
140
|
+
}
|
|
141
|
+
async function loadSharedFreshResource(key, loadFresh) {
|
|
142
|
+
const pendingLoad = pendingFreshLoads.get(key);
|
|
143
|
+
if (pendingLoad) {
|
|
144
|
+
return (await pendingLoad);
|
|
145
|
+
}
|
|
146
|
+
const nextLoad = loadFresh();
|
|
147
|
+
pendingFreshLoads.set(key, nextLoad);
|
|
148
|
+
try {
|
|
149
|
+
return await nextLoad;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
if (pendingFreshLoads.get(key) === nextLoad) {
|
|
153
|
+
pendingFreshLoads.delete(key);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function toReadCacheEnvelope(value) {
|
|
158
|
+
if (!value || typeof value !== "object") {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const envelope = value;
|
|
162
|
+
if (envelope.schemaVersion !== readCacheSchemaVersion ||
|
|
163
|
+
typeof envelope.savedAt !== "string" ||
|
|
164
|
+
!("value" in envelope)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return envelope;
|
|
168
|
+
}
|
|
169
|
+
function normalizeError(error) {
|
|
170
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
171
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createIdbKeyValueDriver = createIdbKeyValueDriver;
|
|
4
|
+
const idb_1 = require("idb");
|
|
5
|
+
function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
|
|
6
|
+
if (!indexedDB) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const databasePromise = openKeyValueDatabase({
|
|
10
|
+
databaseName,
|
|
11
|
+
databaseVersion,
|
|
12
|
+
indexedDB,
|
|
13
|
+
storeName,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
async clear() {
|
|
17
|
+
await runBrowserStorageOperation({
|
|
18
|
+
fallback: undefined,
|
|
19
|
+
operation: async () => {
|
|
20
|
+
const database = await databasePromise;
|
|
21
|
+
await database.clear(storeName);
|
|
22
|
+
},
|
|
23
|
+
timeoutMs: operationTimeoutMs,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
async close() {
|
|
27
|
+
await runBrowserStorageOperation({
|
|
28
|
+
fallback: undefined,
|
|
29
|
+
operation: async () => {
|
|
30
|
+
const database = await databasePromise;
|
|
31
|
+
database.close();
|
|
32
|
+
},
|
|
33
|
+
timeoutMs: operationTimeoutMs,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
async delete(key) {
|
|
37
|
+
await runBrowserStorageOperation({
|
|
38
|
+
fallback: undefined,
|
|
39
|
+
operation: async () => {
|
|
40
|
+
const database = await databasePromise;
|
|
41
|
+
await database.delete(storeName, key);
|
|
42
|
+
},
|
|
43
|
+
timeoutMs: operationTimeoutMs,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
async get(key) {
|
|
47
|
+
return runBrowserStorageOperation({
|
|
48
|
+
fallback: null,
|
|
49
|
+
operation: async () => {
|
|
50
|
+
const database = await databasePromise;
|
|
51
|
+
return (await database.get(storeName, key)) ?? null;
|
|
52
|
+
},
|
|
53
|
+
timeoutMs: operationTimeoutMs,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
async list() {
|
|
57
|
+
return runBrowserStorageOperation({
|
|
58
|
+
fallback: [],
|
|
59
|
+
operation: async () => {
|
|
60
|
+
const database = await databasePromise;
|
|
61
|
+
return database.getAll(storeName);
|
|
62
|
+
},
|
|
63
|
+
timeoutMs: operationTimeoutMs,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async set(key, value) {
|
|
67
|
+
await runBrowserStorageOperation({
|
|
68
|
+
fallback: undefined,
|
|
69
|
+
operation: async () => {
|
|
70
|
+
const database = await databasePromise;
|
|
71
|
+
await database.put(storeName, value, key);
|
|
72
|
+
},
|
|
73
|
+
timeoutMs: operationTimeoutMs,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB, storeName, }) {
|
|
79
|
+
const request = indexedDB.open(databaseName, databaseVersion);
|
|
80
|
+
request.onupgradeneeded = () => {
|
|
81
|
+
const database = request.result;
|
|
82
|
+
if (!database.objectStoreNames.contains(storeName)) {
|
|
83
|
+
database.createObjectStore(storeName);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const database = await (0, idb_1.wrap)(request);
|
|
87
|
+
if (!database) {
|
|
88
|
+
throw new Error(`Could not open IndexedDB database ${databaseName}.`);
|
|
89
|
+
}
|
|
90
|
+
return database;
|
|
91
|
+
}
|
|
92
|
+
async function runBrowserStorageOperation({ fallback, operation, timeoutMs, }) {
|
|
93
|
+
let timeoutId = null;
|
|
94
|
+
try {
|
|
95
|
+
return await Promise.race([
|
|
96
|
+
operation(),
|
|
97
|
+
new Promise((resolve) => {
|
|
98
|
+
timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (timeoutId) {
|
|
107
|
+
clearTimeout(timeoutId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./readCache"), exports);
|
|
18
|
+
__exportStar(require("./pendingWrites"), exports);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
|
|
4
|
+
exports.createPendingWriteStore = createPendingWriteStore;
|
|
5
|
+
exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
|
|
6
|
+
exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
|
|
7
|
+
exports.createBrowserPendingWriteStore = createBrowserPendingWriteStore;
|
|
8
|
+
exports.createPendingWriteKey = createPendingWriteKey;
|
|
9
|
+
exports.syncPendingWrite = syncPendingWrite;
|
|
10
|
+
const idbDriver_1 = require("./idbDriver");
|
|
11
|
+
exports.pendingWriteSchemaVersion = 1;
|
|
12
|
+
exports.pendingWriteOperationTimeoutMs = 2_000;
|
|
13
|
+
function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
|
|
14
|
+
return {
|
|
15
|
+
async clear(id) {
|
|
16
|
+
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
17
|
+
},
|
|
18
|
+
async clearAll() {
|
|
19
|
+
await driver.clearAll();
|
|
20
|
+
},
|
|
21
|
+
async clearIfSavedAt(id, savedAt) {
|
|
22
|
+
const current = await this.get(id);
|
|
23
|
+
if (!current || current.savedAt !== savedAt) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
await this.clear(id);
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
async close() {
|
|
30
|
+
await driver.close?.();
|
|
31
|
+
},
|
|
32
|
+
async get(id) {
|
|
33
|
+
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizePayload })?.record ?? null);
|
|
34
|
+
},
|
|
35
|
+
async list() {
|
|
36
|
+
return (await driver.list())
|
|
37
|
+
.map((value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
38
|
+
normalizePayload,
|
|
39
|
+
})?.record)
|
|
40
|
+
.filter((record) => Boolean(record))
|
|
41
|
+
.sort((left, right) => left.savedAt.localeCompare(right.savedAt));
|
|
42
|
+
},
|
|
43
|
+
async set(record) {
|
|
44
|
+
await driver.set(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), {
|
|
45
|
+
record,
|
|
46
|
+
schemaVersion,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function createMemoryPendingWriteDriver() {
|
|
52
|
+
const records = new Map();
|
|
53
|
+
return {
|
|
54
|
+
async clear(key) {
|
|
55
|
+
records.delete(key);
|
|
56
|
+
},
|
|
57
|
+
async clearAll() {
|
|
58
|
+
records.clear();
|
|
59
|
+
},
|
|
60
|
+
async close() { },
|
|
61
|
+
async get(key) {
|
|
62
|
+
return records.get(key) ?? null;
|
|
63
|
+
},
|
|
64
|
+
async list() {
|
|
65
|
+
return [...records.values()];
|
|
66
|
+
},
|
|
67
|
+
async set(key, value) {
|
|
68
|
+
records.set(key, value);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, storeName, }) {
|
|
73
|
+
const driver = (0, idbDriver_1.createIdbKeyValueDriver)({
|
|
74
|
+
databaseName,
|
|
75
|
+
databaseVersion,
|
|
76
|
+
indexedDB,
|
|
77
|
+
operationTimeoutMs,
|
|
78
|
+
storeName,
|
|
79
|
+
});
|
|
80
|
+
return driver ? toPendingWriteDriver(driver) : null;
|
|
81
|
+
}
|
|
82
|
+
function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
|
|
83
|
+
const driver = createBrowserPendingWriteDriver({
|
|
84
|
+
databaseName,
|
|
85
|
+
databaseVersion,
|
|
86
|
+
indexedDB,
|
|
87
|
+
operationTimeoutMs,
|
|
88
|
+
storeName,
|
|
89
|
+
});
|
|
90
|
+
return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizePayload, schemaVersion });
|
|
91
|
+
}
|
|
92
|
+
function createPendingWriteKey({ id, prefix }) {
|
|
93
|
+
return `${prefix}:${encodeURIComponent(id)}`;
|
|
94
|
+
}
|
|
95
|
+
async function syncPendingWrite({ id, store, sync, }) {
|
|
96
|
+
const pending = await store.get(id);
|
|
97
|
+
if (!pending) {
|
|
98
|
+
return { status: "idle" };
|
|
99
|
+
}
|
|
100
|
+
await markPendingSnapshot({
|
|
101
|
+
id,
|
|
102
|
+
nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
|
|
103
|
+
savedAt: pending.savedAt,
|
|
104
|
+
store,
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await sync(pending.payload);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
111
|
+
await markPendingSnapshot({
|
|
112
|
+
id,
|
|
113
|
+
nextRecord: { ...pending, error: message, syncStatus: "failed" },
|
|
114
|
+
savedAt: pending.savedAt,
|
|
115
|
+
store,
|
|
116
|
+
});
|
|
117
|
+
return { error: message, status: "failed" };
|
|
118
|
+
}
|
|
119
|
+
return (await store.clearIfSavedAt(id, pending.savedAt))
|
|
120
|
+
? { status: "synced" }
|
|
121
|
+
: { status: "superseded" };
|
|
122
|
+
}
|
|
123
|
+
function createNoopPendingWriteDriver() {
|
|
124
|
+
return {
|
|
125
|
+
async clear() { },
|
|
126
|
+
async clearAll() { },
|
|
127
|
+
async close() { },
|
|
128
|
+
async get() {
|
|
129
|
+
return null;
|
|
130
|
+
},
|
|
131
|
+
async list() {
|
|
132
|
+
return [];
|
|
133
|
+
},
|
|
134
|
+
async set() { },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function toPendingWriteDriver(driver) {
|
|
138
|
+
return {
|
|
139
|
+
clear: driver.delete,
|
|
140
|
+
clearAll: driver.clear,
|
|
141
|
+
close: driver.close,
|
|
142
|
+
get: driver.get,
|
|
143
|
+
list: driver.list,
|
|
144
|
+
set: driver.set,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async function markPendingSnapshot({ id, nextRecord, savedAt, store, }) {
|
|
148
|
+
const current = await store.get(id);
|
|
149
|
+
if (current?.savedAt !== savedAt) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
await store.set(nextRecord);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
function toPendingWriteEnvelope(value, schemaVersion, { normalizePayload }) {
|
|
156
|
+
if (!value ||
|
|
157
|
+
typeof value !== "object" ||
|
|
158
|
+
!("schemaVersion" in value) ||
|
|
159
|
+
value.schemaVersion !== schemaVersion ||
|
|
160
|
+
!("record" in value) ||
|
|
161
|
+
!isPendingWriteRecord(value.record)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const record = normalizePendingWriteRecord(value.record, normalizePayload);
|
|
165
|
+
return record ? { record, schemaVersion } : null;
|
|
166
|
+
}
|
|
167
|
+
function normalizePendingWriteRecord(record, normalizePayload) {
|
|
168
|
+
if (!normalizePayload) {
|
|
169
|
+
return record;
|
|
170
|
+
}
|
|
171
|
+
const payload = normalizePayload(record.payload);
|
|
172
|
+
if (payload === null) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...(typeof record.error === "string" ? { error: record.error } : {}),
|
|
177
|
+
id: record.id,
|
|
178
|
+
payload,
|
|
179
|
+
savedAt: record.savedAt,
|
|
180
|
+
syncStatus: record.syncStatus,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function isPendingWriteRecord(value) {
|
|
184
|
+
return (value !== null &&
|
|
185
|
+
typeof value === "object" &&
|
|
186
|
+
"id" in value &&
|
|
187
|
+
typeof value.id === "string" &&
|
|
188
|
+
"savedAt" in value &&
|
|
189
|
+
typeof value.savedAt === "string" &&
|
|
190
|
+
"syncStatus" in value &&
|
|
191
|
+
(value.syncStatus === "pending" ||
|
|
192
|
+
value.syncStatus === "syncing" ||
|
|
193
|
+
value.syncStatus === "failed") &&
|
|
194
|
+
"payload" in value);
|
|
195
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.browserReadCacheOperationTimeoutMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
|
|
4
|
+
exports.createReadCache = createReadCache;
|
|
5
|
+
exports.createMemoryReadCacheDriver = createMemoryReadCacheDriver;
|
|
6
|
+
exports.createBrowserReadCache = createBrowserReadCache;
|
|
7
|
+
exports.createReadCacheKey = createReadCacheKey;
|
|
8
|
+
exports.readThroughCache = readThroughCache;
|
|
9
|
+
exports.loadReadCacheResource = loadReadCacheResource;
|
|
10
|
+
const idbDriver_1 = require("./idbDriver");
|
|
11
|
+
exports.readCacheSchemaVersion = 1;
|
|
12
|
+
exports.readCacheFreshnessMaxAgeMs = 30_000;
|
|
13
|
+
exports.browserReadCacheOperationTimeoutMs = 2_000;
|
|
14
|
+
const pendingFreshLoads = new Map();
|
|
15
|
+
function createReadCache(driver) {
|
|
16
|
+
return {
|
|
17
|
+
async clear() {
|
|
18
|
+
pendingFreshLoads.clear();
|
|
19
|
+
await driver.clear();
|
|
20
|
+
},
|
|
21
|
+
async close() {
|
|
22
|
+
await driver.close?.();
|
|
23
|
+
},
|
|
24
|
+
async get(key) {
|
|
25
|
+
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
26
|
+
return envelope
|
|
27
|
+
? {
|
|
28
|
+
fromCache: true,
|
|
29
|
+
savedAt: envelope.savedAt,
|
|
30
|
+
value: envelope.value,
|
|
31
|
+
}
|
|
32
|
+
: null;
|
|
33
|
+
},
|
|
34
|
+
async set(key, value) {
|
|
35
|
+
await driver.set(key, {
|
|
36
|
+
savedAt: new Date().toISOString(),
|
|
37
|
+
schemaVersion: exports.readCacheSchemaVersion,
|
|
38
|
+
value,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function createMemoryReadCacheDriver() {
|
|
44
|
+
const records = new Map();
|
|
45
|
+
return {
|
|
46
|
+
async clear() {
|
|
47
|
+
records.clear();
|
|
48
|
+
},
|
|
49
|
+
async close() { },
|
|
50
|
+
async get(key) {
|
|
51
|
+
return records.get(key) ?? null;
|
|
52
|
+
},
|
|
53
|
+
async set(key, value) {
|
|
54
|
+
records.set(key, value);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = exports.browserReadCacheOperationTimeoutMs, storeName, }) {
|
|
59
|
+
const driver = (0, idbDriver_1.createIdbKeyValueDriver)({
|
|
60
|
+
databaseName,
|
|
61
|
+
databaseVersion,
|
|
62
|
+
indexedDB,
|
|
63
|
+
operationTimeoutMs,
|
|
64
|
+
storeName,
|
|
65
|
+
});
|
|
66
|
+
return createReadCache(driver ?? createNoopReadCacheDriver());
|
|
67
|
+
}
|
|
68
|
+
function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
69
|
+
const serializedParams = Object.entries(params)
|
|
70
|
+
.filter(([, value]) => value !== undefined)
|
|
71
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
72
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
73
|
+
.join("&");
|
|
74
|
+
return [
|
|
75
|
+
prefix,
|
|
76
|
+
encodeURIComponent(namespace),
|
|
77
|
+
encodeURIComponent(scope),
|
|
78
|
+
serializedParams,
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(":");
|
|
82
|
+
}
|
|
83
|
+
async function readThroughCache({ cache, key, loadFresh, }) {
|
|
84
|
+
const cached = await cache.get(key);
|
|
85
|
+
try {
|
|
86
|
+
const fresh = await loadFresh();
|
|
87
|
+
await cache.set(key, fresh);
|
|
88
|
+
return {
|
|
89
|
+
data: fresh,
|
|
90
|
+
error: null,
|
|
91
|
+
fromCache: false,
|
|
92
|
+
stale: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (cached) {
|
|
97
|
+
return {
|
|
98
|
+
data: cached.value,
|
|
99
|
+
error: normalizeError(error),
|
|
100
|
+
fromCache: true,
|
|
101
|
+
stale: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
|
|
108
|
+
const cached = await cache.get(key);
|
|
109
|
+
if (cached) {
|
|
110
|
+
onUpdate({ data: cached.value, fromCache: true });
|
|
111
|
+
if (isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
112
|
+
return cached.value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const fresh = await loadSharedFreshResource(key, loadFresh);
|
|
117
|
+
onUpdate({ data: fresh, fromCache: false });
|
|
118
|
+
void cache.set(key, fresh).catch(() => undefined);
|
|
119
|
+
return fresh;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!cached) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
onUpdate({
|
|
126
|
+
data: cached.value,
|
|
127
|
+
error: normalizeError(error),
|
|
128
|
+
fromCache: true,
|
|
129
|
+
});
|
|
130
|
+
return cached.value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function createNoopReadCacheDriver() {
|
|
134
|
+
return {
|
|
135
|
+
async clear() { },
|
|
136
|
+
async close() { },
|
|
137
|
+
async get() {
|
|
138
|
+
return null;
|
|
139
|
+
},
|
|
140
|
+
async set() { },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function isCacheFresh(savedAt, maxAgeMs) {
|
|
144
|
+
if (!maxAgeMs || maxAgeMs <= 0) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const savedTime = Date.parse(savedAt);
|
|
148
|
+
return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
|
|
149
|
+
}
|
|
150
|
+
async function loadSharedFreshResource(key, loadFresh) {
|
|
151
|
+
const pendingLoad = pendingFreshLoads.get(key);
|
|
152
|
+
if (pendingLoad) {
|
|
153
|
+
return (await pendingLoad);
|
|
154
|
+
}
|
|
155
|
+
const nextLoad = loadFresh();
|
|
156
|
+
pendingFreshLoads.set(key, nextLoad);
|
|
157
|
+
try {
|
|
158
|
+
return await nextLoad;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
if (pendingFreshLoads.get(key) === nextLoad) {
|
|
162
|
+
pendingFreshLoads.delete(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function toReadCacheEnvelope(value) {
|
|
167
|
+
if (!value || typeof value !== "object") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const envelope = value;
|
|
171
|
+
if (envelope.schemaVersion !== exports.readCacheSchemaVersion ||
|
|
172
|
+
typeof envelope.savedAt !== "string" ||
|
|
173
|
+
!("value" in envelope)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return envelope;
|
|
177
|
+
}
|
|
178
|
+
function normalizeError(error) {
|
|
179
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bayonai/offline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable offline read-cache and pending-write primitives.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bayon AI",
|
|
7
|
+
"homepage": "https://github.com/toledoroy/bounded/tree/main/packages/offline#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/toledoroy/bounded.git",
|
|
11
|
+
"directory": "packages/offline"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/toledoroy/bounded/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"offline",
|
|
18
|
+
"indexeddb",
|
|
19
|
+
"cache",
|
|
20
|
+
"pending-writes",
|
|
21
|
+
"idb",
|
|
22
|
+
"typescript"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist-cjs/index.js",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist-cjs/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"dist-cjs",
|
|
39
|
+
"CHANGELOG.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs",
|
|
48
|
+
"lint": "tsc --noEmit -p tsconfig.json",
|
|
49
|
+
"prepack": "pnpm run build",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"idb": "^8.0.3"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"typescript": "^5",
|
|
57
|
+
"vitest": "^4.1.6"
|
|
58
|
+
}
|
|
59
|
+
}
|