@appurist/offlinedb 1.0.0 → 1.0.2

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 (4) hide show
  1. package/package.json +40 -39
  2. package/src/client.js +826 -587
  3. package/src/neon.js +274 -209
  4. package/src/sql.js +2 -4
package/src/client.js CHANGED
@@ -1,587 +1,826 @@
1
- import { defineTable } from "./schema.js";
2
-
3
- /**
4
- * @typedef {{
5
- * localId: string,
6
- * globalId: number,
7
- * clientSequence: number,
8
- * table: string,
9
- * key: string,
10
- * baseRevision: number,
11
- * values: Record<string, unknown>,
12
- * occurredAt: string,
13
- * metadata?: Record<string, unknown>
14
- * }} PendingMutation
15
- */
16
-
17
- /**
18
- * @typedef {{
19
- * table: string,
20
- * key: string,
21
- * row: Record<string, unknown> | null
22
- * }} ChangeRecord
23
- */
24
-
25
- /**
26
- * @typedef {{
27
- * accepted: Array<{localId: string, globalId: number, row?: Record<string, unknown> | null}>,
28
- * conflicts: Array<{localId: string, code: "revision_conflict", serverRow: Record<string, unknown> | null}>,
29
- * changes: ChangeRecord[],
30
- * lastGlobalId: number
31
- * }} SyncResult
32
- */
33
-
34
- export class OfflineDbClient {
35
- /**
36
- * @param {{
37
- * persistence?: PersistenceLayer | "indexeddb" | "localstorage" | "memory",
38
- * transport: {sync: (request: {mutations: PendingMutation[], tables?: string[], lastGlobalId: number}) => Promise<SyncResult>},
39
- * tables: ReturnType<typeof defineTable>[],
40
- * databaseName?: string,
41
- * indexedDb?: IDBFactory,
42
- * localStorage?: StorageLike,
43
- * now?: () => Date,
44
- * idFactory?: () => string
45
- * }} options
46
- */
47
- constructor(options) {
48
- this.persistence = options.persistence;
49
- this.transport = options.transport;
50
- this.tables = new Map(options.tables.map((table) => [table.name, table]));
51
- this.now = options.now ?? (() => new Date());
52
- this.idFactory =
53
- options.idFactory ??
54
- (() => {
55
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
56
- return crypto.randomUUID();
57
- }
58
-
59
- return `mutation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
60
- });
61
- }
62
-
63
- /**
64
- * @param {ConstructorParameters<typeof OfflineDbClient>[0]} options
65
- */
66
- static async open(options) {
67
- const persistence = resolvePersistence(options);
68
- await persistence.initialize();
69
- return new OfflineDbClient({
70
- ...options,
71
- persistence
72
- });
73
- }
74
-
75
- /**
76
- * @param {string} table
77
- * @param {string} key
78
- */
79
- async get(table, key) {
80
- this.#requireTable(table);
81
- return this.persistence.getRow(table, key);
82
- }
83
-
84
- /**
85
- * @param {string} table
86
- */
87
- async list(table) {
88
- this.#requireTable(table);
89
- return this.persistence.listRows(table);
90
- }
91
-
92
- /**
93
- * @param {{table: string, key: string, values: Record<string, unknown>, metadata?: Record<string, unknown>}} args
94
- */
95
- async mutate(args) {
96
- const table = this.#requireTable(args.table);
97
- const current = await this.persistence.getRow(table.name, args.key);
98
- const revisionColumn = table.revisionColumn;
99
- const updatedAtColumn = table.updatedAtColumn;
100
- const localIdColumn = table.localIdColumn;
101
- const globalIdColumn = table.globalIdColumn;
102
- const nowIso = this.now().toISOString();
103
- const baseRevision = Number(current?.[revisionColumn] ?? 0);
104
- const localId = this.idFactory();
105
- const mutation = {
106
- localId,
107
- globalId: 0,
108
- clientSequence: await this.persistence.nextClientSequence(),
109
- table: table.name,
110
- key: args.key,
111
- baseRevision,
112
- values: {
113
- ...(current ?? {}),
114
- ...args.values,
115
- [table.primaryKey]: args.key,
116
- [revisionColumn]: baseRevision + 1,
117
- [updatedAtColumn]: nowIso,
118
- [localIdColumn]: localId,
119
- [globalIdColumn]: 0
120
- },
121
- occurredAt: nowIso,
122
- metadata: args.metadata
123
- };
124
-
125
- await this.persistence.saveRow(table.name, args.key, mutation.values);
126
- await this.persistence.appendPendingMutation(mutation);
127
- return mutation;
128
- }
129
-
130
- /**
131
- * @param {{tables?: string[]}=} options
132
- */
133
- async sync(options = {}) {
134
- const tableNames = this.#resolveTables(options.tables);
135
- const [mutations, lastGlobalId] = await Promise.all([
136
- this.persistence.getPendingMutations(),
137
- this.persistence.getLastGlobalId()
138
- ]);
139
- const result = await this.transport.sync({
140
- mutations,
141
- tables: tableNames,
142
- lastGlobalId
143
- });
144
-
145
- await this.persistence.acknowledgeMutations(result.accepted.map((entry) => entry.localId));
146
-
147
- for (const accepted of result.accepted) {
148
- const mutation = mutations.find((entry) => entry.localId === accepted.localId);
149
- if (!mutation) {
150
- continue;
151
- }
152
-
153
- if (accepted.row) {
154
- await this.persistence.saveRow(mutation.table, mutation.key, accepted.row);
155
- continue;
156
- }
157
-
158
- const current = await this.persistence.getRow(mutation.table, mutation.key);
159
- if (!current) {
160
- continue;
161
- }
162
-
163
- const table = this.#requireTable(mutation.table);
164
- if (current[table.localIdColumn] !== accepted.localId) {
165
- continue;
166
- }
167
-
168
- await this.persistence.saveRow(mutation.table, mutation.key, {
169
- ...current,
170
- [table.localIdColumn]: null,
171
- [table.globalIdColumn]: accepted.globalId
172
- });
173
- }
174
-
175
- for (const conflict of result.conflicts) {
176
- const mutation = mutations.find((entry) => entry.localId === conflict.localId);
177
- if (!mutation) {
178
- continue;
179
- }
180
-
181
- await this.persistence.acknowledgeMutations([conflict.localId]);
182
- if (conflict.serverRow) {
183
- await this.persistence.saveRow(mutation.table, mutation.key, conflict.serverRow);
184
- }
185
- }
186
-
187
- for (const change of result.changes) {
188
- this.#requireTable(change.table);
189
- if (change.row == null) {
190
- await this.persistence.deleteRow(change.table, change.key);
191
- continue;
192
- }
193
-
194
- await this.persistence.saveRow(change.table, change.key, change.row);
195
- }
196
-
197
- await this.persistence.setLastGlobalId(result.lastGlobalId);
198
- return result;
199
- }
200
-
201
- async exportState() {
202
- return this.persistence.exportState();
203
- }
204
-
205
- #requireTable(name) {
206
- const table = this.tables.get(name);
207
- if (!table) {
208
- throw new Error(`Unknown table: ${name}`);
209
- }
210
-
211
- return table;
212
- }
213
-
214
- #resolveTables(tableNames) {
215
- if (!tableNames || tableNames.length === 0) {
216
- return [...this.tables.keys()];
217
- }
218
-
219
- return tableNames.map((name) => this.#requireTable(name).name);
220
- }
221
- }
222
-
223
- export class IndexedDbPersistence {
224
- /**
225
- * @param {{databaseName?: string, indexedDb?: IDBFactory}=} options
226
- */
227
- constructor(options = {}) {
228
- this.databaseName = options.databaseName ?? "offlinedb";
229
- this.indexedDb = options.indexedDb ?? globalThis.indexedDB;
230
- this.memory = new InMemoryPersistence();
231
- }
232
-
233
- async initialize() {
234
- if (!this.indexedDb) {
235
- throw new Error("IndexedDB persistence requires indexedDB to be available or injected.");
236
- }
237
-
238
- const snapshot = await this.#readSnapshot();
239
- if (snapshot) {
240
- await this.memory.importState(snapshot);
241
- }
242
- }
243
-
244
- async nextClientSequence() {
245
- const value = await this.memory.nextClientSequence();
246
- await this.#persist();
247
- return value;
248
- }
249
-
250
- async getRow(table, key) {
251
- return this.memory.getRow(table, key);
252
- }
253
-
254
- async listRows(table) {
255
- return this.memory.listRows(table);
256
- }
257
-
258
- async saveRow(table, key, row) {
259
- await this.memory.saveRow(table, key, row);
260
- await this.#persist();
261
- }
262
-
263
- async deleteRow(table, key) {
264
- await this.memory.deleteRow(table, key);
265
- await this.#persist();
266
- }
267
-
268
- async appendPendingMutation(mutation) {
269
- await this.memory.appendPendingMutation(mutation);
270
- await this.#persist();
271
- }
272
-
273
- async getPendingMutations() {
274
- return this.memory.getPendingMutations();
275
- }
276
-
277
- async acknowledgeMutations(mutationIds) {
278
- await this.memory.acknowledgeMutations(mutationIds);
279
- await this.#persist();
280
- }
281
-
282
- async getLastGlobalId() {
283
- return this.memory.getLastGlobalId();
284
- }
285
-
286
- async setLastGlobalId(lastGlobalId) {
287
- await this.memory.setLastGlobalId(lastGlobalId);
288
- await this.#persist();
289
- }
290
-
291
- async exportState() {
292
- return this.memory.exportState();
293
- }
294
-
295
- async #persist() {
296
- const db = await this.#openDatabase();
297
- const snapshot = await this.memory.exportState();
298
-
299
- await new Promise((resolve, reject) => {
300
- const tx = db.transaction("state", "readwrite");
301
- tx.objectStore("state").put(snapshot, "snapshot");
302
- tx.oncomplete = () => resolve();
303
- tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed."));
304
- });
305
- }
306
-
307
- async #readSnapshot() {
308
- const db = await this.#openDatabase();
309
-
310
- return new Promise((resolve, reject) => {
311
- const tx = db.transaction("state", "readonly");
312
- const request = tx.objectStore("state").get("snapshot");
313
- request.onsuccess = () => resolve(request.result ?? null);
314
- request.onerror = () => reject(request.error ?? new Error("IndexedDB read failed."));
315
- });
316
- }
317
-
318
- async #openDatabase() {
319
- return new Promise((resolve, reject) => {
320
- const request = this.indexedDb.open(this.databaseName, 1);
321
- request.onupgradeneeded = () => {
322
- if (!request.result.objectStoreNames.contains("state")) {
323
- request.result.createObjectStore("state");
324
- }
325
- };
326
- request.onsuccess = () => resolve(request.result);
327
- request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed."));
328
- });
329
- }
330
- }
331
-
332
- export class LocalStoragePersistence {
333
- /**
334
- * @param {{storageKey?: string, localStorage?: StorageLike}=} options
335
- */
336
- constructor(options = {}) {
337
- this.storageKey = options.storageKey ?? "offlinedb:snapshot";
338
- this.localStorage = options.localStorage ?? globalThis.localStorage;
339
- this.memory = new InMemoryPersistence();
340
- }
341
-
342
- async initialize() {
343
- if (!this.localStorage) {
344
- throw new Error("LocalStorage persistence requires localStorage to be available or injected.");
345
- }
346
-
347
- const raw = this.localStorage.getItem(this.storageKey);
348
- if (!raw) {
349
- return;
350
- }
351
-
352
- await this.memory.importState(JSON.parse(raw));
353
- }
354
-
355
- async nextClientSequence() {
356
- const value = await this.memory.nextClientSequence();
357
- await this.#persist();
358
- return value;
359
- }
360
-
361
- async getRow(table, key) {
362
- return this.memory.getRow(table, key);
363
- }
364
-
365
- async listRows(table) {
366
- return this.memory.listRows(table);
367
- }
368
-
369
- async saveRow(table, key, row) {
370
- await this.memory.saveRow(table, key, row);
371
- await this.#persist();
372
- }
373
-
374
- async deleteRow(table, key) {
375
- await this.memory.deleteRow(table, key);
376
- await this.#persist();
377
- }
378
-
379
- async appendPendingMutation(mutation) {
380
- await this.memory.appendPendingMutation(mutation);
381
- await this.#persist();
382
- }
383
-
384
- async getPendingMutations() {
385
- return this.memory.getPendingMutations();
386
- }
387
-
388
- async acknowledgeMutations(mutationIds) {
389
- await this.memory.acknowledgeMutations(mutationIds);
390
- await this.#persist();
391
- }
392
-
393
- async getLastGlobalId() {
394
- return this.memory.getLastGlobalId();
395
- }
396
-
397
- async setLastGlobalId(lastGlobalId) {
398
- await this.memory.setLastGlobalId(lastGlobalId);
399
- await this.#persist();
400
- }
401
-
402
- async exportState() {
403
- return this.memory.exportState();
404
- }
405
-
406
- async #persist() {
407
- const snapshot = await this.memory.exportState();
408
- this.localStorage.setItem(this.storageKey, JSON.stringify(snapshot));
409
- }
410
- }
411
-
412
- export class InMemoryPersistence {
413
- constructor() {
414
- this.rows = new Map();
415
- this.pending = new Map();
416
- this.lastGlobalId = 0;
417
- this.clientSequence = 0;
418
- }
419
-
420
- async initialize() {}
421
-
422
- async nextClientSequence() {
423
- this.clientSequence += 1;
424
- return this.clientSequence;
425
- }
426
-
427
- async getRow(table, key) {
428
- return this.#bucket(table).get(key) ?? null;
429
- }
430
-
431
- async listRows(table) {
432
- return [...this.#bucket(table).values()];
433
- }
434
-
435
- async saveRow(table, key, row) {
436
- this.#bucket(table).set(key, clone(row));
437
- }
438
-
439
- async deleteRow(table, key) {
440
- this.#bucket(table).delete(key);
441
- }
442
-
443
- async appendPendingMutation(mutation) {
444
- this.pending.set(mutation.localId, clone(mutation));
445
- }
446
-
447
- async getPendingMutations() {
448
- return [...this.pending.values()].sort((left, right) => left.clientSequence - right.clientSequence);
449
- }
450
-
451
- async acknowledgeMutations(mutationIds) {
452
- for (const mutationId of mutationIds) {
453
- this.pending.delete(mutationId);
454
- }
455
- }
456
-
457
- async getLastGlobalId() {
458
- return this.lastGlobalId;
459
- }
460
-
461
- async setLastGlobalId(lastGlobalId) {
462
- this.lastGlobalId = Number(lastGlobalId ?? 0);
463
- }
464
-
465
- async exportState() {
466
- return {
467
- clientSequence: this.clientSequence,
468
- lastGlobalId: this.lastGlobalId,
469
- pending: await this.getPendingMutations(),
470
- rows: [...this.rows.entries()].map(([table, bucket]) => [table, [...bucket.entries()].map(([key, row]) => [key, clone(row)])])
471
- };
472
- }
473
-
474
- async importState(state) {
475
- this.clientSequence = Number(state?.clientSequence ?? 0);
476
- this.lastGlobalId = Number(state?.lastGlobalId ?? state?.watermark ?? state?.cursors?.global ?? 0);
477
- this.pending.clear();
478
- this.rows.clear();
479
-
480
- for (const mutation of state?.pending ?? []) {
481
- this.pending.set(mutation.localId ?? mutation.mutationId, clone(mutation));
482
- }
483
-
484
- for (const [table, entries] of state?.rows ?? []) {
485
- const bucket = this.#bucket(table);
486
- for (const [key, row] of entries) {
487
- bucket.set(key, clone(row));
488
- }
489
- }
490
- }
491
-
492
- #bucket(table) {
493
- if (!this.rows.has(table)) {
494
- this.rows.set(table, new Map());
495
- }
496
-
497
- return this.rows.get(table);
498
- }
499
- }
500
-
501
- /**
502
- * @param {ConstructorParameters<typeof OfflineDbClient>[0]} options
503
- */
504
- function resolvePersistence(options) {
505
- if (!options.persistence || options.persistence === "indexeddb") {
506
- return new IndexedDbPersistence({
507
- databaseName: options.databaseName ?? "offlinedb",
508
- indexedDb: options.indexedDb
509
- });
510
- }
511
-
512
- if (options.persistence === "memory") {
513
- return new InMemoryPersistence();
514
- }
515
-
516
- if (options.persistence === "localstorage") {
517
- return new LocalStoragePersistence({
518
- storageKey: `${options.databaseName ?? "offlinedb"}:snapshot`,
519
- localStorage: options.localStorage
520
- });
521
- }
522
-
523
- return options.persistence;
524
- }
525
-
526
- /**
527
- * @typedef {{
528
- * initialize: () => Promise<void>,
529
- * nextClientSequence: () => Promise<number>,
530
- * getRow: (table: string, key: string) => Promise<Record<string, unknown> | null>,
531
- * listRows: (table: string) => Promise<Record<string, unknown>[]>,
532
- * saveRow: (table: string, key: string, row: Record<string, unknown>) => Promise<void>,
533
- * deleteRow: (table: string, key: string) => Promise<void>,
534
- * appendPendingMutation: (mutation: PendingMutation) => Promise<void>,
535
- * getPendingMutations: () => Promise<PendingMutation[]>,
536
- * acknowledgeMutations: (mutationIds: string[]) => Promise<void>,
537
- * getLastGlobalId: () => Promise<number>,
538
- * setLastGlobalId: (lastGlobalId: number) => Promise<void>,
539
- * exportState: () => Promise<unknown>
540
- * }} PersistenceLayer
541
- *
542
- * @typedef {{
543
- * open: (name: string, version: number) => IDBOpenDBRequest
544
- * }} IDBFactory
545
- *
546
- * @typedef {{
547
- * getItem: (key: string) => string | null,
548
- * setItem: (key: string, value: string) => void
549
- * }} StorageLike
550
- */
551
-
552
- /**
553
- * @param {{baseUrl: string, fetchImpl?: typeof fetch, getAuthToken?: () => Promise<string | null> | string | null, syncPath?: string}} options
554
- */
555
- export function createNeonHttpTransport(options) {
556
- const fetchImpl = options.fetchImpl ?? fetch;
557
- const syncPath = options.syncPath ?? "rpc/offlinedb_sync";
558
-
559
- return {
560
- async sync(request) {
561
- const token = await options.getAuthToken?.();
562
- const response = await fetchImpl(joinUrl(options.baseUrl, syncPath), {
563
- method: "POST",
564
- headers: {
565
- "content-type": "application/json",
566
- ...(token ? { authorization: `Bearer ${token}` } : {})
567
- },
568
- body: JSON.stringify(request)
569
- });
570
-
571
- if (!response.ok) {
572
- throw new Error(`Sync failed with status ${response.status}`);
573
- }
574
-
575
- return response.json();
576
- }
577
- };
578
- }
579
-
580
- function joinUrl(baseUrl, path) {
581
- const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
582
- return new URL(path, normalizedBase);
583
- }
584
-
585
- function clone(value) {
586
- return value == null ? value : JSON.parse(JSON.stringify(value));
587
- }
1
+ import { defineTable } from "./schema.js";
2
+
3
+ /**
4
+ * @typedef {{
5
+ * localId: string,
6
+ * globalId: number,
7
+ * clientSequence: number,
8
+ * table: string,
9
+ * key: string,
10
+ * baseRevision: number,
11
+ * values: Record<string, unknown>,
12
+ * occurredAt: string,
13
+ * metadata?: Record<string, unknown>
14
+ * }} PendingMutation
15
+ */
16
+
17
+ /**
18
+ * @typedef {{
19
+ * table: string,
20
+ * key: string,
21
+ * operation: "upsert" | "delete",
22
+ * firstTouchedAt: string,
23
+ * lastTouchedAt: string,
24
+ * attemptCount: number,
25
+ * lastError?: string | null
26
+ * }} TouchedRow
27
+ */
28
+
29
+ /**
30
+ * @typedef {{
31
+ * table: string,
32
+ * key: string,
33
+ * row: Record<string, unknown> | null
34
+ * }} ChangeRecord
35
+ */
36
+
37
+ /**
38
+ * @typedef {{
39
+ * accepted: Array<{localId: string, globalId: number, row?: Record<string, unknown> | null}>,
40
+ * conflicts: Array<{localId: string, code: "revision_conflict", serverRow: Record<string, unknown> | null}>,
41
+ * changes: ChangeRecord[],
42
+ * lastGlobalId: number
43
+ * }} SyncResult
44
+ */
45
+
46
+ export class OfflineDbClient {
47
+ /**
48
+ * @param {{
49
+ * persistence?: PersistenceLayer | "indexeddb" | "localstorage" | "memory",
50
+ * transport: {sync: (request: {mutations: PendingMutation[], tables?: string[], lastGlobalId: number}) => Promise<SyncResult>},
51
+ * tables: ReturnType<typeof defineTable>[],
52
+ * databaseName?: string,
53
+ * indexedDb?: IDBFactory,
54
+ * localStorage?: StorageLike,
55
+ * now?: () => Date,
56
+ * idFactory?: () => string
57
+ * }} options
58
+ */
59
+ constructor(options) {
60
+ this.persistence = options.persistence;
61
+ this.transport = options.transport;
62
+ this.tables = new Map(options.tables.map((table) => [table.name, table]));
63
+ this.now = options.now ?? (() => new Date());
64
+ this.idFactory =
65
+ options.idFactory ??
66
+ (() => {
67
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
68
+ return crypto.randomUUID();
69
+ }
70
+
71
+ return `mutation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
72
+ });
73
+ }
74
+
75
+ /**
76
+ * @param {ConstructorParameters<typeof OfflineDbClient>[0]} options
77
+ */
78
+ static async open(options) {
79
+ const persistence = resolvePersistence(options);
80
+ await persistence.initialize();
81
+ return new OfflineDbClient({
82
+ ...options,
83
+ persistence
84
+ });
85
+ }
86
+
87
+ /**
88
+ * @param {string} table
89
+ * @param {string} key
90
+ */
91
+ async get(table, key) {
92
+ this.#requireTable(table);
93
+ return this.persistence.getRow(table, key);
94
+ }
95
+
96
+ /**
97
+ * @param {string} table
98
+ */
99
+ async list(table) {
100
+ this.#requireTable(table);
101
+ return this.persistence.listRows(table);
102
+ }
103
+
104
+ /**
105
+ * @param {{table: string, key: string, values: Record<string, unknown>, metadata?: Record<string, unknown>}} args
106
+ */
107
+ async mutate(args) {
108
+ const table = this.#requireTable(args.table);
109
+ const current = await this.persistence.getRow(table.name, args.key);
110
+ const revisionColumn = table.revisionColumn;
111
+ const updatedAtColumn = table.updatedAtColumn;
112
+ const localIdColumn = table.localIdColumn;
113
+ const globalIdColumn = table.globalIdColumn;
114
+ const nowIso = this.now().toISOString();
115
+ const baseRevision = Number(current?.[revisionColumn] ?? 0);
116
+ const localId = this.idFactory();
117
+ const mutation = {
118
+ localId,
119
+ globalId: 0,
120
+ clientSequence: await this.persistence.nextClientSequence(),
121
+ table: table.name,
122
+ key: args.key,
123
+ baseRevision,
124
+ values: {
125
+ ...(current ?? {}),
126
+ ...args.values,
127
+ [table.primaryKey]: args.key,
128
+ [revisionColumn]: baseRevision + 1,
129
+ [updatedAtColumn]: nowIso,
130
+ [localIdColumn]: localId,
131
+ [globalIdColumn]: 0
132
+ },
133
+ occurredAt: nowIso,
134
+ metadata: args.metadata
135
+ };
136
+
137
+ await this.persistence.saveRow(table.name, args.key, mutation.values);
138
+ await this.persistence.appendPendingMutation(mutation);
139
+ await this.persistence.recordTouchedRow?.({
140
+ table: table.name,
141
+ key: args.key,
142
+ operation: "upsert",
143
+ occurredAt: nowIso
144
+ });
145
+ return mutation;
146
+ }
147
+
148
+ /**
149
+ * Queue a delete tombstone and optimistically remove the local row.
150
+ * @param {{table: string, key: string, metadata?: Record<string, unknown>}} args
151
+ */
152
+ async delete(args) {
153
+ const table = this.#requireTable(args.table);
154
+ const current = await this.persistence.getRow(table.name, args.key);
155
+ const nowIso = this.now().toISOString();
156
+ const localId = this.idFactory();
157
+ const mutation = {
158
+ localId,
159
+ globalId: 0,
160
+ clientSequence: await this.persistence.nextClientSequence(),
161
+ table: table.name,
162
+ key: args.key,
163
+ baseRevision: Number(current?.[table.revisionColumn] ?? 0),
164
+ values: null,
165
+ occurredAt: nowIso,
166
+ metadata: args.metadata
167
+ };
168
+
169
+ await this.persistence.deleteRow(table.name, args.key);
170
+ await this.persistence.appendPendingMutation(mutation);
171
+ await this.persistence.recordTouchedRow?.({
172
+ table: table.name,
173
+ key: args.key,
174
+ operation: "delete",
175
+ occurredAt: nowIso
176
+ });
177
+ return mutation;
178
+ }
179
+
180
+ /**
181
+ * @param {{tables?: string[]}=} options
182
+ */
183
+ async sync(options = {}) {
184
+ const tableNames = this.#resolveTables(options.tables);
185
+ const [mutations, lastGlobalId] = await Promise.all([
186
+ this.persistence.getPendingMutations(),
187
+ this.persistence.getLastGlobalId()
188
+ ]);
189
+ const result = await this.transport.sync({
190
+ mutations,
191
+ tables: tableNames,
192
+ lastGlobalId
193
+ });
194
+
195
+ await this.persistence.acknowledgeMutations(result.accepted.map((entry) => entry.localId));
196
+
197
+ for (const accepted of result.accepted) {
198
+ const mutation = mutations.find((entry) => entry.localId === accepted.localId);
199
+ if (!mutation) {
200
+ continue;
201
+ }
202
+
203
+ if (accepted.row) {
204
+ await this.persistence.saveRow(mutation.table, mutation.key, accepted.row);
205
+ continue;
206
+ }
207
+
208
+ const current = await this.persistence.getRow(mutation.table, mutation.key);
209
+ if (!current) {
210
+ continue;
211
+ }
212
+
213
+ const table = this.#requireTable(mutation.table);
214
+ if (current[table.localIdColumn] !== accepted.localId) {
215
+ continue;
216
+ }
217
+
218
+ await this.persistence.saveRow(mutation.table, mutation.key, {
219
+ ...current,
220
+ [table.localIdColumn]: null,
221
+ [table.globalIdColumn]: accepted.globalId
222
+ });
223
+ }
224
+
225
+ for (const conflict of result.conflicts) {
226
+ const mutation = mutations.find((entry) => entry.localId === conflict.localId);
227
+ if (!mutation) {
228
+ continue;
229
+ }
230
+
231
+ await this.persistence.acknowledgeMutations([conflict.localId]);
232
+ if (conflict.serverRow) {
233
+ await this.persistence.saveRow(mutation.table, mutation.key, conflict.serverRow);
234
+ }
235
+ }
236
+
237
+ for (const change of result.changes) {
238
+ this.#requireTable(change.table);
239
+ if (change.row == null) {
240
+ await this.persistence.deleteRow(change.table, change.key);
241
+ continue;
242
+ }
243
+
244
+ await this.persistence.saveRow(change.table, change.key, change.row);
245
+ }
246
+
247
+ await this.persistence.setLastGlobalId(result.lastGlobalId);
248
+ return result;
249
+ }
250
+
251
+ /**
252
+ * Requeue touched rows, optionally every local row, then run normal bidirectional sync.
253
+ * @param {{tables?: string[], includeAllLocalRows?: boolean, onProgress?: (progress: {phase: string, current: number, total: number, label?: string}) => void}=} options
254
+ */
255
+ async repairSync(options = {}) {
256
+ const tableNames = this.#resolveTables(options.tables);
257
+ const nowIso = this.now().toISOString();
258
+ const touched = await this.persistence.listTouchedRows?.(tableNames) ?? [];
259
+ const touchedKeys = new Set(touched.map((entry) => `${entry.table}:${entry.key}`));
260
+ const candidates = [...touched];
261
+
262
+ if (options.includeAllLocalRows) {
263
+ for (const table of tableNames) {
264
+ const rows = await this.persistence.listRows(table);
265
+ for (const row of rows) {
266
+ const tableDef = this.#requireTable(table);
267
+ const key = String(row?.[tableDef.primaryKey] ?? row?.id ?? "");
268
+ if (!key || touchedKeys.has(`${table}:${key}`)) {
269
+ continue;
270
+ }
271
+ candidates.push({
272
+ table,
273
+ key,
274
+ operation: "upsert",
275
+ firstTouchedAt: nowIso,
276
+ lastTouchedAt: nowIso,
277
+ attemptCount: 0,
278
+ lastError: null
279
+ });
280
+ }
281
+ }
282
+ }
283
+
284
+ options.onProgress?.({ phase: "queue", current: 0, total: candidates.length, label: "Queueing local changes..." });
285
+ let queued = 0;
286
+ const queuedTouched = [];
287
+ for (const entry of candidates) {
288
+ const table = this.#requireTable(entry.table);
289
+ try {
290
+ if (entry.operation === "delete") {
291
+ await this.delete({ table: table.name, key: entry.key });
292
+ } else {
293
+ const row = await this.persistence.getRow(table.name, entry.key);
294
+ if (!row) {
295
+ await this.persistence.clearTouchedRows?.([entry]);
296
+ queued += 1;
297
+ options.onProgress?.({ phase: "queue", current: queued, total: candidates.length, label: `Queued ${queued} of ${candidates.length}` });
298
+ continue;
299
+ }
300
+ await this.mutate({ table: table.name, key: entry.key, values: row });
301
+ }
302
+ queuedTouched.push(entry);
303
+ } catch (error) {
304
+ await this.persistence.markTouchedRowError?.(entry.table, entry.key, error.message);
305
+ }
306
+ queued += 1;
307
+ options.onProgress?.({ phase: "queue", current: queued, total: candidates.length, label: `Queued ${queued} of ${candidates.length}` });
308
+ }
309
+
310
+ options.onProgress?.({ phase: "sync", current: 0, total: 1, label: "Syncing with cloud..." });
311
+ const originalLastGlobalId = await this.persistence.getLastGlobalId();
312
+ await this.persistence.setLastGlobalId(0);
313
+ let result;
314
+ try {
315
+ result = await this.sync({ tables: tableNames });
316
+ } catch (error) {
317
+ await this.persistence.setLastGlobalId(originalLastGlobalId);
318
+ throw error;
319
+ }
320
+ const pending = await this.persistence.getPendingMutations();
321
+ const pendingKeys = new Set(pending.map((mutation) => `${mutation.table}:${mutation.key}`));
322
+ const clearable = queuedTouched.filter((entry) => !pendingKeys.has(`${entry.table}:${entry.key}`));
323
+ await this.persistence.clearTouchedRows?.(clearable);
324
+ options.onProgress?.({ phase: "sync", current: 1, total: 1, label: "Repair sync complete" });
325
+
326
+ return {
327
+ ...result,
328
+ touched: touched.length,
329
+ queued: queuedTouched.length,
330
+ clearedTouched: clearable.length,
331
+ pending: pending.length
332
+ };
333
+ }
334
+
335
+ async getSyncStatus(options = {}) {
336
+ const tableNames = this.#resolveTables(options.tables);
337
+ const [pending, touched] = await Promise.all([
338
+ this.persistence.getPendingMutations(),
339
+ this.persistence.listTouchedRows?.(tableNames) ?? []
340
+ ]);
341
+ const tableSet = new Set(tableNames);
342
+ return {
343
+ pendingCount: pending.filter((entry) => tableSet.has(entry.table)).length,
344
+ touchedCount: touched.length,
345
+ lastGlobalId: await this.persistence.getLastGlobalId()
346
+ };
347
+ }
348
+
349
+ async exportState() {
350
+ return this.persistence.exportState();
351
+ }
352
+
353
+ #requireTable(name) {
354
+ const table = this.tables.get(name);
355
+ if (!table) {
356
+ throw new Error(`Unknown table: ${name}`);
357
+ }
358
+
359
+ return table;
360
+ }
361
+
362
+ #resolveTables(tableNames) {
363
+ if (!tableNames || tableNames.length === 0) {
364
+ return [...this.tables.keys()];
365
+ }
366
+
367
+ return tableNames.map((name) => this.#requireTable(name).name);
368
+ }
369
+ }
370
+
371
+ export class IndexedDbPersistence {
372
+ /**
373
+ * @param {{databaseName?: string, indexedDb?: IDBFactory}=} options
374
+ */
375
+ constructor(options = {}) {
376
+ this.databaseName = options.databaseName ?? "offlinedb";
377
+ this.indexedDb = options.indexedDb ?? globalThis.indexedDB;
378
+ this.memory = new InMemoryPersistence();
379
+ }
380
+
381
+ async initialize() {
382
+ if (!this.indexedDb) {
383
+ throw new Error("IndexedDB persistence requires indexedDB to be available or injected.");
384
+ }
385
+
386
+ const snapshot = await this.#readSnapshot();
387
+ if (snapshot) {
388
+ await this.memory.importState(snapshot);
389
+ }
390
+ }
391
+
392
+ async nextClientSequence() {
393
+ const value = await this.memory.nextClientSequence();
394
+ await this.#persist();
395
+ return value;
396
+ }
397
+
398
+ async getRow(table, key) {
399
+ return this.memory.getRow(table, key);
400
+ }
401
+
402
+ async listRows(table) {
403
+ return this.memory.listRows(table);
404
+ }
405
+
406
+ async saveRow(table, key, row) {
407
+ await this.memory.saveRow(table, key, row);
408
+ await this.#persist();
409
+ }
410
+
411
+ async deleteRow(table, key) {
412
+ await this.memory.deleteRow(table, key);
413
+ await this.#persist();
414
+ }
415
+
416
+ async appendPendingMutation(mutation) {
417
+ await this.memory.appendPendingMutation(mutation);
418
+ await this.#persist();
419
+ }
420
+
421
+ async recordTouchedRow(entry) {
422
+ await this.memory.recordTouchedRow(entry);
423
+ await this.#persist();
424
+ }
425
+
426
+ async listTouchedRows(tableNames = []) {
427
+ return this.memory.listTouchedRows(tableNames);
428
+ }
429
+
430
+ async clearTouchedRows(entries) {
431
+ await this.memory.clearTouchedRows(entries);
432
+ await this.#persist();
433
+ }
434
+
435
+ async markTouchedRowError(table, key, error) {
436
+ await this.memory.markTouchedRowError(table, key, error);
437
+ await this.#persist();
438
+ }
439
+
440
+ async getPendingMutations() {
441
+ return this.memory.getPendingMutations();
442
+ }
443
+
444
+ async acknowledgeMutations(mutationIds) {
445
+ await this.memory.acknowledgeMutations(mutationIds);
446
+ await this.#persist();
447
+ }
448
+
449
+ async getLastGlobalId() {
450
+ return this.memory.getLastGlobalId();
451
+ }
452
+
453
+ async setLastGlobalId(lastGlobalId) {
454
+ await this.memory.setLastGlobalId(lastGlobalId);
455
+ await this.#persist();
456
+ }
457
+
458
+ async exportState() {
459
+ return this.memory.exportState();
460
+ }
461
+
462
+ async #persist() {
463
+ const db = await this.#openDatabase();
464
+ const snapshot = await this.memory.exportState();
465
+
466
+ await new Promise((resolve, reject) => {
467
+ const tx = db.transaction("state", "readwrite");
468
+ tx.objectStore("state").put(snapshot, "snapshot");
469
+ tx.oncomplete = () => resolve();
470
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed."));
471
+ });
472
+ }
473
+
474
+ async #readSnapshot() {
475
+ const db = await this.#openDatabase();
476
+
477
+ return new Promise((resolve, reject) => {
478
+ const tx = db.transaction("state", "readonly");
479
+ const request = tx.objectStore("state").get("snapshot");
480
+ request.onsuccess = () => resolve(request.result ?? null);
481
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB read failed."));
482
+ });
483
+ }
484
+
485
+ async #openDatabase() {
486
+ return new Promise((resolve, reject) => {
487
+ const request = this.indexedDb.open(this.databaseName, 1);
488
+ request.onupgradeneeded = () => {
489
+ if (!request.result.objectStoreNames.contains("state")) {
490
+ request.result.createObjectStore("state");
491
+ }
492
+ };
493
+ request.onsuccess = () => resolve(request.result);
494
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed."));
495
+ });
496
+ }
497
+ }
498
+
499
+ export class LocalStoragePersistence {
500
+ /**
501
+ * @param {{storageKey?: string, localStorage?: StorageLike}=} options
502
+ */
503
+ constructor(options = {}) {
504
+ this.storageKey = options.storageKey ?? "offlinedb:snapshot";
505
+ this.localStorage = options.localStorage ?? globalThis.localStorage;
506
+ this.memory = new InMemoryPersistence();
507
+ }
508
+
509
+ async initialize() {
510
+ if (!this.localStorage) {
511
+ throw new Error("LocalStorage persistence requires localStorage to be available or injected.");
512
+ }
513
+
514
+ const raw = this.localStorage.getItem(this.storageKey);
515
+ if (!raw) {
516
+ return;
517
+ }
518
+
519
+ await this.memory.importState(JSON.parse(raw));
520
+ }
521
+
522
+ async nextClientSequence() {
523
+ const value = await this.memory.nextClientSequence();
524
+ await this.#persist();
525
+ return value;
526
+ }
527
+
528
+ async getRow(table, key) {
529
+ return this.memory.getRow(table, key);
530
+ }
531
+
532
+ async listRows(table) {
533
+ return this.memory.listRows(table);
534
+ }
535
+
536
+ async saveRow(table, key, row) {
537
+ await this.memory.saveRow(table, key, row);
538
+ await this.#persist();
539
+ }
540
+
541
+ async deleteRow(table, key) {
542
+ await this.memory.deleteRow(table, key);
543
+ await this.#persist();
544
+ }
545
+
546
+ async appendPendingMutation(mutation) {
547
+ await this.memory.appendPendingMutation(mutation);
548
+ await this.#persist();
549
+ }
550
+
551
+ async recordTouchedRow(entry) {
552
+ await this.memory.recordTouchedRow(entry);
553
+ await this.#persist();
554
+ }
555
+
556
+ async listTouchedRows(tableNames = []) {
557
+ return this.memory.listTouchedRows(tableNames);
558
+ }
559
+
560
+ async clearTouchedRows(entries) {
561
+ await this.memory.clearTouchedRows(entries);
562
+ await this.#persist();
563
+ }
564
+
565
+ async markTouchedRowError(table, key, error) {
566
+ await this.memory.markTouchedRowError(table, key, error);
567
+ await this.#persist();
568
+ }
569
+
570
+ async getPendingMutations() {
571
+ return this.memory.getPendingMutations();
572
+ }
573
+
574
+ async acknowledgeMutations(mutationIds) {
575
+ await this.memory.acknowledgeMutations(mutationIds);
576
+ await this.#persist();
577
+ }
578
+
579
+ async getLastGlobalId() {
580
+ return this.memory.getLastGlobalId();
581
+ }
582
+
583
+ async setLastGlobalId(lastGlobalId) {
584
+ await this.memory.setLastGlobalId(lastGlobalId);
585
+ await this.#persist();
586
+ }
587
+
588
+ async exportState() {
589
+ return this.memory.exportState();
590
+ }
591
+
592
+ async #persist() {
593
+ const snapshot = await this.memory.exportState();
594
+ this.localStorage.setItem(this.storageKey, JSON.stringify(snapshot));
595
+ }
596
+ }
597
+
598
+ export class InMemoryPersistence {
599
+ constructor() {
600
+ this.rows = new Map();
601
+ this.pending = new Map();
602
+ this.touched = new Map();
603
+ this.lastGlobalId = 0;
604
+ this.clientSequence = 0;
605
+ }
606
+
607
+ async initialize() {}
608
+
609
+ async nextClientSequence() {
610
+ this.clientSequence += 1;
611
+ return this.clientSequence;
612
+ }
613
+
614
+ async getRow(table, key) {
615
+ return this.#bucket(table).get(key) ?? null;
616
+ }
617
+
618
+ async listRows(table) {
619
+ return [...this.#bucket(table).values()];
620
+ }
621
+
622
+ async saveRow(table, key, row) {
623
+ this.#bucket(table).set(key, clone(row));
624
+ }
625
+
626
+ async deleteRow(table, key) {
627
+ this.#bucket(table).delete(key);
628
+ }
629
+
630
+ async appendPendingMutation(mutation) {
631
+ this.pending.set(mutation.localId, clone(mutation));
632
+ }
633
+
634
+ async recordTouchedRow(entry) {
635
+ const key = `${entry.table}:${entry.key}`;
636
+ const existing = this.touched.get(key);
637
+ const occurredAt = entry.occurredAt ?? new Date().toISOString();
638
+ this.touched.set(key, {
639
+ table: entry.table,
640
+ key: entry.key,
641
+ operation: entry.operation ?? existing?.operation ?? "upsert",
642
+ firstTouchedAt: existing?.firstTouchedAt ?? occurredAt,
643
+ lastTouchedAt: occurredAt,
644
+ attemptCount: Number(existing?.attemptCount ?? 0),
645
+ lastError: null
646
+ });
647
+ }
648
+
649
+ async listTouchedRows(tableNames = []) {
650
+ const tableSet = new Set(Array.isArray(tableNames) ? tableNames : [tableNames]);
651
+ return [...this.touched.values()]
652
+ .filter((entry) => tableSet.size === 0 || tableSet.has(entry.table))
653
+ .sort((left, right) => left.lastTouchedAt.localeCompare(right.lastTouchedAt))
654
+ .map(clone);
655
+ }
656
+
657
+ async clearTouchedRows(entries) {
658
+ for (const entry of entries ?? []) {
659
+ this.touched.delete(`${entry.table}:${entry.key}`);
660
+ }
661
+ }
662
+
663
+ async markTouchedRowError(table, key, error) {
664
+ const mapKey = `${table}:${key}`;
665
+ const existing = this.touched.get(mapKey);
666
+ if (!existing) {
667
+ return;
668
+ }
669
+ this.touched.set(mapKey, {
670
+ ...existing,
671
+ attemptCount: Number(existing.attemptCount ?? 0) + 1,
672
+ lastError: error ?? "Sync failed"
673
+ });
674
+ }
675
+
676
+ async getPendingMutations() {
677
+ return [...this.pending.values()].sort((left, right) => left.clientSequence - right.clientSequence);
678
+ }
679
+
680
+ async acknowledgeMutations(mutationIds) {
681
+ for (const mutationId of mutationIds) {
682
+ this.pending.delete(mutationId);
683
+ }
684
+ }
685
+
686
+ async getLastGlobalId() {
687
+ return this.lastGlobalId;
688
+ }
689
+
690
+ async setLastGlobalId(lastGlobalId) {
691
+ this.lastGlobalId = Number(lastGlobalId ?? 0);
692
+ }
693
+
694
+ async exportState() {
695
+ return {
696
+ clientSequence: this.clientSequence,
697
+ lastGlobalId: this.lastGlobalId,
698
+ pending: await this.getPendingMutations(),
699
+ touched: await this.listTouchedRows(),
700
+ rows: [...this.rows.entries()].map(([table, bucket]) => [table, [...bucket.entries()].map(([key, row]) => [key, clone(row)])])
701
+ };
702
+ }
703
+
704
+ async importState(state) {
705
+ this.clientSequence = Number(state?.clientSequence ?? 0);
706
+ this.lastGlobalId = Number(state?.lastGlobalId ?? state?.watermark ?? state?.cursors?.global ?? 0);
707
+ this.pending.clear();
708
+ this.touched.clear();
709
+ this.rows.clear();
710
+
711
+ for (const mutation of state?.pending ?? []) {
712
+ this.pending.set(mutation.localId ?? mutation.mutationId, clone(mutation));
713
+ }
714
+
715
+ for (const entry of state?.touched ?? []) {
716
+ this.touched.set(`${entry.table}:${entry.key}`, clone(entry));
717
+ }
718
+
719
+ for (const [table, entries] of state?.rows ?? []) {
720
+ const bucket = this.#bucket(table);
721
+ for (const [key, row] of entries) {
722
+ bucket.set(key, clone(row));
723
+ }
724
+ }
725
+ }
726
+
727
+ #bucket(table) {
728
+ if (!this.rows.has(table)) {
729
+ this.rows.set(table, new Map());
730
+ }
731
+
732
+ return this.rows.get(table);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * @param {ConstructorParameters<typeof OfflineDbClient>[0]} options
738
+ */
739
+ function resolvePersistence(options) {
740
+ if (!options.persistence || options.persistence === "indexeddb") {
741
+ return new IndexedDbPersistence({
742
+ databaseName: options.databaseName ?? "offlinedb",
743
+ indexedDb: options.indexedDb
744
+ });
745
+ }
746
+
747
+ if (options.persistence === "memory") {
748
+ return new InMemoryPersistence();
749
+ }
750
+
751
+ if (options.persistence === "localstorage") {
752
+ return new LocalStoragePersistence({
753
+ storageKey: `${options.databaseName ?? "offlinedb"}:snapshot`,
754
+ localStorage: options.localStorage
755
+ });
756
+ }
757
+
758
+ return options.persistence;
759
+ }
760
+
761
+ /**
762
+ * @typedef {{
763
+ * initialize: () => Promise<void>,
764
+ * nextClientSequence: () => Promise<number>,
765
+ * getRow: (table: string, key: string) => Promise<Record<string, unknown> | null>,
766
+ * listRows: (table: string) => Promise<Record<string, unknown>[]>,
767
+ * saveRow: (table: string, key: string, row: Record<string, unknown>) => Promise<void>,
768
+ * deleteRow: (table: string, key: string) => Promise<void>,
769
+ * appendPendingMutation: (mutation: PendingMutation) => Promise<void>,
770
+ * recordTouchedRow?: (entry: {table: string, key: string, operation?: "upsert" | "delete", occurredAt?: string}) => Promise<void>,
771
+ * listTouchedRows?: (tableNames?: string[]) => Promise<TouchedRow[]>,
772
+ * clearTouchedRows?: (entries: TouchedRow[]) => Promise<void>,
773
+ * markTouchedRowError?: (table: string, key: string, error: string) => Promise<void>,
774
+ * getPendingMutations: () => Promise<PendingMutation[]>,
775
+ * acknowledgeMutations: (mutationIds: string[]) => Promise<void>,
776
+ * getLastGlobalId: () => Promise<number>,
777
+ * setLastGlobalId: (lastGlobalId: number) => Promise<void>,
778
+ * exportState: () => Promise<unknown>
779
+ * }} PersistenceLayer
780
+ *
781
+ * @typedef {{
782
+ * open: (name: string, version: number) => IDBOpenDBRequest
783
+ * }} IDBFactory
784
+ *
785
+ * @typedef {{
786
+ * getItem: (key: string) => string | null,
787
+ * setItem: (key: string, value: string) => void
788
+ * }} StorageLike
789
+ */
790
+
791
+ /**
792
+ * @param {{baseUrl: string, fetchImpl?: typeof fetch, getAuthToken?: () => Promise<string | null> | string | null, syncPath?: string}} options
793
+ */
794
+ export function createNeonHttpTransport(options) {
795
+ const fetchImpl = options.fetchImpl ?? fetch;
796
+ const syncPath = options.syncPath ?? "rpc/offlinedb_sync";
797
+
798
+ return {
799
+ async sync(request) {
800
+ const token = await options.getAuthToken?.();
801
+ const response = await fetchImpl(joinUrl(options.baseUrl, syncPath), {
802
+ method: "POST",
803
+ headers: {
804
+ "content-type": "application/json",
805
+ ...(token ? { authorization: `Bearer ${token}` } : {})
806
+ },
807
+ body: JSON.stringify(request)
808
+ });
809
+
810
+ if (!response.ok) {
811
+ throw new Error(`Sync failed with status ${response.status}`);
812
+ }
813
+
814
+ return response.json();
815
+ }
816
+ };
817
+ }
818
+
819
+ function joinUrl(baseUrl, path) {
820
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
821
+ return new URL(path, normalizedBase);
822
+ }
823
+
824
+ function clone(value) {
825
+ return value == null ? value : JSON.parse(JSON.stringify(value));
826
+ }