@procedures/store 0.2.2 → 0.3.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +109 -2
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/indexeddb/idb.d.ts +100 -0
  5. package/dist/indexeddb/idb.d.ts.map +1 -0
  6. package/dist/indexeddb/idb.js +137 -0
  7. package/dist/indexeddb/idb.js.map +1 -0
  8. package/dist/indexeddb/program-store.d.ts +42 -0
  9. package/dist/indexeddb/program-store.d.ts.map +1 -0
  10. package/dist/indexeddb/program-store.js +71 -0
  11. package/dist/indexeddb/program-store.js.map +1 -0
  12. package/dist/indexeddb/queue-store.d.ts +38 -0
  13. package/dist/indexeddb/queue-store.d.ts.map +1 -0
  14. package/dist/indexeddb/queue-store.js +73 -0
  15. package/dist/indexeddb/queue-store.js.map +1 -0
  16. package/dist/indexeddb.d.ts +19 -0
  17. package/dist/indexeddb.d.ts.map +1 -0
  18. package/dist/indexeddb.js +17 -0
  19. package/dist/indexeddb.js.map +1 -0
  20. package/dist/program-store/file.d.ts +5 -0
  21. package/dist/program-store/file.d.ts.map +1 -1
  22. package/dist/program-store/file.js +5 -2
  23. package/dist/program-store/file.js.map +1 -1
  24. package/dist/program-store/procedures.d.ts.map +1 -1
  25. package/dist/program-store/procedures.js +5 -2
  26. package/dist/program-store/procedures.js.map +1 -1
  27. package/dist/storage/api.d.ts +18 -7
  28. package/dist/storage/api.d.ts.map +1 -1
  29. package/dist/storage/api.js +34 -3
  30. package/dist/storage/api.js.map +1 -1
  31. package/dist/storage/factory.d.ts +13 -2
  32. package/dist/storage/factory.d.ts.map +1 -1
  33. package/dist/storage/factory.js +12 -4
  34. package/dist/storage/factory.js.map +1 -1
  35. package/dist/storage/file-queue.d.ts +32 -0
  36. package/dist/storage/file-queue.d.ts.map +1 -0
  37. package/dist/storage/file-queue.js +101 -0
  38. package/dist/storage/file-queue.js.map +1 -0
  39. package/dist/storage/hybrid.d.ts +355 -42
  40. package/dist/storage/hybrid.d.ts.map +1 -1
  41. package/dist/storage/hybrid.js +1093 -250
  42. package/dist/storage/hybrid.js.map +1 -1
  43. package/dist/storage/index.d.ts +4 -2
  44. package/dist/storage/index.d.ts.map +1 -1
  45. package/dist/storage/index.js +5 -1
  46. package/dist/storage/index.js.map +1 -1
  47. package/dist/storage/queue.d.ts +67 -0
  48. package/dist/storage/queue.d.ts.map +1 -0
  49. package/dist/storage/queue.js +85 -0
  50. package/dist/storage/queue.js.map +1 -0
  51. package/dist/storage/synced-registry.d.ts +18 -2
  52. package/dist/storage/synced-registry.d.ts.map +1 -1
  53. package/dist/storage/synced-registry.js +37 -3
  54. package/dist/storage/synced-registry.js.map +1 -1
  55. package/dist/storage/types.d.ts +5 -2
  56. package/dist/storage/types.d.ts.map +1 -1
  57. package/package.json +16 -6
  58. package/src/indexeddb/idb.ts +190 -0
  59. package/src/indexeddb/program-store.ts +80 -0
  60. package/src/indexeddb/queue-store.ts +80 -0
  61. package/src/indexeddb.ts +19 -0
  62. package/src/program-store/file.ts +5 -2
  63. package/src/program-store/procedures.ts +5 -2
  64. package/src/storage/api.ts +50 -10
  65. package/src/storage/factory.ts +21 -4
  66. package/src/storage/file-queue.ts +103 -0
  67. package/src/storage/hybrid.ts +1142 -274
  68. package/src/storage/index.ts +15 -2
  69. package/src/storage/queue.ts +128 -0
  70. package/src/storage/synced-registry.ts +49 -4
  71. package/src/storage/types.ts +6 -2
@@ -1,19 +1,27 @@
1
1
  /**
2
2
  * Hybrid Storage Implementation
3
3
  *
4
- * Combines local cache (InMemoryStorage) with remote persistence (ApiStorage).
5
- * Provides fast local reads with remote backup and sync.
4
+ * A local copy (an `InMemoryStorage`, or the `local` storage that the application gives) in
5
+ * front of a remote `CollectionStorage` (often an `ApiStorage`). The storage has four modes, and
6
+ * `setMode()` changes the mode while the storage operates (roadmap R28):
6
7
  *
7
- * Features:
8
- * - Local cache for fast reads, with a remote read on a miss
9
- * - Write-through or write-back strategies
10
- * - Automatic sync on initialization
11
- * - Conflict resolution
12
- * - Offline operation queue
8
+ * | Mode | Reads | Writes | Remote calls |
9
+ * |---|---|---|---|
10
+ * | `local-only` | the local copy | the local copy and the queue | none |
11
+ * | `write-through` | the local copy, the remote on a miss | the local copy, then the remote | yes |
12
+ * | `write-back` | the local copy, the remote on a miss | the local copy and the queue, a timer sends it | yes |
13
+ * | `remote-only` | the remote | the remote | yes, and no local copy |
14
+ *
15
+ * - The queue is durable through the `queueStore` that the application gives.
16
+ * - A sync asks for the changes since a version when the remote has `getChangesSince`.
17
+ * - With `versionOf`, a conflict is a change on both sides, and `conflictResolution` decides.
18
+ * - `pullInterval` keeps the local copy fresh.
13
19
  */
14
20
 
15
- import type { CollectionStorage, StorageMetadata } from "@procedures/collections";
21
+ import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
16
22
  import { InMemoryStorage } from "@procedures/collections";
23
+ import { optionalMs, type Duration } from "@procedures/client";
24
+ import { MemoryQueueStore, idsOf, withoutId, type QueuedOperation, type QueueStore } from "./queue.js";
17
25
 
18
26
  /**
19
27
  * Conflict resolution strategy.
@@ -22,19 +30,37 @@ export type ConflictResolution = "local" | "remote" | "merge" | "error";
22
30
 
23
31
  /**
24
32
  * Write strategy for hybrid storage.
33
+ * @deprecated Use `HybridMode`: the strategies are two of its modes.
25
34
  */
26
35
  export type WriteStrategy = "write-through" | "write-back";
27
36
 
37
+ /**
38
+ * The mode of a `HybridStorage` (roadmap R28).
39
+ *
40
+ * - `local-only`: the storage makes no remote call. Each write goes to the local copy and to the
41
+ * queue, so a later switch to a remote mode sends it.
42
+ * - `write-through`: each write goes to the local copy, then to the remote.
43
+ * - `write-back`: each write goes to the local copy and to the queue. A timer sends the queue.
44
+ * - `remote-only`: each read and each write goes to the remote. The storage keeps no local copy.
45
+ */
46
+ export type HybridMode = "local-only" | "write-through" | "write-back" | "remote-only";
47
+
48
+ const MODES: ReadonlySet<string> = new Set<HybridMode>(["local-only", "write-through", "write-back", "remote-only"]);
49
+
28
50
  /**
29
51
  * Hybrid storage configuration options.
30
52
  */
