@bayonai/offline 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.1.1 - 2026-08-10
4
4
 
5
+ - Added unique pending-write snapshot identities and atomic conditional
6
+ replacement/deletion, preventing overlapping sync and autosave operations
7
+ from deleting newer local data. Existing records remain compatible.
5
8
  - Added the initial workspace package with generic read-cache and pending-write
6
9
  primitives backed by `idb`.
7
10
  - Replaced app-specific test fixtures with neutral package examples.
package/README.md CHANGED
@@ -81,6 +81,13 @@ await syncPendingWrite({
81
81
  });
82
82
  ```
83
83
 
84
+ Each call to `set` assigns a unique `snapshotId`. Sync status updates and
85
+ successful cleanup compare that identity inside a single storage transaction,
86
+ so a newer local write cannot be overwritten or deleted by an older sync.
87
+ Records created before snapshot identities were introduced remain readable;
88
+ they receive a stable compatibility identity and are upgraded when next
89
+ updated.
90
+
84
91
  Host apps that change their pending-write payload shape can normalize current
85
92
  records while keeping domain logic outside the package:
86
93
 
@@ -2,10 +2,13 @@ export type BrowserKeyValueDriver = {
2
2
  clear: () => Promise<void>;
3
3
  close: () => Promise<void>;
4
4
  delete: (key: string) => Promise<void>;
5
+ deleteIf: (key: string, predicate: KeyValuePredicate) => Promise<boolean>;
5
6
  get: (key: string) => Promise<unknown | null>;
6
7
  list: () => Promise<unknown[]>;
7
8
  set: (key: string, value: unknown) => Promise<void>;
9
+ setIf: (key: string, value: unknown, predicate: KeyValuePredicate) => Promise<boolean>;
8
10
  };
11
+ export type KeyValuePredicate = (value: unknown | null) => boolean;
9
12
  export type BrowserKeyValueDriverOptions = {
10
13
  databaseName: string;
11
14
  databaseVersion?: number;
package/dist/idbDriver.js CHANGED
@@ -13,6 +13,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
13
13
  async clear() {
14
14
  await runBrowserStorageOperation({
15
15
  fallback: undefined,
16
+ operationName: "clear object store",
16
17
  operation: async () => {
17
18
  const database = await databasePromise;
18
19
  await database.clear(storeName);
@@ -23,6 +24,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
23
24
  async close() {
24
25
  await runBrowserStorageOperation({
25
26
  fallback: undefined,
27
+ operationName: "close database",
26
28
  operation: async () => {
27
29
  const database = await databasePromise;
28
30
  database.close();
@@ -33,6 +35,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
33
35
  async delete(key) {
34
36
  await runBrowserStorageOperation({
35
37
  fallback: undefined,
38
+ operationName: "delete key",
36
39
  operation: async () => {
37
40
  const database = await databasePromise;
38
41
  await database.delete(storeName, key);
@@ -40,9 +43,32 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
40
43
  timeoutMs: operationTimeoutMs,
41
44
  });
42
45
  },
46
+ async deleteIf(key, predicate) {
47
+ return runBrowserStorageOperation({
48
+ fallback: false,
49
+ operationName: "conditionally delete key",
50
+ operation: async () => {
51
+ const database = await databasePromise;
52
+ // The ownership check and deletion must share a transaction; splitting
53
+ // them can delete a newer offline snapshot written by another trigger.
54
+ const transaction = database.transaction(storeName, "readwrite");
55
+ const objectStore = transaction.objectStore(storeName);
56
+ const current = (await objectStore.get(key)) ?? null;
57
+ if (!predicate(current)) {
58
+ await transaction.done;
59
+ return false;
60
+ }
61
+ await objectStore.delete(key);
62
+ await transaction.done;
63
+ return true;
64
+ },
65
+ timeoutMs: operationTimeoutMs,
66
+ });
67
+ },
43
68
  async get(key) {
44
69
  return runBrowserStorageOperation({
45
70
  fallback: null,
71
+ operationName: "read key",
46
72
  operation: async () => {
47
73
  const database = await databasePromise;
48
74
  return (await database.get(storeName, key)) ?? null;
@@ -53,6 +79,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
53
79
  async list() {
54
80
  return runBrowserStorageOperation({
55
81
  fallback: [],
82
+ operationName: "list object store",
56
83
  operation: async () => {
57
84
  const database = await databasePromise;
58
85
  return database.getAll(storeName);
@@ -63,6 +90,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
63
90
  async set(key, value) {
64
91
  await runBrowserStorageOperation({
65
92
  fallback: undefined,
93
+ operationName: "write key",
66
94
  operation: async () => {
67
95
  const database = await databasePromise;
68
96
  await database.put(storeName, value, key);
@@ -70,6 +98,26 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
70
98
  timeoutMs: operationTimeoutMs,
71
99
  });
72
100
  },
101
+ async setIf(key, value, predicate) {
102
+ return runBrowserStorageOperation({
103
+ fallback: false,
104
+ operationName: "conditionally replace key",
105
+ operation: async () => {
106
+ const database = await databasePromise;
107
+ const transaction = database.transaction(storeName, "readwrite");
108
+ const objectStore = transaction.objectStore(storeName);
109
+ const current = (await objectStore.get(key)) ?? null;
110
+ if (!predicate(current)) {
111
+ await transaction.done;
112
+ return false;
113
+ }
114
+ await objectStore.put(value, key);
115
+ await transaction.done;
116
+ return true;
117
+ },
118
+ timeoutMs: operationTimeoutMs,
119
+ });
120
+ },
73
121
  };
74
122
  }
75
123
  async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB, storeName, }) {
@@ -86,17 +134,23 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
86
134
  }
87
135
  return database;
88
136
  }
89
- async function runBrowserStorageOperation({ fallback, operation, timeoutMs, }) {
137
+ async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
90
138
  let timeoutId = null;
91
139
  try {
92
140
  return await Promise.race([
93
141
  operation(),
94
142
  new Promise((resolve) => {
95
- timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
143
+ timeoutId = setTimeout(() => {
144
+ console.error(`IndexedDB timed out while trying to ${operationName}.`, {
145
+ timeoutMs,
146
+ });
147
+ resolve(fallback);
148
+ }, timeoutMs);
96
149
  }),
97
150
  ]);
98
151
  }
99
- catch {
152
+ catch (error) {
153
+ console.error(`IndexedDB failed to ${operationName}.`, { error });
100
154
  return fallback;
101
155
  }
102
156
  finally {
@@ -7,28 +7,37 @@ export type PendingWriteRecord<Payload = unknown> = {
7
7
  id: string;
8
8
  payload: Payload;
9
9
  savedAt: string;
10
+ snapshotId: string;
10
11
  syncStatus: PendingWriteStatus;
11
12
  };
13
+ export type PendingWriteRecordInput<Payload = unknown> = Omit<PendingWriteRecord<Payload>, "snapshotId"> & {
14
+ snapshotId?: string;
15
+ };
12
16
  export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown) => Payload | null;
13
17
  export type PendingWriteDriver = {
14
18
  clear: (key: string) => Promise<void>;
19
+ clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
15
20
  clearAll: () => Promise<void>;
16
21
  close?: () => Promise<void>;
17
22
  get: (key: string) => Promise<unknown | null>;
18
23
  list: () => Promise<unknown[]>;
19
24
  set: (key: string, value: unknown) => Promise<void>;
25
+ setIf: (key: string, value: unknown, predicate: PendingWritePredicate) => Promise<boolean>;
20
26
  };
27
+ export type PendingWritePredicate = (value: unknown | null) => boolean;
21
28
  export type PendingWriteStore<Payload = unknown> = {
22
29
  clear: (id: string) => Promise<void>;
23
30
  clearAll: () => Promise<void>;
24
- clearIfSavedAt: (id: string, savedAt: string) => Promise<boolean>;
31
+ clearIfSnapshotId: (id: string, snapshotId: string) => Promise<boolean>;
25
32
  close: () => Promise<void>;
26
33
  get: (id: string) => Promise<PendingWriteRecord<Payload> | null>;
27
34
  list: () => Promise<Array<PendingWriteRecord<Payload>>>;
28
- set: (record: PendingWriteRecord<Payload>) => Promise<void>;
35
+ replaceIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>) => Promise<boolean>;
36
+ set: (record: PendingWriteRecordInput<Payload>) => Promise<PendingWriteRecord<Payload>>;
29
37
  };
30
38
  export type PendingWriteStoreOptions<Payload = unknown> = {
31
39
  keyPrefix: string;
40
+ normalizeLegacyRecord?: PendingWritePayloadNormalizer<PendingWriteRecordInput<Payload>>;
32
41
  normalizePayload?: PendingWritePayloadNormalizer<Payload>;
33
42
  schemaVersion?: number;
34
43
  };
@@ -51,11 +60,12 @@ export type SyncPendingWriteResult = {
51
60
  error: string;
52
61
  status: "failed";
53
62
  };
54
- export declare function createPendingWriteStore<Payload = unknown>(driver: PendingWriteDriver, { keyPrefix, normalizePayload, schemaVersion, }: PendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
63
+ export declare function createPendingWriteStore<Payload = unknown>(driver: PendingWriteDriver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion, }: PendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
55
64
  export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
56
65
  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>;
66
+ export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
58
67
  export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
68
+ export declare function createPendingWriteSnapshotId(): `${string}-${string}-${string}-${string}-${string}`;
59
69
  export declare function syncPendingWrite<Payload>({ id, store, sync, }: {
60
70
  id: string;
61
71
  store: PendingWriteStore<Payload>;
@@ -1,7 +1,7 @@
1
1
  import { createIdbKeyValueDriver, } from "./idbDriver.js";
2
2
  export const pendingWriteSchemaVersion = 1;
3
3
  export const pendingWriteOperationTimeoutMs = 2_000;
4
- export function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVersion = pendingWriteSchemaVersion, }) {
4
+ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = pendingWriteSchemaVersion, }) {
5
5
  return {
6
6
  async clear(id) {
7
7
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
@@ -9,33 +9,43 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizePayload, s
9
9
  async clearAll() {
10
10
  await driver.clearAll();
11
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;
12
+ async clearIfSnapshotId(id, snapshotId) {
13
+ return driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
14
+ normalizeLegacyRecord,
15
+ normalizePayload,
16
+ })?.record.snapshotId === snapshotId);
19
17
  },
20
18
  async close() {
21
19
  await driver.close?.();
22
20
  },
23
21
  async get(id) {
24
- return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizePayload })?.record ?? null);
22
+ return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
25
23
  },
26
24
  async list() {
27
25
  return (await driver.list())
28
26
  .map((value) => toPendingWriteEnvelope(value, schemaVersion, {
27
+ normalizeLegacyRecord,
29
28
  normalizePayload,
30
29
  })?.record)
31
30
  .filter((record) => Boolean(record))
32
31
  .sort((left, right) => left.savedAt.localeCompare(right.savedAt));
33
32
  },
33
+ async replaceIfSnapshotId(id, snapshotId, record) {
34
+ return driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
35
+ normalizeLegacyRecord,
36
+ normalizePayload,
37
+ })?.record.snapshotId === snapshotId);
38
+ },
34
39
  async set(record) {
40
+ const storedRecord = {
41
+ ...record,
42
+ snapshotId: record.snapshotId ?? createPendingWriteSnapshotId(),
43
+ };
35
44
  await driver.set(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), {
36
- record,
45
+ record: storedRecord,
37
46
  schemaVersion,
38
47
  });
48
+ return storedRecord;
39
49
  },
40
50
  };
41
51
  }
@@ -45,6 +55,14 @@ export function createMemoryPendingWriteDriver() {
45
55
  async clear(key) {
46
56
  records.delete(key);
47
57
  },
58
+ async clearIf(key, predicate) {
59
+ const current = records.get(key) ?? null;
60
+ if (!predicate(current)) {
61
+ return false;
62
+ }
63
+ records.delete(key);
64
+ return true;
65
+ },
48
66
  async clearAll() {
49
67
  records.clear();
50
68
  },
@@ -58,6 +76,13 @@ export function createMemoryPendingWriteDriver() {
58
76
  async set(key, value) {
59
77
  records.set(key, value);
60
78
  },
79
+ async setIf(key, value, predicate) {
80
+ if (!predicate(records.get(key) ?? null)) {
81
+ return false;
82
+ }
83
+ records.set(key, value);
84
+ return true;
85
+ },
61
86
  };
62
87
  }
63
88
  export function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = pendingWriteOperationTimeoutMs, storeName, }) {
@@ -70,7 +95,7 @@ export function createBrowserPendingWriteDriver({ databaseName, databaseVersion,
70
95
  });
71
96
  return driver ? toPendingWriteDriver(driver) : null;
72
97
  }
73
- export function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizePayload, operationTimeoutMs = pendingWriteOperationTimeoutMs, schemaVersion = pendingWriteSchemaVersion, storeName, }) {
98
+ export function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs = pendingWriteOperationTimeoutMs, schemaVersion = pendingWriteSchemaVersion, storeName, }) {
74
99
  const driver = createBrowserPendingWriteDriver({
75
100
  databaseName,
76
101
  databaseVersion,
@@ -78,11 +103,14 @@ export function createBrowserPendingWriteStore({ databaseName, databaseVersion,
78
103
  operationTimeoutMs,
79
104
  storeName,
80
105
  });
81
- return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizePayload, schemaVersion });
106
+ return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
82
107
  }
83
108
  export function createPendingWriteKey({ id, prefix }) {
84
109
  return `${prefix}:${encodeURIComponent(id)}`;
85
110
  }
111
+ export function createPendingWriteSnapshotId() {
112
+ return globalThis.crypto.randomUUID();
113
+ }
86
114
  export async function syncPendingWrite({ id, store, sync, }) {
87
115
  const pending = await store.get(id);
88
116
  if (!pending) {
@@ -91,7 +119,7 @@ export async function syncPendingWrite({ id, store, sync, }) {
91
119
  await markPendingSnapshot({
92
120
  id,
93
121
  nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
94
- savedAt: pending.savedAt,
122
+ snapshotId: pending.snapshotId,
95
123
  store,
96
124
  });
97
125
  try {
@@ -102,18 +130,21 @@ export async function syncPendingWrite({ id, store, sync, }) {
102
130
  await markPendingSnapshot({
103
131
  id,
104
132
  nextRecord: { ...pending, error: message, syncStatus: "failed" },
105
- savedAt: pending.savedAt,
133
+ snapshotId: pending.snapshotId,
106
134
  store,
107
135
  });
108
136
  return { error: message, status: "failed" };
109
137
  }
110
- return (await store.clearIfSavedAt(id, pending.savedAt))
138
+ return (await store.clearIfSnapshotId(id, pending.snapshotId))
111
139
  ? { status: "synced" }
112
140
  : { status: "superseded" };
113
141
  }
114
142
  function createNoopPendingWriteDriver() {
115
143
  return {
116
144
  async clear() { },
145
+ async clearIf() {
146
+ return false;
147
+ },
117
148
  async clearAll() { },
118
149
  async close() { },
119
150
  async get() {
@@ -123,43 +154,41 @@ function createNoopPendingWriteDriver() {
123
154
  return [];
124
155
  },
125
156
  async set() { },
157
+ async setIf() {
158
+ return false;
159
+ },
126
160
  };
127
161
  }
128
162
  function toPendingWriteDriver(driver) {
129
163
  return {
130
164
  clear: driver.delete,
165
+ clearIf: driver.deleteIf,
131
166
  clearAll: driver.clear,
132
167
  close: driver.close,
133
168
  get: driver.get,
134
169
  list: driver.list,
135
170
  set: driver.set,
171
+ setIf: driver.setIf,
136
172
  };
137
173
  }
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;
174
+ async function markPendingSnapshot({ id, nextRecord, snapshotId, store, }) {
175
+ return store.replaceIfSnapshotId(id, snapshotId, nextRecord);
145
176
  }
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);
177
+ function toPendingWriteEnvelope(value, schemaVersion, { normalizeLegacyRecord, normalizePayload }) {
178
+ const envelopeRecord = readPendingWriteEnvelopeRecord(value, schemaVersion);
179
+ const legacyRecord = envelopeRecord
180
+ ? null
181
+ : (normalizeLegacyRecord?.(value) ?? null);
182
+ const record = normalizePendingWriteRecord(envelopeRecord ?? legacyRecord, normalizePayload);
156
183
  return record ? { record, schemaVersion } : null;
157
184
  }
158
185
  function normalizePendingWriteRecord(record, normalizePayload) {
159
- if (!normalizePayload) {
160
- return record;
186
+ if (!record) {
187
+ return null;
161
188
  }
162
- const payload = normalizePayload(record.payload);
189
+ const payload = normalizePayload
190
+ ? normalizePayload(record.payload)
191
+ : record.payload;
163
192
  if (payload === null) {
164
193
  return null;
165
194
  }
@@ -168,9 +197,24 @@ function normalizePendingWriteRecord(record, normalizePayload) {
168
197
  id: record.id,
169
198
  payload,
170
199
  savedAt: record.savedAt,
200
+ snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
171
201
  syncStatus: record.syncStatus,
172
202
  };
173
203
  }
204
+ function readPendingWriteEnvelopeRecord(value, schemaVersion) {
205
+ if (!value ||
206
+ typeof value !== "object" ||
207
+ !("schemaVersion" in value) ||
208
+ value.schemaVersion !== schemaVersion ||
209
+ !("record" in value) ||
210
+ !isPendingWriteRecord(value.record)) {
211
+ return null;
212
+ }
213
+ return value.record;
214
+ }
215
+ function createLegacyPendingWriteSnapshotId(record) {
216
+ return `legacy:${encodeURIComponent(record.id)}:${encodeURIComponent(record.savedAt)}`;
217
+ }
174
218
  function isPendingWriteRecord(value) {
175
219
  return (value !== null &&
176
220
  typeof value === "object" &&
@@ -178,6 +222,7 @@ function isPendingWriteRecord(value) {
178
222
  typeof value.id === "string" &&
179
223
  "savedAt" in value &&
180
224
  typeof value.savedAt === "string" &&
225
+ (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
181
226
  "syncStatus" in value &&
182
227
  (value.syncStatus === "pending" ||
183
228
  value.syncStatus === "syncing" ||
@@ -1,6 +1,8 @@
1
1
  import { type BrowserKeyValueDriverOptions } from "./idbDriver";
2
2
  export declare const readCacheSchemaVersion = 1;
3
3
  export declare const readCacheFreshnessMaxAgeMs = 30000;
4
+ export declare const durableReadCacheFreshnessMaxAgeMs: number;
5
+ export declare const identityReadCacheFreshnessMaxAgeMs: number;
4
6
  export declare const browserReadCacheOperationTimeoutMs = 2000;
5
7
  export type ReadCacheHit<T> = {
6
8
  fromCache: true;
@@ -10,14 +12,23 @@ export type ReadCacheHit<T> = {
10
12
  export type ReadCacheDriver = {
11
13
  clear: () => Promise<void>;
12
14
  close?: () => Promise<void>;
15
+ delete: (key: string) => Promise<void>;
13
16
  get: (key: string) => Promise<unknown | null>;
14
17
  set: (key: string, value: unknown) => Promise<void>;
15
18
  };
16
19
  export type ReadCache = {
20
+ /**
21
+ * Resolves a stable cache for the duration of a resource load. Dynamic
22
+ * adapters use this to pin the active account before asynchronous work.
23
+ */
24
+ capture?: () => ReadCache;
17
25
  clear: () => Promise<void>;
18
26
  close: () => Promise<void>;
27
+ delete: (key: string) => Promise<void>;
19
28
  get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
29
+ getVersion: (key: string) => string;
20
30
  set: <T>(key: string, value: T) => Promise<void>;
31
+ setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
21
32
  };
22
33
  export type ReadThroughCacheResult<T> = {
23
34
  data: T;
@@ -45,15 +56,18 @@ export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
45
56
  export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
46
57
  export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
47
58
  export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
48
- export declare function readThroughCache<T>({ cache, key, loadFresh, }: {
59
+ export declare function readThroughCache<T>({ cache, key, loadFresh, maxAgeMs, }: {
49
60
  cache: ReadCache;
50
61
  key: string;
51
62
  loadFresh: () => Promise<T>;
63
+ maxAgeMs?: number;
52
64
  }): Promise<ReadThroughCacheResult<T>>;
53
- export declare function loadReadCacheResource<T>({ cache, key, loadFresh, maxAgeMs, onUpdate, }: {
65
+ export declare function loadReadCacheResource<T>({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache, }: {
54
66
  cache: ReadCache;
55
67
  key: string;
56
68
  loadFresh: () => Promise<T>;
57
69
  maxAgeMs?: number;
70
+ onCacheWriteError?: (error: Error) => void;
58
71
  onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
72
+ skipCache?: boolean;
59
73
  }): Promise<T>;
package/dist/readCache.js CHANGED
@@ -1,16 +1,37 @@
1
1
  import { createIdbKeyValueDriver, } from "./idbDriver.js";
2
2
  export const readCacheSchemaVersion = 1;
3
3
  export const readCacheFreshnessMaxAgeMs = 30_000;
4
+ export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
5
+ export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
4
6
  export const browserReadCacheOperationTimeoutMs = 2_000;
5
- const pendingFreshLoads = new Map();
7
+ const pendingFreshLoads = new WeakMap();
6
8
  export function createReadCache(driver) {
7
- return {
9
+ let globalVersion = 0;
10
+ const keyVersions = new Map();
11
+ let operationQueue = Promise.resolve();
12
+ const pendingLoads = new Map();
13
+ function getVersion(key) {
14
+ return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
15
+ }
16
+ function enqueue(operation) {
17
+ const next = operationQueue.then(operation, operation);
18
+ operationQueue = next.then(() => undefined, () => undefined);
19
+ return next;
20
+ }
21
+ const cache = {
8
22
  async clear() {
9
- pendingFreshLoads.clear();
10
- await driver.clear();
23
+ pendingLoads.clear();
24
+ globalVersion += 1;
25
+ keyVersions.clear();
26
+ await enqueue(() => driver.clear());
11
27
  },
12
28
  async close() {
13
- await driver.close?.();
29
+ await enqueue(async () => driver.close?.());
30
+ },
31
+ async delete(key) {
32
+ pendingLoads.delete(key);
33
+ keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
34
+ await enqueue(() => driver.delete(key));
14
35
  },
15
36
  async get(key) {
16
37
  const envelope = toReadCacheEnvelope(await driver.get(key));
@@ -22,14 +43,33 @@ export function createReadCache(driver) {
22
43
  }
23
44
  : null;
24
45
  },
46
+ getVersion,
25
47
  async set(key, value) {
26
- await driver.set(key, {
48
+ await enqueue(() => driver.set(key, {
27
49
  savedAt: new Date().toISOString(),
28
50
  schemaVersion: readCacheSchemaVersion,
29
51
  value,
52
+ }));
53
+ },
54
+ async setIfVersion(key, value, version) {
55
+ if (getVersion(key) !== version) {
56
+ return false;
57
+ }
58
+ return enqueue(async () => {
59
+ if (getVersion(key) !== version) {
60
+ return false;
61
+ }
62
+ await driver.set(key, {
63
+ savedAt: new Date().toISOString(),
64
+ schemaVersion: readCacheSchemaVersion,
65
+ value,
66
+ });
67
+ return true;
30
68
  });
31
69
  },
32
70
  };
71
+ pendingFreshLoads.set(cache, pendingLoads);
72
+ return cache;
33
73
  }
34
74
  export function createMemoryReadCacheDriver() {
35
75
  const records = new Map();
@@ -38,6 +78,9 @@ export function createMemoryReadCacheDriver() {
38
78
  records.clear();
39
79
  },
40
80
  async close() { },
81
+ async delete(key) {
82
+ records.delete(key);
83
+ },
41
84
  async get(key) {
42
85
  return records.get(key) ?? null;
43
86
  },
@@ -71,11 +114,21 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
71
114
  .filter(Boolean)
72
115
  .join(":");
73
116
  }
74
- export async function readThroughCache({ cache, key, loadFresh, }) {
75
- const cached = await cache.get(key);
117
+ export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
118
+ const resourceCache = cache.capture?.() ?? cache;
119
+ const version = resourceCache.getVersion(key);
120
+ const cached = await resourceCache.get(key);
121
+ if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
122
+ return {
123
+ data: cached.value,
124
+ error: null,
125
+ fromCache: true,
126
+ stale: false,
127
+ };
128
+ }
76
129
  try {
77
130
  const fresh = await loadFresh();
78
- await cache.set(key, fresh);
131
+ await resourceCache.setIfVersion(key, fresh, version);
79
132
  return {
80
133
  data: fresh,
81
134
  error: null,
@@ -95,8 +148,10 @@ export async function readThroughCache({ cache, key, loadFresh, }) {
95
148
  throw error;
96
149
  }
97
150
  }
98
- export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
99
- const cached = await cache.get(key);
151
+ export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
152
+ const resourceCache = cache.capture?.() ?? cache;
153
+ const version = resourceCache.getVersion(key);
154
+ const cached = skipCache ? null : await resourceCache.get(key);
100
155
  if (cached) {
101
156
  onUpdate({ data: cached.value, fromCache: true });
102
157
  if (isCacheFresh(cached.savedAt, maxAgeMs)) {
@@ -104,9 +159,16 @@ export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, o
104
159
  }
105
160
  }
106
161
  try {
107
- const fresh = await loadSharedFreshResource(key, loadFresh);
162
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
108
163
  onUpdate({ data: fresh, fromCache: false });
109
- void cache.set(key, fresh).catch(() => undefined);
164
+ void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
165
+ const normalizedError = normalizeError(error);
166
+ console.error("[Bayon offline] Unable to refresh the read cache.", {
167
+ error: normalizedError,
168
+ key,
169
+ });
170
+ onCacheWriteError?.(normalizedError);
171
+ });
110
172
  return fresh;
111
173
  }
112
174
  catch (error) {
@@ -125,6 +187,7 @@ function createNoopReadCacheDriver() {
125
187
  return {
126
188
  async clear() { },
127
189
  async close() { },
190
+ async delete() { },
128
191
  async get() {
129
192
  return null;
130
193
  },
@@ -138,22 +201,32 @@ function isCacheFresh(savedAt, maxAgeMs) {
138
201
  const savedTime = Date.parse(savedAt);
139
202
  return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
140
203
  }
141
- async function loadSharedFreshResource(key, loadFresh) {
142
- const pendingLoad = pendingFreshLoads.get(key);
204
+ async function loadSharedFreshResource(cache, key, loadFresh) {
205
+ const pendingLoads = getPendingFreshLoads(cache);
206
+ const pendingLoad = pendingLoads.get(key);
143
207
  if (pendingLoad) {
144
208
  return (await pendingLoad);
145
209
  }
146
210
  const nextLoad = loadFresh();
147
- pendingFreshLoads.set(key, nextLoad);
211
+ pendingLoads.set(key, nextLoad);
148
212
  try {
149
213
  return await nextLoad;
150
214
  }
151
215
  finally {
152
- if (pendingFreshLoads.get(key) === nextLoad) {
153
- pendingFreshLoads.delete(key);
216
+ if (pendingLoads.get(key) === nextLoad) {
217
+ pendingLoads.delete(key);
154
218
  }
155
219
  }
156
220
  }
221
+ function getPendingFreshLoads(cache) {
222
+ const existing = pendingFreshLoads.get(cache);
223
+ if (existing) {
224
+ return existing;
225
+ }
226
+ const next = new Map();
227
+ pendingFreshLoads.set(cache, next);
228
+ return next;
229
+ }
157
230
  function toReadCacheEnvelope(value) {
158
231
  if (!value || typeof value !== "object") {
159
232
  return null;
@@ -16,6 +16,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
16
16
  async clear() {
17
17
  await runBrowserStorageOperation({
18
18
  fallback: undefined,
19
+ operationName: "clear object store",
19
20
  operation: async () => {
20
21
  const database = await databasePromise;
21
22
  await database.clear(storeName);
@@ -26,6 +27,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
26
27
  async close() {
27
28
  await runBrowserStorageOperation({
28
29
  fallback: undefined,
30
+ operationName: "close database",
29
31
  operation: async () => {
30
32
  const database = await databasePromise;
31
33
  database.close();
@@ -36,6 +38,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
36
38
  async delete(key) {
37
39
  await runBrowserStorageOperation({
38
40
  fallback: undefined,
41
+ operationName: "delete key",
39
42
  operation: async () => {
40
43
  const database = await databasePromise;
41
44
  await database.delete(storeName, key);
@@ -43,9 +46,32 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
43
46
  timeoutMs: operationTimeoutMs,
44
47
  });
45
48
  },
49
+ async deleteIf(key, predicate) {
50
+ return runBrowserStorageOperation({
51
+ fallback: false,
52
+ operationName: "conditionally delete key",
53
+ operation: async () => {
54
+ const database = await databasePromise;
55
+ // The ownership check and deletion must share a transaction; splitting
56
+ // them can delete a newer offline snapshot written by another trigger.
57
+ const transaction = database.transaction(storeName, "readwrite");
58
+ const objectStore = transaction.objectStore(storeName);
59
+ const current = (await objectStore.get(key)) ?? null;
60
+ if (!predicate(current)) {
61
+ await transaction.done;
62
+ return false;
63
+ }
64
+ await objectStore.delete(key);
65
+ await transaction.done;
66
+ return true;
67
+ },
68
+ timeoutMs: operationTimeoutMs,
69
+ });
70
+ },
46
71
  async get(key) {
47
72
  return runBrowserStorageOperation({
48
73
  fallback: null,
74
+ operationName: "read key",
49
75
  operation: async () => {
50
76
  const database = await databasePromise;
51
77
  return (await database.get(storeName, key)) ?? null;
@@ -56,6 +82,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
56
82
  async list() {
57
83
  return runBrowserStorageOperation({
58
84
  fallback: [],
85
+ operationName: "list object store",
59
86
  operation: async () => {
60
87
  const database = await databasePromise;
61
88
  return database.getAll(storeName);
@@ -66,6 +93,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
66
93
  async set(key, value) {
67
94
  await runBrowserStorageOperation({
68
95
  fallback: undefined,
96
+ operationName: "write key",
69
97
  operation: async () => {
70
98
  const database = await databasePromise;
71
99
  await database.put(storeName, value, key);
@@ -73,6 +101,26 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
73
101
  timeoutMs: operationTimeoutMs,
74
102
  });
75
103
  },
104
+ async setIf(key, value, predicate) {
105
+ return runBrowserStorageOperation({
106
+ fallback: false,
107
+ operationName: "conditionally replace key",
108
+ operation: async () => {
109
+ const database = await databasePromise;
110
+ const transaction = database.transaction(storeName, "readwrite");
111
+ const objectStore = transaction.objectStore(storeName);
112
+ const current = (await objectStore.get(key)) ?? null;
113
+ if (!predicate(current)) {
114
+ await transaction.done;
115
+ return false;
116
+ }
117
+ await objectStore.put(value, key);
118
+ await transaction.done;
119
+ return true;
120
+ },
121
+ timeoutMs: operationTimeoutMs,
122
+ });
123
+ },
76
124
  };
77
125
  }
78
126
  async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB, storeName, }) {
@@ -89,17 +137,23 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
89
137
  }
90
138
  return database;
91
139
  }
92
- async function runBrowserStorageOperation({ fallback, operation, timeoutMs, }) {
140
+ async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
93
141
  let timeoutId = null;
94
142
  try {
95
143
  return await Promise.race([
96
144
  operation(),
97
145
  new Promise((resolve) => {
98
- timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
146
+ timeoutId = setTimeout(() => {
147
+ console.error(`IndexedDB timed out while trying to ${operationName}.`, {
148
+ timeoutMs,
149
+ });
150
+ resolve(fallback);
151
+ }, timeoutMs);
99
152
  }),
100
153
  ]);
101
154
  }
102
- catch {
155
+ catch (error) {
156
+ console.error(`IndexedDB failed to ${operationName}.`, { error });
103
157
  return fallback;
104
158
  }
105
159
  finally {
@@ -6,11 +6,12 @@ exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
6
6
  exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
7
7
  exports.createBrowserPendingWriteStore = createBrowserPendingWriteStore;
8
8
  exports.createPendingWriteKey = createPendingWriteKey;
9
+ exports.createPendingWriteSnapshotId = createPendingWriteSnapshotId;
9
10
  exports.syncPendingWrite = syncPendingWrite;
10
11
  const idbDriver_1 = require("./idbDriver");
11
12
  exports.pendingWriteSchemaVersion = 1;
12
13
  exports.pendingWriteOperationTimeoutMs = 2_000;
13
- function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
14
+ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
14
15
  return {
15
16
  async clear(id) {
16
17
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
@@ -18,33 +19,43 @@ function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVe
18
19
  async clearAll() {
19
20
  await driver.clearAll();
20
21
  },
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;
22
+ async clearIfSnapshotId(id, snapshotId) {
23
+ return driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
24
+ normalizeLegacyRecord,
25
+ normalizePayload,
26
+ })?.record.snapshotId === snapshotId);
28
27
  },
29
28
  async close() {
30
29
  await driver.close?.();
31
30
  },
32
31
  async get(id) {
33
- return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizePayload })?.record ?? null);
32
+ return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
34
33
  },
35
34
  async list() {
36
35
  return (await driver.list())
37
36
  .map((value) => toPendingWriteEnvelope(value, schemaVersion, {
37
+ normalizeLegacyRecord,
38
38
  normalizePayload,
39
39
  })?.record)
40
40
  .filter((record) => Boolean(record))
41
41
  .sort((left, right) => left.savedAt.localeCompare(right.savedAt));
42
42
  },
43
+ async replaceIfSnapshotId(id, snapshotId, record) {
44
+ return driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
45
+ normalizeLegacyRecord,
46
+ normalizePayload,
47
+ })?.record.snapshotId === snapshotId);
48
+ },
43
49
  async set(record) {
50
+ const storedRecord = {
51
+ ...record,
52
+ snapshotId: record.snapshotId ?? createPendingWriteSnapshotId(),
53
+ };
44
54
  await driver.set(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), {
45
- record,
55
+ record: storedRecord,
46
56
  schemaVersion,
47
57
  });
58
+ return storedRecord;
48
59
  },
49
60
  };
50
61
  }
@@ -54,6 +65,14 @@ function createMemoryPendingWriteDriver() {
54
65
  async clear(key) {
55
66
  records.delete(key);
56
67
  },
68
+ async clearIf(key, predicate) {
69
+ const current = records.get(key) ?? null;
70
+ if (!predicate(current)) {
71
+ return false;
72
+ }
73
+ records.delete(key);
74
+ return true;
75
+ },
57
76
  async clearAll() {
58
77
  records.clear();
59
78
  },
@@ -67,6 +86,13 @@ function createMemoryPendingWriteDriver() {
67
86
  async set(key, value) {
68
87
  records.set(key, value);
69
88
  },
89
+ async setIf(key, value, predicate) {
90
+ if (!predicate(records.get(key) ?? null)) {
91
+ return false;
92
+ }
93
+ records.set(key, value);
94
+ return true;
95
+ },
70
96
  };
71
97
  }
72
98
  function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, storeName, }) {
@@ -79,7 +105,7 @@ function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexe
79
105
  });
80
106
  return driver ? toPendingWriteDriver(driver) : null;
81
107
  }
82
- function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
108
+ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
83
109
  const driver = createBrowserPendingWriteDriver({
84
110
  databaseName,
85
111
  databaseVersion,
@@ -87,11 +113,14 @@ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexed
87
113
  operationTimeoutMs,
88
114
  storeName,
89
115
  });
90
- return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizePayload, schemaVersion });
116
+ return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
91
117
  }
92
118
  function createPendingWriteKey({ id, prefix }) {
93
119
  return `${prefix}:${encodeURIComponent(id)}`;
94
120
  }
121
+ function createPendingWriteSnapshotId() {
122
+ return globalThis.crypto.randomUUID();
123
+ }
95
124
  async function syncPendingWrite({ id, store, sync, }) {
96
125
  const pending = await store.get(id);
97
126
  if (!pending) {
@@ -100,7 +129,7 @@ async function syncPendingWrite({ id, store, sync, }) {
100
129
  await markPendingSnapshot({
101
130
  id,
102
131
  nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
103
- savedAt: pending.savedAt,
132
+ snapshotId: pending.snapshotId,
104
133
  store,
105
134
  });
106
135
  try {
@@ -111,18 +140,21 @@ async function syncPendingWrite({ id, store, sync, }) {
111
140
  await markPendingSnapshot({
112
141
  id,
113
142
  nextRecord: { ...pending, error: message, syncStatus: "failed" },
114
- savedAt: pending.savedAt,
143
+ snapshotId: pending.snapshotId,
115
144
  store,
116
145
  });
117
146
  return { error: message, status: "failed" };
118
147
  }
119
- return (await store.clearIfSavedAt(id, pending.savedAt))
148
+ return (await store.clearIfSnapshotId(id, pending.snapshotId))
120
149
  ? { status: "synced" }
121
150
  : { status: "superseded" };
122
151
  }
123
152
  function createNoopPendingWriteDriver() {
124
153
  return {
125
154
  async clear() { },
155
+ async clearIf() {
156
+ return false;
157
+ },
126
158
  async clearAll() { },
127
159
  async close() { },
128
160
  async get() {
@@ -132,43 +164,41 @@ function createNoopPendingWriteDriver() {
132
164
  return [];
133
165
  },
134
166
  async set() { },
167
+ async setIf() {
168
+ return false;
169
+ },
135
170
  };
136
171
  }
137
172
  function toPendingWriteDriver(driver) {
138
173
  return {
139
174
  clear: driver.delete,
175
+ clearIf: driver.deleteIf,
140
176
  clearAll: driver.clear,
141
177
  close: driver.close,
142
178
  get: driver.get,
143
179
  list: driver.list,
144
180
  set: driver.set,
181
+ setIf: driver.setIf,
145
182
  };
146
183
  }
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;
184
+ async function markPendingSnapshot({ id, nextRecord, snapshotId, store, }) {
185
+ return store.replaceIfSnapshotId(id, snapshotId, nextRecord);
154
186
  }
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);
187
+ function toPendingWriteEnvelope(value, schemaVersion, { normalizeLegacyRecord, normalizePayload }) {
188
+ const envelopeRecord = readPendingWriteEnvelopeRecord(value, schemaVersion);
189
+ const legacyRecord = envelopeRecord
190
+ ? null
191
+ : (normalizeLegacyRecord?.(value) ?? null);
192
+ const record = normalizePendingWriteRecord(envelopeRecord ?? legacyRecord, normalizePayload);
165
193
  return record ? { record, schemaVersion } : null;
166
194
  }
167
195
  function normalizePendingWriteRecord(record, normalizePayload) {
168
- if (!normalizePayload) {
169
- return record;
196
+ if (!record) {
197
+ return null;
170
198
  }
171
- const payload = normalizePayload(record.payload);
199
+ const payload = normalizePayload
200
+ ? normalizePayload(record.payload)
201
+ : record.payload;
172
202
  if (payload === null) {
173
203
  return null;
174
204
  }
@@ -177,9 +207,24 @@ function normalizePendingWriteRecord(record, normalizePayload) {
177
207
  id: record.id,
178
208
  payload,
179
209
  savedAt: record.savedAt,
210
+ snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
180
211
  syncStatus: record.syncStatus,
181
212
  };
182
213
  }
214
+ function readPendingWriteEnvelopeRecord(value, schemaVersion) {
215
+ if (!value ||
216
+ typeof value !== "object" ||
217
+ !("schemaVersion" in value) ||
218
+ value.schemaVersion !== schemaVersion ||
219
+ !("record" in value) ||
220
+ !isPendingWriteRecord(value.record)) {
221
+ return null;
222
+ }
223
+ return value.record;
224
+ }
225
+ function createLegacyPendingWriteSnapshotId(record) {
226
+ return `legacy:${encodeURIComponent(record.id)}:${encodeURIComponent(record.savedAt)}`;
227
+ }
183
228
  function isPendingWriteRecord(value) {
184
229
  return (value !== null &&
185
230
  typeof value === "object" &&
@@ -187,6 +232,7 @@ function isPendingWriteRecord(value) {
187
232
  typeof value.id === "string" &&
188
233
  "savedAt" in value &&
189
234
  typeof value.savedAt === "string" &&
235
+ (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
190
236
  "syncStatus" in value &&
191
237
  (value.syncStatus === "pending" ||
192
238
  value.syncStatus === "syncing" ||
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.browserReadCacheOperationTimeoutMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
3
+ exports.browserReadCacheOperationTimeoutMs = exports.identityReadCacheFreshnessMaxAgeMs = exports.durableReadCacheFreshnessMaxAgeMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
4
4
  exports.createReadCache = createReadCache;
5
5
  exports.createMemoryReadCacheDriver = createMemoryReadCacheDriver;
6
6
  exports.createBrowserReadCache = createBrowserReadCache;
@@ -10,16 +10,37 @@ exports.loadReadCacheResource = loadReadCacheResource;
10
10
  const idbDriver_1 = require("./idbDriver");
11
11
  exports.readCacheSchemaVersion = 1;
12
12
  exports.readCacheFreshnessMaxAgeMs = 30_000;
13
+ exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
14
+ exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
13
15
  exports.browserReadCacheOperationTimeoutMs = 2_000;
14
- const pendingFreshLoads = new Map();
16
+ const pendingFreshLoads = new WeakMap();
15
17
  function createReadCache(driver) {
16
- return {
18
+ let globalVersion = 0;
19
+ const keyVersions = new Map();
20
+ let operationQueue = Promise.resolve();
21
+ const pendingLoads = new Map();
22
+ function getVersion(key) {
23
+ return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
24
+ }
25
+ function enqueue(operation) {
26
+ const next = operationQueue.then(operation, operation);
27
+ operationQueue = next.then(() => undefined, () => undefined);
28
+ return next;
29
+ }
30
+ const cache = {
17
31
  async clear() {
18
- pendingFreshLoads.clear();
19
- await driver.clear();
32
+ pendingLoads.clear();
33
+ globalVersion += 1;
34
+ keyVersions.clear();
35
+ await enqueue(() => driver.clear());
20
36
  },
21
37
  async close() {
22
- await driver.close?.();
38
+ await enqueue(async () => driver.close?.());
39
+ },
40
+ async delete(key) {
41
+ pendingLoads.delete(key);
42
+ keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
43
+ await enqueue(() => driver.delete(key));
23
44
  },
24
45
  async get(key) {
25
46
  const envelope = toReadCacheEnvelope(await driver.get(key));
@@ -31,14 +52,33 @@ function createReadCache(driver) {
31
52
  }
32
53
  : null;
33
54
  },
55
+ getVersion,
34
56
  async set(key, value) {
35
- await driver.set(key, {
57
+ await enqueue(() => driver.set(key, {
36
58
  savedAt: new Date().toISOString(),
37
59
  schemaVersion: exports.readCacheSchemaVersion,
38
60
  value,
61
+ }));
62
+ },
63
+ async setIfVersion(key, value, version) {
64
+ if (getVersion(key) !== version) {
65
+ return false;
66
+ }
67
+ return enqueue(async () => {
68
+ if (getVersion(key) !== version) {
69
+ return false;
70
+ }
71
+ await driver.set(key, {
72
+ savedAt: new Date().toISOString(),
73
+ schemaVersion: exports.readCacheSchemaVersion,
74
+ value,
75
+ });
76
+ return true;
39
77
  });
40
78
  },
41
79
  };
80
+ pendingFreshLoads.set(cache, pendingLoads);
81
+ return cache;
42
82
  }
43
83
  function createMemoryReadCacheDriver() {
44
84
  const records = new Map();
@@ -47,6 +87,9 @@ function createMemoryReadCacheDriver() {
47
87
  records.clear();
48
88
  },
49
89
  async close() { },
90
+ async delete(key) {
91
+ records.delete(key);
92
+ },
50
93
  async get(key) {
51
94
  return records.get(key) ?? null;
52
95
  },
@@ -80,11 +123,21 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
80
123
  .filter(Boolean)
81
124
  .join(":");
82
125
  }
83
- async function readThroughCache({ cache, key, loadFresh, }) {
84
- const cached = await cache.get(key);
126
+ async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
127
+ const resourceCache = cache.capture?.() ?? cache;
128
+ const version = resourceCache.getVersion(key);
129
+ const cached = await resourceCache.get(key);
130
+ if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
131
+ return {
132
+ data: cached.value,
133
+ error: null,
134
+ fromCache: true,
135
+ stale: false,
136
+ };
137
+ }
85
138
  try {
86
139
  const fresh = await loadFresh();
87
- await cache.set(key, fresh);
140
+ await resourceCache.setIfVersion(key, fresh, version);
88
141
  return {
89
142
  data: fresh,
90
143
  error: null,
@@ -104,8 +157,10 @@ async function readThroughCache({ cache, key, loadFresh, }) {
104
157
  throw error;
105
158
  }
106
159
  }
107
- async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
108
- const cached = await cache.get(key);
160
+ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
161
+ const resourceCache = cache.capture?.() ?? cache;
162
+ const version = resourceCache.getVersion(key);
163
+ const cached = skipCache ? null : await resourceCache.get(key);
109
164
  if (cached) {
110
165
  onUpdate({ data: cached.value, fromCache: true });
111
166
  if (isCacheFresh(cached.savedAt, maxAgeMs)) {
@@ -113,9 +168,16 @@ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate
113
168
  }
114
169
  }
115
170
  try {
116
- const fresh = await loadSharedFreshResource(key, loadFresh);
171
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
117
172
  onUpdate({ data: fresh, fromCache: false });
118
- void cache.set(key, fresh).catch(() => undefined);
173
+ void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
174
+ const normalizedError = normalizeError(error);
175
+ console.error("[Bayon offline] Unable to refresh the read cache.", {
176
+ error: normalizedError,
177
+ key,
178
+ });
179
+ onCacheWriteError?.(normalizedError);
180
+ });
119
181
  return fresh;
120
182
  }
121
183
  catch (error) {
@@ -134,6 +196,7 @@ function createNoopReadCacheDriver() {
134
196
  return {
135
197
  async clear() { },
136
198
  async close() { },
199
+ async delete() { },
137
200
  async get() {
138
201
  return null;
139
202
  },
@@ -147,22 +210,32 @@ function isCacheFresh(savedAt, maxAgeMs) {
147
210
  const savedTime = Date.parse(savedAt);
148
211
  return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
149
212
  }
150
- async function loadSharedFreshResource(key, loadFresh) {
151
- const pendingLoad = pendingFreshLoads.get(key);
213
+ async function loadSharedFreshResource(cache, key, loadFresh) {
214
+ const pendingLoads = getPendingFreshLoads(cache);
215
+ const pendingLoad = pendingLoads.get(key);
152
216
  if (pendingLoad) {
153
217
  return (await pendingLoad);
154
218
  }
155
219
  const nextLoad = loadFresh();
156
- pendingFreshLoads.set(key, nextLoad);
220
+ pendingLoads.set(key, nextLoad);
157
221
  try {
158
222
  return await nextLoad;
159
223
  }
160
224
  finally {
161
- if (pendingFreshLoads.get(key) === nextLoad) {
162
- pendingFreshLoads.delete(key);
225
+ if (pendingLoads.get(key) === nextLoad) {
226
+ pendingLoads.delete(key);
163
227
  }
164
228
  }
165
229
  }
230
+ function getPendingFreshLoads(cache) {
231
+ const existing = pendingFreshLoads.get(cache);
232
+ if (existing) {
233
+ return existing;
234
+ }
235
+ const next = new Map();
236
+ pendingFreshLoads.set(cache, next);
237
+ return next;
238
+ }
166
239
  function toReadCacheEnvelope(value) {
167
240
  if (!value || typeof value !== "object") {
168
241
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Reusable offline read-cache and pending-write primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",
@@ -46,8 +46,10 @@
46
46
  "scripts": {
47
47
  "build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs",
48
48
  "lint": "tsc --noEmit -p tsconfig.json",
49
- "prepack": "pnpm run build",
50
- "test": "vitest run"
49
+ "pack:smoke": "corepack pnpm run build && npm pack --dry-run --access public",
50
+ "prepack": "corepack pnpm run build",
51
+ "test": "vitest run",
52
+ "test:coverage": "vitest run --coverage"
51
53
  },
52
54
  "dependencies": {
53
55
  "idb": "^8.0.3"