@appurist/offlinedb 1.0.1 → 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.
- package/package.json +40 -39
- package/src/client.js +826 -587
- package/src/neon.js +274 -209
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* @param {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async
|
|
403
|
-
return this.memory.
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async
|
|
407
|
-
|
|
408
|
-
this
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
this.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
async
|
|
436
|
-
this
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
async
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
+
}
|