31
53
  export interface HybridStorageOptions {
32
54
  /**
33
- * Write strategy
34
- * - write-through: Write to both local and remote immediately
35
- * - write-back: Write to local immediately, sync to remote later
55
+ * The first mode. `setMode()` changes it later.
36
56
  * @default "write-through"
37
57
  */
58
+ mode?: HybridMode;
59
+
60
+ /**
61
+ * The first mode, with its old name. `mode` comes first when both are given.
62
+ * @deprecated Use `mode`.
63
+ */
38
64
  writeStrategy?: WriteStrategy;
39
65
 
40
66
  /**
@@ -42,14 +68,20 @@ export interface HybridStorageOptions {
42
68
  * - local: Local changes win
43
69
  * - remote: Remote changes win
44
70
  * - merge: Custom merge function (requires mergeFn)
45
- * - error: Throw error on conflict
71
+ * - error: The sync throws `ConflictError` with the ids, and changes nothing for them
72
+ *
73
+ * With `versionOf`, a conflict is a queued local write of an id whose remote version changed
74
+ * after the last version that the storage saw. A queued write of an id without a known remote
75
+ * version wins. Without `versionOf`, a conflict is a local value that differs from the remote
76
+ * value, with no queued write: a queued write always wins.
46
77
  * @default "remote"
47
78
  */
48
79
  conflictResolution?: ConflictResolution;
49
80
 
50
81
  /**
51
82
  * Custom merge function for conflict resolution.
52
- * Only used when conflictResolution is "merge".
83
+ * Only used when conflictResolution is "merge". A conflict where one side deleted the item
84
+ * keeps the local side.
53
85
  *
54
86
  * @param local - Local version
55
87
  * @param remote - Remote version
@@ -64,62 +96,174 @@ export interface HybridStorageOptions {
64
96
  keyOf?: (item: any) => string | undefined;
65
97
 
66
98
  /**
67
- * Automatic sync interval in milliseconds (for write-back mode).
68
- * Set to 0 to disable automatic sync.
69
- * @default 5000 (5 seconds)
99
+ * The version of an item, for example its ETag or a revision field (roadmap R28). The sync
100
+ * compares the versions, not the JSON of the values. A remote change of an id with a queued
101
+ * local write is a conflict.
102
+ */
103
+ versionOf?: (item: any) => string | undefined;
104
+
105
+ /**
106
+ * Automatic sync interval (for write-back mode), for example `seconds(5)`.
107
+ * Set to 0 to disable automatic sync. In write-through mode, it is also the delay before the
108
+ * storage sends the queue after a failure (5 seconds when it is 0).
109
+ * @default 5 seconds
110
+ */
111
+ syncInterval?: Duration;
112
+
113
+ /**
114
+ * The interval of the periodic pull, for example `minutes(1)` (roadmap R28). A pull reads the
115
+ * remote changes into the local copy in the modes `write-through` and `write-back`.
116
+ * @default no periodic pull
70
117
  */
71
- syncInterval?: number;
118
+ pullInterval?: Duration;
72
119
 
73
120
  /**
74
- * Initialize by syncing from remote on construction.
121
+ * Initialize by syncing from remote on construction: send the loaded queue, then read the
122
+ * remote. The mode `local-only` makes no remote call.
75
123
  * @default true
76
124
  */
77
125
  syncOnInit?: boolean;
78
126
 
79
127
  /**
80
- * Maximum offline operations to queue.
128
+ * Maximum offline operations to queue. A full queue refuses the next write.
81
129
  * @default 1000
82
130
  */
83
131
  maxOfflineOps?: number;
84
132
 
85
133
  /**
86
- * Enable offline operation queue.
87
- * When remote is unavailable, operations are queued and retried.
134
+ * The queue of the write-through mode.
135
+ * In write-through mode, a write that the remote cannot take stays in the local cache and the
136
+ * queue, and the write throws `PendingWriteError`. A timer sends the queue later.
137
+ * Without the queue, the write is undone in the local cache and the write throws.
138
+ * The modes `write-back` and `local-only` always use the queue.
88
139
  * @default true
89
140
  */
90
141
  offlineQueue?: boolean;
142
+
143
+ /**
144
+ * The store that keeps the queue (roadmap R28). A new storage with the same store loads the
145
+ * queue again and sends it. `FileQueueStore` of `@procedures/store/file-queue` keeps it in a
146
+ * file.
147
+ * @default a new `MemoryQueueStore`
148
+ */
149
+ queueStore?: QueueStore;
150
+
151
+ /**
152
+ * True when an error of the remote means "not available now", so the write can wait in the
153
+ * queue. Another error (a refusal of the remote) undoes the write. The default: an error with
154
+ * `retryable: true`, or an error without a `code`.
155
+ */
156
+ isUnavailable?: (error: unknown) => boolean;
157
+
158
+ /**
159
+ * The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
160
+ * of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
161
+ * `local-only` mode reads the items of the last run. Give it with a durable `queueStore`.
162
+ * `close()` closes it.
163
+ * @default a new `InMemoryStorage`
164
+ */
165
+ local?: LocalCopyStorage<any>;
91
166
  }
92
167
 
93
168
  /**
94
- * Offline operation for write-back/queue.
169
+ * The remote did not take a write of the write-through mode. The write is in the local cache and
170
+ * in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
95
171
  */
96
- interface OfflineOperation {
97
- type: "set" | "delete" | "setBatch" | "deleteBatch" | "clear";
98
- data: unknown;
99
- timestamp: number;
172
+ export class PendingWriteError extends Error {
173
+ override readonly name = "PendingWriteError";
174
+ readonly code = "WRITE_PENDING";
175
+ readonly pending = true;
176
+ constructor(operation: string, cause: unknown) {
177
+ super(`The remote did not take the ${operation}: it waits in the queue`, { cause });
178
+ }
179
+ }
180
+
181
+ /** The queue of operations is full, so the storage refuses the write (roadmap R2). */
182
+ export class QueueFullError extends Error {
183
+ override readonly name = "QueueFullError";
184
+ readonly code = "QUEUE_FULL";
185
+ constructor(max: number) {
186
+ super(`The queue of operations is full (${max} operations): the storage refuses the write`);
187
+ }
188
+ }
189
+
190
+ /** The remote gave items without a key, so a sync cannot know which local items to keep. */
191
+ export class MissingKeysError extends Error {
192
+ override readonly name = "MissingKeysError";
193
+ readonly code = "MISSING_KEYS";
194
+ constructor(count: number) {
195
+ super(`${count} remote items have no key. Give the option keyOf, or a remote storage with getEntries(). The sync changed nothing.`);
196
+ }
100
197
  }
101
198
 
102
- /** The ids that an operation changes ("*" for clear). */
103
- function idsOf(op: OfflineOperation): string[] {
104
- const data = op.data as { id?: string; ids?: string[]; items?: Array<[string, unknown]> };
105
- switch (op.type) {
106
- case "set":
107
- case "delete":
108
- return data.id === undefined ? [] : [data.id];
109
- case "setBatch":
110
- return (data.items ?? []).map(([id]) => id);
111
- case "deleteBatch":
112
- return data.ids ?? [];
113
- case "clear":
114
- return ["*"];
199
+ /**
200
+ * The queue store failed (roadmap R28). A write that the store did not keep is undone and not
201
+ * acknowledged. A storage whose store cannot load refuses each operation.
202
+ */
203
+ export class QueueStoreError extends Error {
204
+ override readonly name = "QueueStoreError";
205
+ readonly code = "QUEUE_STORE";
206
+ constructor(action: "load" | "write", cause: unknown) {
207
+ super(`The queue store could not ${action === "load" ? "load the queue" : "keep the queue"}`, { cause });
115
208
  }
116
209
  }
117
210
 
211
+ /**
212
+ * The sync found conflicts and `conflictResolution` is "error" (roadmap R28). The local values and
213
+ * their queued writes stay. `ids` are the ids of the conflicts.
214
+ */
215
+ export class ConflictError extends Error {
216
+ override readonly name = "ConflictError";
217
+ readonly code = "SYNC_CONFLICT";
218
+ constructor(readonly ids: string[]) {
219
+ super(`Conflict detected between local and remote versions of ${ids.length} items: ${ids.slice(0, 10).join(", ")}`);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * `setMode()` could not change the mode (roadmap R28). The mode did not change. `cause` is the
225
+ * error of the flush or of the sync.
226
+ */
227
+ export class ModeSwitchError extends Error {
228
+ override readonly name = "ModeSwitchError";
229
+ readonly code = "MODE_SWITCH_FAILED";
230
+ constructor(readonly from: HybridMode, readonly to: HybridMode, cause: unknown) {
231
+ super(`The storage could not change from ${from} to ${to}: the mode is still ${from}`, { cause });
232
+ }
233
+ }
234
+
235
+ const defaultIsUnavailable = (error: unknown): boolean => {
236
+ if (typeof error !== "object" || error === null) return true;
237
+ const { retryable, code } = error as { retryable?: unknown; code?: unknown };
238
+ return retryable === true || code === undefined;
239
+ };
240
+
241
+ /** True when a remote does not know a procedure: an older server without `changes`. */
242
+ const isNotFound = (error: unknown): boolean =>
243
+ typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
244
+
118
245
  const defaultKeyOf = (item: unknown): string | undefined => {
119
246
  const id = (item as { id?: unknown } | null)?.id;
120
247
  return typeof id === "string" && id.length > 0 ? id : undefined;
121
248
  };
122
249
 
250
+ function checkMode(mode: unknown): HybridMode {
251
+ if (typeof mode !== "string" || !MODES.has(mode)) {
252
+ throw new TypeError(`Unknown mode ${JSON.stringify(mode)}: use local-only, write-through, write-back or remote-only`);
253
+ }
254
+ return mode as HybridMode;
255
+ }
256
+
257
+ /** The changes that a pull applies: from `getChangesSince`, or all entries. */
258
+ type PulledChanges<T> = Omit<StorageChanges<T>, "version"> & { version?: string };
259
+
260
+ /**
261
+ * A storage for the local copy of a `HybridStorage` (roadmap R45): a collection storage that gives
262
+ * its entries. A durable one, for example `SqliteStorage` of `@procedures/sqlite`, keeps the local
263
+ * copy after a restart.
264
+ */
265
+ export type LocalCopyStorage<T> = CollectionStorage<T> & { getEntries(): Promise<Array<[string, T]>> };
266
+
123
267
  /**
124
268
  * Hybrid storage combining local cache with remote persistence.
125
269
  *
@@ -131,9 +275,9 @@ const defaultKeyOf = (item: unknown): string | undefined => {
131
275
  *
132
276
  * const remote = new ApiStorage(client, { collection: "users" });
133
277
  * const hybrid = new HybridStorage(remote, {
134
- * writeStrategy: "write-through",
278
+ * mode: "write-through",
135
279
  * conflictResolution: "remote",
136
- * syncInterval: 5000
280
+ * syncInterval: seconds(5)
137
281
  * });
138
282
  *
139
283
  * // Fast local reads
@@ -141,29 +285,69 @@ const defaultKeyOf = (item: unknown): string | undefined => {
141
285
  *
142
286
  * // Writes go to both local and remote
143
287
  * await hybrid.set("123", { name: "John" });
288
+ *
289
+ * // Offline: no remote call. The writes wait in the queue.
290
+ * await hybrid.setMode("local-only");
291
+ * // Online again: the switch sends the queue, then syncs the local copy
292
+ * await hybrid.setMode("write-through");
144
293
  * ```
145
294
  */
146
295
  export class HybridStorage<T> implements CollectionStorage<T> {
147
- private local: InMemoryStorage<T>;
148
- private remote: CollectionStorage<T>;
296
+ private readonly local: LocalCopyStorage<T>;
297
+ private readonly remote: CollectionStorage<T>;
298
+ private readonly queueStore: QueueStore;
149
299
  private options: {
150
- writeStrategy: WriteStrategy;
151
300
  conflictResolution: ConflictResolution;
152
301
  mergeFn?: <T>(local: T, remote: T) => T;
153
302
  keyOf: (item: T) => string | undefined;
303
+ versionOf?: (item: T) => string | undefined;
154
304
  syncInterval: number;
305
+ pullInterval: number;
155
306
  syncOnInit: boolean;
156
307
  maxOfflineOps: number;
157
308
  offlineQueue: boolean;
309
+ isUnavailable: (error: unknown) => boolean;
158
310
  };
159
- private offlineOps: OfflineOperation[] = [];
311
+ private mode: HybridMode;
312
+ private offlineOps: QueuedOperation[] = [];
313
+ /** The operations in `offlineOps` that the queue store does not have yet */
314
+ private readonly unpersisted = new Set<QueuedOperation>();
315
+ /** True when the queue store can differ from `offlineOps`: a failed write, or sent operations */
316
+ private storeDirty = false;
317
+ /** The calls of the queue store, one at a time */
318
+ private storeChain: Promise<unknown> = Promise.resolve();
319
+ /** The push timer of the write-back mode */
160
320
  private syncTimer: ReturnType<typeof setInterval> | null = null;
321
+ /** The periodic pull */
322
+ private pullTimer: ReturnType<typeof setInterval> | null = null;
323
+ /** True while pushQueue sends the queue: the first operation can be on its way */
324
+ private pushing = false;
325
+ /** The timer that sends the queue of the write-through mode after a failure */
326
+ private drainTimer: ReturnType<typeof setTimeout> | null = null;
327
+ /** The load of the queue. The writes wait for it. */
328
+ private readonly loaded: Promise<void>;
329
+ /** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
330
+ private readonly ready: Promise<void>;
331
+ /** The error of the load (of the queue store, or of the local copy): the storage refuses each operation */
332
+ private loadError: Error | undefined;
333
+ /** A mode switch that runs. The operations wait for it. */
334
+ private gate: Promise<void> = Promise.resolve();
335
+ /** The operations that run, and the switches that wait for them to end */
336
+ private active = 0;
337
+ private idleWaiters: Array<() => void> = [];
161
338
  private isOnline = true;
339
+ private closed = false;
162
340
  /** A counter of local writes for each id, so a sync does not restore an older remote value */
163
341
  private readonly writeVersions = new Map<string, number>();
164
342
  private clearVersion = 0;
165
343
  /** The running sync: syncs run one at a time */
166
344
  private syncChain: Promise<unknown> = Promise.resolve();
345
+ /** The remote version of the last delta pull */
346
+ private cursor: string | undefined;
347
+ /** True when the remote has `getChangesSince` but its server does not know `changes` */
348
+ private changesUnsupported = false;
349
+ /** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
350
+ private readonly bases = new Map<string, string | null>();
167
351
  private stats = {
168
352
  cacheHits: 0,
169
353
  cacheMisses: 0,
@@ -175,32 +359,135 @@ export class HybridStorage<T> implements CollectionStorage<T> {
175
359
  remote: CollectionStorage<T>,
176
360
  options: HybridStorageOptions = {}
177
361
  ) {
178
- this.local = new InMemoryStorage<T>();
362
+ this.local = (options.local as LocalCopyStorage<T> | undefined) ?? new InMemoryStorage<T>();
179
363
  this.remote = remote;
364
+ this.queueStore = options.queueStore ?? new MemoryQueueStore();
365
+ this.mode = checkMode(options.mode ?? options.writeStrategy ?? "write-through");
180
366
  this.options = {
181
- writeStrategy: options.writeStrategy ?? "write-through",
182
367
  conflictResolution: options.conflictResolution ?? "remote",
183
368
  ...(options.mergeFn !== undefined && { mergeFn: options.mergeFn }),
184
369
  keyOf: options.keyOf ?? defaultKeyOf,
185
- syncInterval: options.syncInterval ?? 5000,
370
+ ...(options.versionOf !== undefined && { versionOf: options.versionOf }),
371
+ syncInterval: optionalMs(options.syncInterval, "syncInterval") ?? 5000,
372
+ pullInterval: optionalMs(options.pullInterval, "pullInterval") ?? 0,
186
373
  syncOnInit: options.syncOnInit ?? true,
187
374
  maxOfflineOps: options.maxOfflineOps ?? 1000,
188
375
  offlineQueue: options.offlineQueue ?? true,
376
+ isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
189
377
  };
190
378
 
191
- // Initialize with remote data
192
- if (this.options.syncOnInit) {
193
- this.syncFromRemote().catch((err) => {
194
- console.error("Failed to sync on init:", err);
195
- });
379
+ // The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
380
+ this.loaded = this.loadQueue();
381
+ this.ready = this.loaded.then(() => this.initialSync());
382
+ this.updateTimers();
383
+ }
384
+
385
+ //
386
+ // ═══ Modes ═══
387
+ //
388
+
389
+ /** The mode of the storage now. */
390
+ getMode(): HybridMode {
391
+ return this.mode;
392
+ }
393
+
394
+ /**
395
+ * This method changes the mode while the storage operates (roadmap R28).
396
+ *
397
+ * - The switch waits for the operations that run. The operations that start later wait for
398
+ * the switch. Switches and syncs run one at a time.
399
+ * - A switch to a remote mode sends the queue first. A switch to `local-only` makes no remote
400
+ * call: the queue stays until the next remote mode.
401
+ * - A switch to `remote-only` drops the local copy, after the queue reached the remote.
402
+ * - A switch from `local-only` or `remote-only` to a mode with a local copy syncs the local
403
+ * copy. The reads wait for the sync.
404
+ * - When the flush or the sync fails, the mode does not change, and the switch throws
405
+ * `ModeSwitchError`.
406
+ */
407
+ async setMode(mode: HybridMode): Promise<void> {
408
+ const target = checkMode(mode);
409
+ const previous = this.gate;
410
+ let release!: () => void;
411
+ this.gate = new Promise<void>((resolve) => (release = resolve));
412
+ try {
413
+ await previous;
414
+ await this.loaded;
415
+ if (this.loadError) throw this.loadError;
416
+ await this.idle();
417
+ await this.serialize(() => this.switchTo(target));
418
+ } finally {
419
+ release();
196
420
  }
421
+ }
197
422
 
198
- // Start periodic sync for write-back mode
199
- if (
200
- this.options.writeStrategy === "write-back" &&
201
- this.options.syncInterval > 0
202
- ) {
203
- this.startPeriodicSync();
423
+ private async switchTo(target: HybridMode): Promise<void> {
424
+ const from = this.mode;
425
+ if (target === from) return;
426
+ try {
427
+ if (target !== "local-only") {
428
+ // The queue reaches the remote before the mode changes
429
+ await this.pushQueue();
430
+ await this.ensureStoreClean();
431
+ }
432
+ if (target === "remote-only") {
433
+ await this.dropLocal();
434
+ } else if (from === "remote-only" || from === "local-only") {
435
+ if (from === "remote-only") await this.rebuildLocal();
436
+ await this.pullRemote(from === "remote-only");
437
+ }
438
+ } catch (error) {
439
+ throw new ModeSwitchError(from, target, error);
440
+ }
441
+ this.mode = target;
442
+ this.updateTimers();
443
+ }
444
+
445
+ /** This method drops the local copy. A read in flight does not fill it again. */
446
+ private async dropLocal(): Promise<void> {
447
+ this.clearVersion++;
448
+ this.cursor = undefined;
449
+ await this.local.clear();
450
+ }
451
+
452
+ /** This method makes the local copy from the queue only, before a full sync. */
453
+ private async rebuildLocal(): Promise<void> {
454
+ await this.dropLocal();
455
+ for (const op of this.offlineOps) await this.applyToLocal(op);
456
+ }
457
+
458
+ /** This method starts and stops the timers for the mode. */
459
+ private updateTimers(): void {
460
+ if (this.closed) return;
461
+ const push = this.mode === "write-back" && this.options.syncInterval > 0;
462
+ if (push && !this.syncTimer) {
463
+ this.syncTimer = setInterval(() => {
464
+ this.syncToRemote().catch((err) => {
465
+ console.error("Periodic sync failed:", err);
466
+ });
467
+ }, this.options.syncInterval);
468
+ // The timer must not keep the process alive (deep dive DATA-9)
469
+ (this.syncTimer as { unref?: () => void }).unref?.();
470
+ } else if (!push && this.syncTimer) {
471
+ clearInterval(this.syncTimer);
472
+ this.syncTimer = null;
473
+ }
474
+
475
+ const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
476
+ if (pull && !this.pullTimer) {
477
+ this.pullTimer = setInterval(() => {
478
+ this.syncFromRemote().catch((err) => {
479
+ console.error("Periodic pull failed:", err);
480
+ });
481
+ }, this.options.pullInterval);
482
+ (this.pullTimer as { unref?: () => void }).unref?.();
483
+ } else if (!pull && this.pullTimer) {
484
+ clearInterval(this.pullTimer);
485
+ this.pullTimer = null;
486
+ }
487
+
488
+ if (this.mode !== "write-through" && this.drainTimer) {
489
+ clearTimeout(this.drainTimer);
490
+ this.drainTimer = null;
204
491
  }
205
492
  }
206
493
 
@@ -210,51 +497,80 @@ export class HybridStorage<T> implements CollectionStorage<T> {
210
497
 
211
498
  /**
212
499
  * The local item, or else the remote item (which the cache then keeps). When the remote
213
- * cannot answer, the storage is offline and the result is undefined.
500
+ * cannot answer, the storage is offline and the result is undefined. In `local-only` mode, a
501
+ * miss is undefined. In `remote-only` mode, the remote answers, and its error passes through.
214
502
  */
215
503
  async get(id: string): Promise<T | undefined> {
216
- // Try local cache first
217
- const cached = await this.local.get(id);
218
- if (cached !== undefined) {
219
- this.stats.cacheHits++;
220
- return cached;
221
- }
504
+ return this.run(true, async () => {
505
+ if (this.mode === "remote-only") return this.remoteRead(() => this.remote.get(id));
506
+ // Try local cache first
507
+ const cached = await this.local.get(id);
508
+ if (cached !== undefined) {
509
+ this.stats.cacheHits++;
510
+ return cached;
511
+ }
222
512
 
223
- this.stats.cacheMisses++;
224
- // A pending local delete means "no item": do not read it back from the remote
225
- if (this.hasPendingOp(id)) {
226
- return undefined;
227
- }
228
- const version = this.versionOf(id);
229
- try {
230
- const remote = await this.remote.get(id);
231
- this.isOnline = true;
232
- if (remote !== undefined && this.versionOf(id) === version) {
233
- await this.local.set(id, remote);
513
+ this.stats.cacheMisses++;
514
+ // A pending local delete means "no item": do not read it back from the remote
515
+ if (this.mode === "local-only" || this.hasPendingOp(id)) {
516
+ return undefined;
234
517
  }
235
- return remote;
236
- } catch {
237
- this.isOnline = false;
238
- return undefined;
239
- }
518
+ const version = this.writeKey(id);
519
+ try {
520
+ const remote = await this.remote.get(id);
521
+ this.isOnline = true;
522
+ if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
523
+ if (remote !== undefined) await this.local.set(id, remote);
524
+ this.noteBase(id, remote);
525
+ }
526
+ return remote;
527
+ } catch {
528
+ this.isOnline = false;
529
+ return undefined;
530
+ }
531
+ });
240
532
  }
241
533
 
242
534
  async getAll(): Promise<T[]> {
243
- // Return local cache
244
- return this.local.getAll();
535
+ return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.getAll()) : this.local.getAll()));
536
+ }
537
+
538
+ /** Every item with its id. */
539
+ async getEntries(): Promise<Array<[string, T]>> {
540
+ return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remoteEntries()) : this.local.getEntries()));
245
541
  }
246
542
 
247
543
  async find(predicate: (item: T) => boolean): Promise<T[]> {
248
- // Search local cache
249
- return this.local.find(predicate);
544
+ return this.run(true, () =>
545
+ this.mode === "remote-only" ? this.remoteRead(() => this.remote.find(predicate)) : this.local.find(predicate),
546
+ );
250
547
  }
251
548
 
252
549
  async has(id: string): Promise<boolean> {
253
- return this.local.has(id);
550
+ return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
254
551
  }
255
552
 
256
553
  async size(): Promise<number> {
257
- return this.local.size();
554
+ return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.size()) : this.local.size()));
555
+ }
556
+
557
+ async getBatch(ids: string[]): Promise<Map<string, T>> {
558
+ return this.run(true, () =>
559
+ this.mode === "remote-only" ? this.remoteRead(() => this.remote.getBatch(ids)) : this.local.getBatch(ids),
560
+ );
561
+ }
562
+
563
+ /** A read of the remote-only mode. A queue that a restart loaded reaches the remote first. */
564
+ private async remoteRead<R>(read: () => Promise<R>): Promise<R> {
565
+ if (this.offlineOps.length > 0) await this.serialize(() => this.pushQueue());
566
+ try {
567
+ const result = await read();
568
+ this.isOnline = true;
569
+ return result;
570
+ } catch (error) {
571
+ if (!(error instanceof MissingKeysError)) this.isOnline = false;
572
+ throw error;
573
+ }
258
574
  }
259
575
 
260
576
  //
@@ -262,68 +578,24 @@ export class HybridStorage<T> implements CollectionStorage<T> {
262
578
  //
263
579
 
264
580
  async set(id: string, value: T): Promise<void> {
265
- this.bump(id);
266
- // Always write to local immediately
267
- await this.local.set(id, value);
268
-
269
- if (this.options.writeStrategy === "write-through") {
270
- // Write-through: sync to remote immediately
271
- try {
272
- await this.remote.set(id, value);
273
- } catch (error) {
274
- this.handleRemoteFailure("set", { id, value });
275
- throw error;
276
- }
277
- } else {
278
- // Write-back: queue for later sync
279
- this.queueOperation({
280
- type: "set",
281
- data: { id, value },
282
- timestamp: Date.now(),
283
- });
284
- }
581
+ await this.write("set", { id, value }, [id], async () => {
582
+ this.bump(id);
583
+ await this.local.set(id, value);
584
+ }, () => this.remote.set(id, value));
285
585
  }
286
586
 
287
587
  async delete(id: string): Promise<boolean> {
288
- this.bump(id);
289
- const existed = await this.local.delete(id);
290
-
291
- if (this.options.writeStrategy === "write-through") {
292
- try {
293
- await this.remote.delete(id);
294
- } catch (error) {
295
- this.handleRemoteFailure("delete", { id });
296
- throw error;
297
- }
298
- } else {
299
- this.queueOperation({
300
- type: "delete",
301
- data: { id },
302
- timestamp: Date.now(),
303
- });
304
- }
305
-
306
- return existed;
588
+ return this.write("delete", { id }, [id], async () => {
589
+ this.bump(id);
590
+ return this.local.delete(id);
591
+ }, () => this.remote.delete(id));
307
592
  }
308
593
 
309
594
  async clear(): Promise<void> {
310
- this.clearVersion++;
311
- await this.local.clear();
312
-
313
- if (this.options.writeStrategy === "write-through") {
314
- try {
315
- await this.remote.clear();
316
- } catch (error) {
317
- this.handleRemoteFailure("clear", {});
318
- throw error;
319
- }
320
- } else {
321
- this.queueOperation({
322
- type: "clear",
323
- data: {},
324
- timestamp: Date.now(),
325
- });
326
- }
595
+ await this.write("clear", {}, "*", async () => {
596
+ this.clearVersion++;
597
+ await this.local.clear();
598
+ }, () => this.remote.clear());
327
599
  }
328
600
 
329
601
  //
@@ -331,49 +603,134 @@ export class HybridStorage<T> implements CollectionStorage<T> {
331
603
  //
332
604
 
333
605
  async setBatch(items: Array<[string, T]>): Promise<void> {
334
- for (const [id] of items) this.bump(id);
335
- await this.local.setBatch(items);
336
-
337
- if (this.options.writeStrategy === "write-through") {
338
- try {
339
- await this.remote.setBatch(items);
340
- } catch (error) {
341
- this.handleRemoteFailure("setBatch", { items });
342
- throw error;
343
- }
344
- } else {
345
- this.queueOperation({
346
- type: "setBatch",
347
- data: { items },
348
- timestamp: Date.now(),
349
- });
350
- }
606
+ await this.write("setBatch", { items }, items.map(([id]) => id), async () => {
607
+ for (const [id] of items) this.bump(id);
608
+ await this.local.setBatch(items);
609
+ }, () => this.remote.setBatch(items));
351
610
  }
352
611
 
353
612
  async deleteBatch(ids: string[]): Promise<number> {
354
- for (const id of ids) this.bump(id);
355
- const deleted = await this.local.deleteBatch(ids);
613
+ return this.write("deleteBatch", { ids }, ids, async () => {
614
+ for (const id of ids) this.bump(id);
615
+ return this.local.deleteBatch(ids);
616
+ }, () => this.remote.deleteBatch(ids));
617
+ }
618
+
619
+ /**
620
+ * One write, in the mode of the storage. The result is the result of the local copy, or of the
621
+ * remote in `remote-only` mode.
622
+ *
623
+ * - `remote-only`: the remote only. Its error passes through.
624
+ * - `write-back` and `local-only`: the local copy and the queue. A full queue refuses the write
625
+ * before the local change. The queue store keeps the operation before the write ends.
626
+ * - `write-through`, the remote refuses the write: the local change is undone, and the error
627
+ * of the remote passes through.
628
+ * - `write-through`, the remote is not available: with the queue, the write waits in it, a timer
629
+ * sends it later, and the write throws `PendingWriteError`. Without the queue, or with a full
630
+ * queue, the local change is undone.
631
+ *
632
+ * Before, a failed write-through stayed in the local cache and in the queue, no timer sent the
633
+ * queue, a second try queued the operation again, and a full queue dropped the oldest operation
634
+ * without a word (roadmap R2).
635
+ */
636
+ private write<R>(
637
+ type: QueuedOperation["type"],
638
+ data: unknown,
639
+ ids: string[] | "*",
640
+ applyLocal: () => Promise<R>,
641
+ applyRemote: () => Promise<R>,
642
+ ): Promise<R> {
643
+ return this.run(false, async () => {
644
+ const op: QueuedOperation = { type, data, timestamp: Date.now() };
645
+
646
+ if (this.mode === "remote-only") {
647
+ const send = async (): Promise<R> => {
648
+ await this.ensureStoreClean();
649
+ const result = await applyRemote();
650
+ this.noteRemoteWrite(op);
651
+ return result;
652
+ };
653
+ try {
654
+ const result = this.offlineOps.length > 0
655
+ ? await this.serialize(async () => {
656
+ await this.pushQueue();
657
+ return send();
658
+ })
659
+ : await send();
660
+ this.isOnline = true;
661
+ return result;
662
+ } catch (error) {
663
+ if (!(error instanceof QueueStoreError) && this.options.isUnavailable(error)) this.isOnline = false;
664
+ throw error;
665
+ }
666
+ }
667
+
668
+ if (this.mode === "write-back" || this.mode === "local-only") {
669
+ this.ensureQueueRoom(op);
670
+ this.attachBase(op);
671
+ const undo = await this.snapshot(ids);
672
+ const result = await applyLocal();
673
+ await this.enqueue(op, undo);
674
+ return result;
675
+ }
356
676
 
357
- if (this.options.writeStrategy === "write-through") {
677
+ const undo = await this.snapshot(ids);
678
+ const result = await applyLocal();
358
679
  try {
359
- await this.remote.deleteBatch(ids);
680
+ if (this.offlineOps.length > 0) {
681
+ // The queued operations are older: they reach the remote first, or the older value of a
682
+ // queued write replaces this one later. A failed send keeps this write behind them.
683
+ await this.serialize(async () => {
684
+ await this.pushQueue();
685
+ await this.ensureStoreClean();
686
+ await applyRemote();
687
+ });
688
+ } else {
689
+ await this.ensureStoreClean();
690
+ await applyRemote();
691
+ }
692
+ this.isOnline = true;
693
+ this.noteRemoteWrite(op);
360
694
  } catch (error) {
361
- this.handleRemoteFailure("deleteBatch", { ids });
695
+ if (
696
+ !(error instanceof QueueStoreError) &&
697
+ this.options.offlineQueue &&
698
+ this.options.isUnavailable(error) &&
699
+ this.hasQueueRoom(op)
700
+ ) {
701
+ this.isOnline = false;
702
+ console.error(`Remote ${type} failed: the operation waits in the queue`);
703
+ this.attachBase(op);
704
+ await this.enqueue(op, undo);
705
+ this.scheduleDrain();
706
+ throw new PendingWriteError(type, error);
707
+ }
708
+ await undo();
362
709
  throw error;
363
710
  }
364
- } else {
365
- this.queueOperation({
366
- type: "deleteBatch",
367
- data: { ids },
368
- timestamp: Date.now(),
369
- });
370
- }
371
-
372
- return deleted;
711
+ return result;
712
+ });
373
713
  }
374
714
 
375
- getBatch(ids: string[]): Promise<Map<string, T>> {
376
- return this.local.getBatch(ids);
715
+ /**
716
+ * The local values of the ids before a write, and a function that puts them back. It puts back
717
+ * only the ids that no later write changed.
718
+ */
719
+ private async snapshot(ids: string[] | "*"): Promise<() => Promise<void>> {
720
+ const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
721
+ const touched = ids === "*" ? [...before.keys()] : ids;
722
+ // The versions after the write: this write adds one to each id (clear adds to clearVersion)
723
+ const versions = new Map(touched.map((id) => [id, (this.writeVersions.get(id) ?? 0) + (ids === "*" ? 0 : 1)]));
724
+ const clearVersion = this.clearVersion + (ids === "*" ? 1 : 0);
725
+ return async () => {
726
+ if (this.clearVersion !== clearVersion) return;
727
+ for (const id of touched) {
728
+ if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
729
+ const value = before.get(id);
730
+ if (value === undefined) await this.local.delete(id);
731
+ else await this.local.set(id, value);
732
+ }
733
+ };
377
734
  }
378
735
 
379
736
  //
@@ -381,27 +738,47 @@ export class HybridStorage<T> implements CollectionStorage<T> {
381
738
  //
382
739
 
383
740
  /**
384
- * Sync all data from remote to local.
385
- * Handles conflicts according to conflict resolution strategy.
741
+ * Sync from the remote to the local copy, in the modes `write-through` and `write-back`. The
742
+ * other modes have no local copy to sync (`remote-only`) or make no remote call (`local-only`):
743
+ * the call does nothing.
386
744
  *
387
- * - Each remote item goes into the cache under `keyOf(item)`.
745
+ * - The sync asks for the changes since the version of the last sync when the remote has
746
+ * `getChangesSince`. Else it reads all entries.
747
+ * - Each remote item goes into the cache under its id.
388
748
  * - A local item that the remote does not have is removed, unless a queued local operation
389
- * changes it (the remote deleted it).
749
+ * changes it.
390
750
  * - An id that a local write changed during the sync keeps the local value: the remote value
391
751
  * is older.
392
752
  * - Syncs run one at a time.
393
753
  */
394
754
  syncFromRemote(): Promise<void> {
395
- return this.serialize(() => this.pullRemote());
755
+ return this.serialize(async () => {
756
+ await this.loaded;
757
+ if (this.loadError) throw this.loadError;
758
+ if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
759
+ });
396
760
  }
397
761
 
398
762
  /**
399
763
  * Sync all pending offline operations to remote.
400
764
  * Processes queued operations in order. When one fails, it and the later operations stay in
401
- * the queue: the operations that reached the remote are not sent again.
765
+ * the queue: the operations that reached the remote are not sent again. In `local-only` mode,
766
+ * the call does nothing.
402
767
  */
403
768
  syncToRemote(): Promise<void> {
404
- return this.serialize(() => this.pushQueue());
769
+ return this.serialize(async () => {
770
+ await this.loaded;
771
+ if (this.loadError) throw this.loadError;
772
+ if (this.mode !== "local-only") await this.pushQueue();
773
+ });
774
+ }
775
+
776
+ /**
777
+ * This method sends the queue to the remote (roadmap R28). `ProcedureServer.useStorage` uses it. In
778
+ * `local-only` mode, it sends nothing: the queue waits for a remote mode.
779
+ */
780
+ flush(): Promise<void> {
781
+ return this.syncToRemote();
405
782
  }
406
783
 
407
784
  /**
@@ -416,24 +793,40 @@ export class HybridStorage<T> implements CollectionStorage<T> {
416
793
  // ═══ Lifecycle & Metadata ═══
417
794
  //
418
795
 
796
+ /**
797
+ * This method stops the timers, sends the queue (not in `local-only` mode), and closes the remote and the
798
+ * local copy. The queue store keeps the operations that the remote did not take. A durable local
799
+ * copy keeps its items.
800
+ */
419
801
  async close(): Promise<void> {
420
- // Stop periodic sync
421
- if (this.syncTimer) {
422
- clearInterval(this.syncTimer);
423
- this.syncTimer = null;
802
+ this.closed = true;
803
+ for (const timer of [this.syncTimer, this.pullTimer]) {
804
+ if (timer) clearInterval(timer);
805
+ }
806
+ this.syncTimer = null;
807
+ this.pullTimer = null;
808
+ if (this.drainTimer) {
809
+ clearTimeout(this.drainTimer);
810
+ this.drainTimer = null;
424
811
  }
425
812
 
813
+ await this.loaded;
426
814
  // Sync pending operations before closing
427
- if (this.offlineOps.length > 0) {
815
+ if (this.offlineOps.length > 0 && this.mode !== "local-only") {
428
816
  try {
429
817
  await this.syncToRemote();
430
818
  } catch (error) {
431
819
  console.error("Failed to sync before close:", error);
432
820
  }
433
821
  }
822
+ await this.storeChain;
434
823
 
435
824
  // Close remote storage (an ApiStorage leaves the caller's client open)
436
- await this.remote.close();
825
+ try {
826
+ await this.remote.close();
827
+ } finally {
828
+ await this.local.close();
829
+ }
437
830
  }
438
831
 
439
832
  async getMetadata(): Promise<StorageMetadata> {
@@ -452,7 +845,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
452
845
  conflicts: this.stats.conflicts,
453
846
  pendingOps: this.offlineOps.length,
454
847
  isOnline: this.isOnline,
455
- writeStrategy: this.options.writeStrategy,
848
+ mode: this.mode,
849
+ writeStrategy: this.mode,
456
850
  conflictResolution: this.options.conflictResolution,
457
851
  },
458
852
  };
@@ -462,93 +856,465 @@ export class HybridStorage<T> implements CollectionStorage<T> {
462
856
  // ═══ Internal Helpers ═══
463
857
  //
464
858
 
465
- private async pullRemote(): Promise<void> {
859
+ /**
860
+ * This method starts a public operation: after the load of the queue (a write) or the first sync (a read),
861
+ * and never during a mode switch.
862
+ */
863
+ private async run<R>(read: boolean, work: () => Promise<R>): Promise<R> {
864
+ await (read ? this.ready : this.loaded);
865
+ for (;;) {
866
+ const gate = this.gate;
867
+ await gate;
868
+ // No new switch started while this operation waited
869
+ if (this.gate === gate) break;
870
+ }
871
+ if (this.loadError) throw this.loadError;
872
+ this.active++;
873
+ try {
874
+ return await work();
875
+ } finally {
876
+ if (--this.active === 0) {
877
+ for (const resolve of this.idleWaiters.splice(0)) resolve();
878
+ }
879
+ }
880
+ }
881
+
882
+ /** A promise that resolves when no operation runs. */
883
+ private idle(): Promise<void> {
884
+ if (this.active === 0) return Promise.resolve();
885
+ return new Promise<void>((resolve) => this.idleWaiters.push(resolve));
886
+ }
887
+
888
+ /** This method loads the queue, and puts its writes in the local copy. */
889
+ private async loadQueue(): Promise<void> {
890
+ let ops: QueuedOperation[];
891
+ try {
892
+ ops = await this.queueStore.load();
893
+ } catch (error) {
894
+ this.loadError = new QueueStoreError("load", error);
895
+ console.error("The queue store could not load the queue: the storage refuses each operation", error);
896
+ return;
897
+ }
898
+ this.offlineOps = ops.slice();
899
+ try {
900
+ for (const op of this.offlineOps) {
901
+ for (const [id, version] of op.base ?? []) {
902
+ if (!this.bases.has(id)) this.bases.set(id, version);
903
+ }
904
+ // A durable local copy can hold the operation already: each operation gives the same
905
+ // result two times
906
+ await this.applyToLocal(op);
907
+ }
908
+ } catch (error) {
909
+ this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
910
+ console.error(this.loadError.message, error);
911
+ }
912
+ }
913
+
914
+ /** The first sync: send the loaded queue, then read the remote. */
915
+ private async initialSync(): Promise<void> {
916
+ if (this.loadError) return;
917
+ if (this.options.syncOnInit && this.mode !== "local-only") {
918
+ if (this.offlineOps.length > 0) {
919
+ await this.syncToRemote().catch((err) => {
920
+ console.error("Failed to send the queue on init:", err);
921
+ });
922
+ }
923
+ if (this.mode === "write-through" || this.mode === "write-back") {
924
+ await this.syncFromRemote().catch((err) => {
925
+ console.error("Failed to sync on init:", err);
926
+ });
927
+ }
928
+ }
929
+ if (this.offlineOps.length > 0) this.scheduleDrain();
930
+ }
931
+
932
+ /** This method applies a queued operation to the local copy. */
933
+ private async applyToLocal(op: QueuedOperation): Promise<void> {
934
+ const data = op.data as { id: string; value: T; ids: string[]; items: Array<[string, T]> };
935
+ switch (op.type) {
936
+ case "set":
937
+ await this.local.set(data.id, data.value);
938
+ break;
939
+ case "delete":
940
+ await this.local.delete(data.id);
941
+ break;
942
+ case "setBatch":
943
+ await this.local.setBatch(data.items);
944
+ break;
945
+ case "deleteBatch":
946
+ await this.local.deleteBatch(data.ids);
947
+ break;
948
+ case "clear":
949
+ await this.local.clear();
950
+ break;
951
+ }
952
+ }
953
+
954
+ /**
955
+ * One pull. `full`: ask for all items (after the local copy was dropped).
956
+ *
957
+ * - The versions of the local writes at the start: a write during the pull keeps its value.
958
+ * - The cursor moves only after a pull without conflicts, so a conflict of the "error" policy
959
+ * comes again at the next pull.
960
+ */
961
+ private async pullRemote(full: boolean): Promise<void> {
466
962
  const versions = new Map(this.writeVersions);
467
963
  const clearVersion = this.clearVersion;
468
964
  // True when a local write changed the id after the sync started
469
965
  const changedLocally = (id: string): boolean =>
470
966
  this.clearVersion !== clearVersion || (this.writeVersions.get(id) ?? 0) !== (versions.get(id) ?? 0);
471
967
 
472
- let remoteData: T[];
968
+ let changes: PulledChanges<T>;
473
969
  try {
474
- remoteData = await this.remote.getAll();
970
+ changes = await this.fetchChanges(full);
475
971
  } catch (error) {
476
- this.isOnline = false;
972
+ if (!(error instanceof MissingKeysError)) this.isOnline = false;
477
973
  throw error;
478
974
  }
479
975
 
976
+ const conflicts: string[] = [];
977
+ let queueChanged = false;
480
978
  const remoteIds = new Set<string>();
481
- for (const item of remoteData) {
482
- const id = this.options.keyOf(item);
483
- if (!id) {
484
- console.warn("Item has no key, skipping:", item);
485
- continue;
486
- }
979
+ for (const [id, item] of changes.changed) {
487
980
  remoteIds.add(id);
488
- if (changedLocally(id) || this.hasPendingOp(id)) {
489
- continue;
981
+ if (await this.pullItem(id, item, changedLocally, conflicts)) queueChanged = true;
982
+ }
983
+ // The remote deleted these items
984
+ const gone = changes.full
985
+ ? (await this.local.getEntries()).map(([id]) => id).filter((id) => !remoteIds.has(id))
986
+ : changes.deleted;
987
+ for (const id of gone) {
988
+ if (await this.pullDelete(id, changedLocally, conflicts)) queueChanged = true;
989
+ }
990
+
991
+ if (queueChanged) {
992
+ this.storeDirty = true;
993
+ await this.persistAll().catch((error) => {
994
+ console.error("The queue store could not keep the queue after the sync:", error);
995
+ });
996
+ }
997
+ this.stats.syncCount++;
998
+ this.isOnline = true;
999
+ if (conflicts.length > 0) throw new ConflictError(conflicts);
1000
+ if (changes.version !== undefined) this.cursor = changes.version;
1001
+ }
1002
+
1003
+ /**
1004
+ * The remote changes: since the cursor from `getChangesSince` when the remote has it, or else
1005
+ * all entries.
1006
+ */
1007
+ private async fetchChanges(full: boolean): Promise<PulledChanges<T>> {
1008
+ if (this.remote.getChangesSince && !this.changesUnsupported) {
1009
+ try {
1010
+ return await this.remote.getChangesSince(full ? undefined : this.cursor);
1011
+ } catch (error) {
1012
+ if (!isNotFound(error)) throw error;
1013
+ // A server without the procedure `changes`: read all entries from now on
1014
+ this.changesUnsupported = true;
1015
+ }
1016
+ }
1017
+ return { full: true, changed: await this.remoteEntries(), deleted: [] };
1018
+ }
1019
+
1020
+ /** This method applies one remote item. It gives true when the queue changed. */
1021
+ private async pullItem(
1022
+ id: string,
1023
+ item: T,
1024
+ changedLocally: (id: string) => boolean,
1025
+ conflicts: string[],
1026
+ ): Promise<boolean> {
1027
+ if (changedLocally(id)) return false;
1028
+ const localItem = await this.local.get(id);
1029
+ if (changedLocally(id)) return false;
1030
+ const remoteVersion = this.versionOfItem(item);
1031
+
1032
+ if (this.hasPendingOp(id)) {
1033
+ // Without versions, the queued write wins, as before
1034
+ if (remoteVersion === undefined) return false;
1035
+ // The remote did not change since the last sync, or the storage never saw a remote version
1036
+ // of the id: the queued write wins
1037
+ const base = this.bases.get(id);
1038
+ if (base === undefined || base === remoteVersion) return false;
1039
+ if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
1040
+ this.bases.set(id, remoteVersion);
1041
+ return false;
490
1042
  }
1043
+ return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
1044
+ }
491
1045
 
492
- const localItem = await this.local.get(id);
493
- if (localItem === undefined) {
494
- // No local version - just store remote
495
- await this.local.set(id, item);
496
- } else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
497
- // Conflict - resolve according to strategy
498
- const resolved = this.resolveConflict(localItem, item);
499
- await this.local.set(id, resolved);
1046
+ const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
1047
+ if (localItem === undefined) {
1048
+ await this.local.set(id, item);
1049
+ } else if (remoteVersion !== undefined && localVersion !== undefined) {
1050
+ // Only the remote changed: it is not a conflict
1051
+ if (localVersion !== remoteVersion) await this.local.set(id, item);
1052
+ } else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
1053
+ // Conflict - resolve according to strategy
1054
+ if (this.options.conflictResolution === "error") {
500
1055
  this.stats.conflicts++;
1056
+ conflicts.push(id);
1057
+ return false;
501
1058
  }
1059
+ const resolved = this.resolveConflict(localItem, item);
1060
+ await this.local.set(id, resolved);
1061
+ this.stats.conflicts++;
502
1062
  }
1063
+ if (remoteVersion !== undefined) this.bases.set(id, remoteVersion);
1064
+ return false;
1065
+ }
503
1066
 
504
- // The remote deleted these items
505
- for (const id of await this.localIds()) {
506
- if (!remoteIds.has(id) && !changedLocally(id) && !this.hasPendingOp(id)) {
1067
+ /** This method applies one remote delete. It gives true when the queue changed. */
1068
+ private async pullDelete(id: string, changedLocally: (id: string) => boolean, conflicts: string[]): Promise<boolean> {
1069
+ if (changedLocally(id)) return false;
1070
+ if (!this.hasPendingOp(id)) {
1071
+ await this.local.delete(id);
1072
+ if (this.options.versionOf) this.bases.set(id, null);
1073
+ return false;
1074
+ }
1075
+ // Without versions, the queued write wins, as before. With versions, the remote deleted the
1076
+ // item only if the last sync saw a version of it.
1077
+ const base = this.bases.get(id);
1078
+ if (!this.options.versionOf || base === undefined || base === null) return false;
1079
+ const localItem = await this.local.get(id);
1080
+ if (changedLocally(id)) return false;
1081
+ if (localItem === undefined) {
1082
+ // Both sides deleted the item
1083
+ this.bases.set(id, null);
1084
+ return false;
1085
+ }
1086
+ this.stats.conflicts++;
1087
+ switch (this.options.conflictResolution) {
1088
+ case "remote":
1089
+ if (!this.dropQueued(id)) break;
507
1090
  await this.local.delete(id);
1091
+ this.bases.set(id, null);
1092
+ return true;
1093
+ case "error":
1094
+ conflicts.push(id);
1095
+ return false;
1096
+ default:
1097
+ // "local", and "merge" with one deleted side: the queued write wins
1098
+ break;
1099
+ }
1100
+ this.bases.set(id, null);
1101
+ return false;
1102
+ }
1103
+
1104
+ /**
1105
+ * A queued local write of an id whose remote version changed: a conflict, and the policy
1106
+ * decides. True when the queue changed.
1107
+ */
1108
+ private async resolvePending(
1109
+ id: string,
1110
+ localItem: T | undefined,
1111
+ remoteItem: T,
1112
+ remoteVersion: string,
1113
+ conflicts: string[],
1114
+ ): Promise<boolean> {
1115
+ this.stats.conflicts++;
1116
+ switch (this.options.conflictResolution) {
1117
+ case "remote":
1118
+ // The remote value replaces the queued write. A queued clear cannot lose one id.
1119
+ if (!this.dropQueued(id)) break;
1120
+ await this.local.set(id, remoteItem);
1121
+ this.bases.set(id, remoteVersion);
1122
+ return true;
1123
+ case "merge": {
1124
+ if (localItem === undefined) break;
1125
+ const merged = this.resolveConflict(localItem, remoteItem);
1126
+ if (!this.dropQueued(id)) break;
1127
+ await this.local.set(id, merged);
1128
+ this.bases.set(id, remoteVersion);
1129
+ this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
1130
+ return true;
508
1131
  }
1132
+ case "error":
1133
+ conflicts.push(id);
1134
+ return false;
1135
+ default:
1136
+ break;
509
1137
  }
1138
+ // "local": the queued write wins, and it replaces this remote version
1139
+ this.bases.set(id, remoteVersion);
1140
+ return false;
1141
+ }
510
1142
 
511
- this.stats.syncCount++;
512
- this.isOnline = true;
1143
+ /**
1144
+ * This method removes an id from the queued operations. It gives false when a queued clear touches it: nothing
1145
+ * changes then.
1146
+ */
1147
+ private dropQueued(id: string): boolean {
1148
+ if (this.offlineOps.some((op) => op.type === "clear")) return false;
1149
+ const next: QueuedOperation[] = [];
1150
+ for (const op of this.offlineOps) {
1151
+ const kept = withoutId(op, id);
1152
+ if (kept) next.push(kept);
1153
+ }
1154
+ this.offlineOps = next;
1155
+ return true;
513
1156
  }
514
1157
 
1158
+ /**
1159
+ * The remote items with their ids: from `getEntries()` when the remote has it, or else from
1160
+ * `getAll()` and `keyOf`. An item without a key stops the sync before any change. Before, the
1161
+ * sync skipped such items and then deleted each local item that the remote list did not have,
1162
+ * so the remote list without ids emptied the cache (roadmap R2).
1163
+ */
1164
+ private async remoteEntries(): Promise<Array<[string, T]>> {
1165
+ if (this.remote.getEntries) return this.remote.getEntries();
1166
+ const items = await this.remote.getAll();
1167
+ const entries: Array<[string, T]> = [];
1168
+ let missing = 0;
1169
+ for (const item of items) {
1170
+ const id = this.options.keyOf(item);
1171
+ if (id) entries.push([id, item]);
1172
+ else missing++;
1173
+ }
1174
+ if (missing > 0) throw new MissingKeysError(missing);
1175
+ return entries;
1176
+ }
1177
+
1178
+ /**
1179
+ * This method sends the queue. The operations that reached the remote leave the queue. The queue store
1180
+ * gets the change at the end.
1181
+ */
515
1182
  private async pushQueue(): Promise<void> {
516
- while (this.offlineOps.length > 0) {
517
- const op = this.offlineOps[0]!;
518
- try {
519
- await this.executeOperation(op);
520
- } catch (error) {
521
- // This operation and the later ones stay queued
522
- this.isOnline = false;
523
- throw error;
1183
+ this.pushing = true;
1184
+ let sent = 0;
1185
+ try {
1186
+ while (this.offlineOps.length > 0) {
1187
+ const op = this.offlineOps[0]!;
1188
+ if (this.unpersisted.has(op)) {
1189
+ // The write of this operation waits for the queue store
1190
+ await this.storeChain;
1191
+ continue;
1192
+ }
1193
+ try {
1194
+ await this.executeOperation(op);
1195
+ } catch (error) {
1196
+ // This operation and the later ones stay queued
1197
+ this.isOnline = false;
1198
+ throw error;
1199
+ }
1200
+ this.noteRemoteWrite(op);
1201
+ // Remove the operation only after it reached the remote
1202
+ if (this.offlineOps[0] === op) {
1203
+ this.offlineOps.shift();
1204
+ sent++;
1205
+ this.storeDirty = true;
1206
+ }
524
1207
  }
525
- // Remove the operation only after it reached the remote
526
- if (this.offlineOps[0] === op) {
527
- this.offlineOps.shift();
1208
+ this.isOnline = true;
1209
+ } finally {
1210
+ this.pushing = false;
1211
+ if (sent > 0) {
1212
+ await this.persistAll().catch((error) => {
1213
+ // The store keeps sent operations. A later direct write repairs it first.
1214
+ console.error("The queue store could not remove the sent operations:", error);
1215
+ });
528
1216
  }
529
1217
  }
530
- this.isOnline = true;
531
1218
  }
532
1219
 
533
1220
  /** Run syncs one at a time. */
534
- private serialize(run: () => Promise<void>): Promise<void> {
1221
+ private serialize<R>(run: () => Promise<R>): Promise<R> {
535
1222
  const next = this.syncChain.then(run, run);
536
1223
  this.syncChain = next.catch(() => undefined);
537
1224
  return next;
538
1225
  }
539
1226
 
540
- private async localIds(): Promise<string[]> {
541
- return [...this.local.keys()];
542
- }
543
-
544
1227
  private bump(id: string): void {
545
1228
  this.writeVersions.set(id, (this.writeVersions.get(id) ?? 0) + 1);
546
1229
  }
547
1230
 
548
- private versionOf(id: string): string {
1231
+ /** The count of local writes of an id: it changes with each write and each clear. */
1232
+ private writeKey(id: string): string {
549
1233
  return `${this.clearVersion}:${this.writeVersions.get(id) ?? 0}`;
550
1234
  }
551
1235
 
1236
+ private versionOfItem(item: T): string | undefined {
1237
+ return this.options.versionOf?.(item);
1238
+ }
1239
+
1240
+ /** This method records the remote version of an id that the storage read. */
1241
+ private noteBase(id: string, item: T | undefined): void {
1242
+ if (!this.options.versionOf) return;
1243
+ if (item === undefined) {
1244
+ this.bases.set(id, null);
1245
+ return;
1246
+ }
1247
+ const version = this.versionOfItem(item);
1248
+ if (version === undefined) this.bases.delete(id);
1249
+ else this.bases.set(id, version);
1250
+ }
1251
+
1252
+ /** This method records the remote versions after a write reached the remote. */
1253
+ private noteRemoteWrite(op: QueuedOperation): void {
1254
+ if (!this.options.versionOf) return;
1255
+ const data = op.data as { id: string; value: T; ids: string[]; items: Array<[string, T]> };
1256
+ switch (op.type) {
1257
+ case "set":
1258
+ this.noteBase(data.id, data.value);
1259
+ break;
1260
+ case "delete":
1261
+ this.bases.set(data.id, null);
1262
+ break;
1263
+ case "setBatch":
1264
+ for (const [id, value] of data.items) this.noteBase(id, value);
1265
+ break;
1266
+ case "deleteBatch":
1267
+ for (const id of data.ids) this.bases.set(id, null);
1268
+ break;
1269
+ case "clear":
1270
+ for (const id of this.bases.keys()) this.bases.set(id, null);
1271
+ break;
1272
+ }
1273
+ }
1274
+
1275
+ /**
1276
+ * This method puts in the operation the remote version that it changes, for each id: the version that an
1277
+ * older queued operation of the id writes, or else the known remote version. A storage that
1278
+ * loads the queue after a restart takes the version of the first operation of each id.
1279
+ */
1280
+ private attachBase(op: QueuedOperation): void {
1281
+ if (!this.options.versionOf) return;
1282
+ const base: Array<[string, string | null]> = [];
1283
+ for (const id of idsOf(op)) {
1284
+ if (id === "*") continue;
1285
+ const version = this.expectedBase(id);
1286
+ if (version !== undefined) base.push([id, version]);
1287
+ }
1288
+ if (base.length > 0) op.base = base;
1289
+ }
1290
+
1291
+ /** The remote version of an id after the queued operations, or undefined when it is not known. */
1292
+ private expectedBase(id: string): string | null | undefined {
1293
+ for (let index = this.offlineOps.length - 1; index >= 0; index--) {
1294
+ const op = this.offlineOps[index]!;
1295
+ const data = op.data as { id?: string; value?: T; ids?: string[]; items?: Array<[string, T]> };
1296
+ switch (op.type) {
1297
+ case "set":
1298
+ if (data.id === id) return this.versionOfItem(data.value as T);
1299
+ break;
1300
+ case "delete":
1301
+ if (data.id === id) return null;
1302
+ break;
1303
+ case "setBatch": {
1304
+ const item = (data.items ?? []).findLast(([itemId]) => itemId === id);
1305
+ if (item) return this.versionOfItem(item[1]);
1306
+ break;
1307
+ }
1308
+ case "deleteBatch":
1309
+ if ((data.ids ?? []).includes(id)) return null;
1310
+ break;
1311
+ case "clear":
1312
+ return null;
1313
+ }
1314
+ }
1315
+ return this.bases.get(id);
1316
+ }
1317
+
552
1318
  private hasPendingOp(id: string): boolean {
553
1319
  return this.offlineOps.some((op) => {
554
1320
  const ids = idsOf(op);
@@ -585,26 +1351,158 @@ export class HybridStorage<T> implements CollectionStorage<T> {
585
1351
  }
586
1352
  }
587
1353
 
1354
+ //
1355
+ // ═══ The queue and its store ═══
1356
+ //
1357
+
588
1358
  /**
589
- * Queue operation for later sync.
1359
+ * This method puts an operation in the queue, then in the queue store. The write ends only after the store
1360
+ * kept it. When the store fails, the operation leaves the queue, the local change is undone,
1361
+ * and the write throws `QueueStoreError`.
1362
+ *
1363
+ * A set or a delete of one id replaces the queued set or delete of that id, when no later
1364
+ * operation touches the id: a second try of a write does not queue it two times.
590
1365
  */
591
- private queueOperation(op: OfflineOperation): void {
592
- if (!this.options.offlineQueue) {
593
- return;
1366
+ private async enqueue(op: QueuedOperation, undo: () => Promise<void>): Promise<void> {
1367
+ const index = this.replaceableIndex(op);
1368
+ let rollback: () => void;
1369
+ if (index >= 0) {
1370
+ const previous = this.offlineOps[index]!;
1371
+ // The new operation changes the same remote version as the operation that it replaces
1372
+ if (previous.base) op.base = previous.base;
1373
+ else delete op.base;
1374
+ this.offlineOps[index] = op;
1375
+ rollback = () => {
1376
+ const at = this.offlineOps.indexOf(op);
1377
+ if (at >= 0) this.offlineOps[at] = previous;
1378
+ };
1379
+ } else {
1380
+ try {
1381
+ this.ensureQueueRoom(op);
1382
+ } catch (error) {
1383
+ await undo();
1384
+ throw error;
1385
+ }
1386
+ this.offlineOps.push(op);
1387
+ rollback = () => {
1388
+ const at = this.offlineOps.indexOf(op);
1389
+ if (at >= 0) this.offlineOps.splice(at, 1);
1390
+ };
1391
+ }
1392
+ this.unpersisted.add(op);
1393
+ try {
1394
+ await this.persist(op, index < 0, rollback);
1395
+ } catch (error) {
1396
+ await undo();
1397
+ throw error;
594
1398
  }
1399
+ }
1400
+
1401
+ /**
1402
+ * This method writes one operation to the queue store: an append when the store holds each operation
1403
+ * before it, or else the whole queue. On a failure, `rollback` removes the operation from the
1404
+ * queue before the next call of the store.
1405
+ */
1406
+ private persist(op: QueuedOperation, appended: boolean, rollback: () => void): Promise<void> {
1407
+ const run = this.storeChain.then(async () => {
1408
+ // An earlier write of the whole queue kept it already
1409
+ if (!this.unpersisted.has(op)) return;
1410
+ const at = this.offlineOps.indexOf(op);
1411
+ const canAppend =
1412
+ appended && !this.storeDirty && at >= 0 && this.offlineOps.slice(0, at).every((other) => !this.unpersisted.has(other));
1413
+ const snapshot = this.offlineOps.slice();
1414
+ try {
1415
+ if (canAppend) {
1416
+ await this.queueStore.append(op);
1417
+ this.unpersisted.delete(op);
1418
+ } else {
1419
+ await this.queueStore.replace(snapshot);
1420
+ for (const kept of snapshot) this.unpersisted.delete(kept);
1421
+ this.unpersisted.delete(op);
1422
+ this.storeDirty = false;
1423
+ }
1424
+ } catch (error) {
1425
+ this.storeDirty = true;
1426
+ this.unpersisted.delete(op);
1427
+ rollback();
1428
+ throw new QueueStoreError("write", error);
1429
+ }
1430
+ });
1431
+ this.storeChain = run.catch(() => undefined);
1432
+ return run;
1433
+ }
1434
+
1435
+ /** This method writes the whole queue to the queue store when the store can differ from the queue. */
1436
+ private persistAll(): Promise<void> {
1437
+ const run = this.storeChain.then(async () => {
1438
+ if (!this.storeDirty && this.unpersisted.size === 0) return;
1439
+ const snapshot = this.offlineOps.slice();
1440
+ try {
1441
+ if (snapshot.length === 0) await this.queueStore.clear();
1442
+ else await this.queueStore.replace(snapshot);
1443
+ for (const kept of snapshot) this.unpersisted.delete(kept);
1444
+ this.storeDirty = false;
1445
+ } catch (error) {
1446
+ this.storeDirty = true;
1447
+ throw new QueueStoreError("write", error);
1448
+ }
1449
+ });
1450
+ this.storeChain = run.catch(() => undefined);
1451
+ return run;
1452
+ }
595
1453
 
596
- this.offlineOps.push(op);
1454
+ /**
1455
+ * Before a write goes to the remote directly, the queue store must not hold a sent operation:
1456
+ * after a restart, it would replace this newer write.
1457
+ */
1458
+ private async ensureStoreClean(): Promise<void> {
1459
+ if (this.storeDirty) await this.persistAll();
1460
+ }
597
1461
 
598
- // Enforce max queue size
599
- if (this.offlineOps.length > this.options.maxOfflineOps) {
600
- this.offlineOps.shift(); // Remove oldest
1462
+ /** The index of the queued operation that `op` replaces, or -1. */
1463
+ private replaceableIndex(op: QueuedOperation): number {
1464
+ if (op.type !== "set" && op.type !== "delete") return -1;
1465
+ const [id] = idsOf(op);
1466
+ if (id === undefined) return -1;
1467
+ for (let index = this.offlineOps.length - 1; index >= 0; index--) {
1468
+ const queued = this.offlineOps[index]!;
1469
+ const ids = idsOf(queued);
1470
+ if (!ids.includes(id) && !ids.includes("*")) continue;
1471
+ // pushQueue can be sending the first operation now: do not change it
1472
+ const sending = index === 0 && this.pushing;
1473
+ return !sending && (queued.type === "set" || queued.type === "delete") ? index : -1;
601
1474
  }
1475
+ return -1;
1476
+ }
1477
+
1478
+ /** True when the queue can take the operation. */
1479
+ private hasQueueRoom(op: QueuedOperation): boolean {
1480
+ return this.replaceableIndex(op) >= 0 || this.offlineOps.length < this.options.maxOfflineOps;
1481
+ }
1482
+
1483
+ /** A full queue refuses the write. Before, it dropped the oldest operation (roadmap R2). */
1484
+ private ensureQueueRoom(op: QueuedOperation): void {
1485
+ if (!this.hasQueueRoom(op)) throw new QueueFullError(this.options.maxOfflineOps);
1486
+ }
1487
+
1488
+ /**
1489
+ * In write-through mode, a timer sends the queue after a failure, and again after each failed
1490
+ * send, until the queue is empty.
1491
+ */
1492
+ private scheduleDrain(): void {
1493
+ if (this.drainTimer || this.closed || this.mode !== "write-through") return;
1494
+ const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
1495
+ this.drainTimer = setTimeout(() => {
1496
+ this.drainTimer = null;
1497
+ this.syncToRemote().catch(() => this.scheduleDrain());
1498
+ }, delay);
1499
+ (this.drainTimer as { unref?: () => void }).unref?.();
602
1500
  }
603
1501
 
604
1502
  /**
605
1503
  * Execute a queued operation against remote storage.
606
1504
  */
607
- private async executeOperation(op: OfflineOperation): Promise<void> {
1505
+ private async executeOperation(op: QueuedOperation): Promise<void> {
608
1506
  const data = op.data as any;
609
1507
 
610
1508
  switch (op.type) {
@@ -630,36 +1528,6 @@ export class HybridStorage<T> implements CollectionStorage<T> {
630
1528
  }
631
1529
  }
632
1530
 
633
- /**
634
- * Handle remote operation failure.
635
- */
636
- private handleRemoteFailure(operation: OfflineOperation["type"], data: unknown): void {
637
- console.error(`Remote ${operation} failed, marking offline`);
638
- this.isOnline = false;
639
-
640
- // Queue operation if offline queue enabled
641
- if (this.options.offlineQueue) {
642
- this.queueOperation({
643
- type: operation,
644
- data,
645
- timestamp: Date.now(),
646
- });
647
- }
648
- }
649
-
650
- /**
651
- * Start periodic background sync.
652
- */
653
- private startPeriodicSync(): void {
654
- this.syncTimer = setInterval(() => {
655
- this.syncToRemote().catch((err) => {
656
- console.error("Periodic sync failed:", err);
657
- });
658
- }, this.options.syncInterval);
659
- // The timer must not keep the process alive (deep dive DATA-9)
660
- (this.syncTimer as { unref?: () => void }).unref?.();
661
- }
662
-
663
1531
  /**
664
1532
  * Get current online status.
665
1533
  */