@jcbuisson/express-x-plugins 5.0.7 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1023 @@
1
+ // Restored from express-x-client commit 944c398.
2
+ import Dexie from "dexie";
3
+ import { from, defer } from 'rxjs';
4
+ import { distinctUntilChanged, finalize, startWith, switchMap } from 'rxjs/operators';
5
+ import { liveQuery } from "dexie";
6
+ // uuidv7 are monotonically increasing and much improve database performance amid B-tree indexes
7
+ import { v7 as uuidv7 } from 'uuid';
8
+ import { tryOnScopeDispose } from '@vueuse/core';
9
+
10
+
11
+
12
+ ////////////////////////// OFFLINE PLUGIN //////////////////////////
13
+ // Enrich `app` with methods, attributes and listeners to handle offline-first crud database access
14
+
15
+ export function offlinePlugin(app, options = {}) {
16
+ if (typeof options.cacheNamespace !== 'string' || !options.cacheNamespace.trim()) {
17
+ throw new TypeError('offlinePlugin requires a non-empty cacheNamespace')
18
+ }
19
+
20
+ const modelSyncFunctions = new Set()
21
+ const syncScopeRefCounts = new Map()
22
+
23
+ function createOfflineModel(modelName, fields) {
24
+
25
+ const dbName = `${options.cacheNamespace}:${modelName}`;
26
+ const db = getOrCreateDB(dbName, fields);
27
+ const synchronizedWhereKeys = new Set();
28
+ const synchronizeWherePromises = new Map();
29
+ const ownedWhereCounts = new Map();
30
+
31
+ const reset = async () => {
32
+ console.log('reset', modelName);
33
+ synchronizedWhereKeys.clear()
34
+ await db.transaction('rw', [db.values, db.metadata], async () => {
35
+ await db.values.clear();
36
+ await db.metadata.clear();
37
+ })
38
+ };
39
+
40
+
41
+ ///////////// PUB / SUB /////////////
42
+
43
+ const removeCreateListener = app.service(modelName).on('createWithMeta', async ([value, meta]) => {
44
+ console.log(`${modelName} EVENT createWithMeta`, value);
45
+ if (!isValidServiceResultIdentity(value, meta)) return
46
+ await db.transaction('rw', [db.values, db.metadata], async () => {
47
+ if (await isIncomingEventStale(value?.uid ?? meta?.uid, meta)) return
48
+ if (meta?.deleted_at) {
49
+ await db.values.delete(meta.uid)
50
+ await db.metadata.delete(meta.uid)
51
+ return
52
+ }
53
+ if (value?.uid) await db.values.put(sanitizeServerValue(value));
54
+ if (meta?.uid) await db.metadata.put({ ...meta, __dirty__: false });
55
+ })
56
+ });
57
+
58
+ const removeUpdateListener = app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
59
+ console.log(`${modelName} EVENT updateWithMeta`, value);
60
+ if (!isValidServiceResultIdentity(value, meta)) return
61
+ // value may be undefined when the server's update yielded 0 rows
62
+ // (concurrent delete race: record was removed between the sync's findMany
63
+ // snapshot and the actual update). Guard to avoid a TypeError crash that
64
+ // would prevent db.metadata.put(meta) from running.
65
+ await db.transaction('rw', [db.values, db.metadata], async () => {
66
+ if (await isIncomingEventStale(value?.uid ?? meta?.uid, meta)) return
67
+ if (meta?.deleted_at) {
68
+ await db.values.delete(meta.uid)
69
+ await db.metadata.delete(meta.uid)
70
+ return
71
+ }
72
+ if (value?.uid) await db.values.put(sanitizeServerValue(value));
73
+ if (meta?.uid) await db.metadata.put({ ...meta, __dirty__: false });
74
+ })
75
+ });
76
+
77
+ const removeDeleteListener = app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
78
+ console.log(`${modelName} EVENT deleteWithMeta`, value)
79
+ if (!isValidServiceResultIdentity(value, meta)) return
80
+ // value may be undefined when the server's delete yielded 0 rows
81
+ // (double-delete race).
82
+ // delete, not put: synchronize() step 2 also deletes idbMetadata for the same
83
+ // uid. If the pub/sub handler fires AFTER step 2, put() would re-create the
84
+ // metadata row as a permanent orphan. delete() is idempotent regardless of order.
85
+ const uid = value?.uid ?? meta?.uid
86
+ await db.transaction('rw', [db.values, db.metadata], async () => {
87
+ if (await isIncomingEventStale(uid, meta)) return
88
+ if (value?.uid) await db.values.delete(value.uid)
89
+ if (uid) await db.metadata.delete(uid)
90
+ })
91
+ });
92
+
93
+ async function isIncomingEventStale(uid, incomingMeta) {
94
+ if (!uid || !incomingMeta) return false
95
+ const currentMeta = await db.metadata.get(uid)
96
+ if (!currentMeta) return false
97
+ return compareMetadataTime(currentMeta, incomingMeta) > 0
98
+ }
99
+
100
+
101
+ ///////////// CREATE/UPDATE/REMOVE /////////////
102
+
103
+ async function create(data) {
104
+ // in offline-first context, uid is created client-side, since server may not be accessible
105
+ const uid = uuidv7()
106
+ // optimistic update
107
+ const now = new Date()
108
+ const safeData = sanitizeMutationData(data)
109
+ await db.transaction('rw', [db.values, db.metadata], async () => {
110
+ await db.values.add({ ...safeData, uid })
111
+ await db.metadata.add({ uid, created_at: now, __dirty__: true, __operation__: 'create' })
112
+ })
113
+ // execute on server, asynchronously, if connection is active
114
+ if (app.isConnected) {
115
+ app.service(modelName).createWithMeta(uid, safeData, now)
116
+ .then(result => applyCreateAcknowledgement(uid, now, result))
117
+ .catch(async err => {
118
+ console.log(`*** err sync ${modelName} create`, err)
119
+ if (!isDefinitiveServiceError(err)) return
120
+ const currentMetadata = await db.metadata.get(uid)
121
+ if (!isCreateRequestStillCurrent(currentMetadata, now)) return
122
+ // rollback
123
+ await db.values.delete(uid)
124
+ await db.metadata.delete(uid)
125
+ })
126
+ }
127
+ return await db.values.get(uid)
128
+ }
129
+
130
+ async function applyCreateAcknowledgement(uid, requestCreatedAt, result) {
131
+ const [value, meta] = validateServiceResult(result, uid)
132
+ await db.transaction('rw', [db.values, db.metadata], async () => {
133
+ const currentMetadata = await db.metadata.get(uid)
134
+ if (!isCreateRequestStillCurrent(currentMetadata, requestCreatedAt)) return
135
+ if (meta?.deleted_at) {
136
+ await db.values.delete(uid)
137
+ await db.metadata.delete(uid)
138
+ return
139
+ }
140
+ if (value?.uid) await db.values.put(sanitizeServerValue(value))
141
+ await db.metadata.put({ ...meta, __dirty__: false })
142
+ })
143
+ }
144
+
145
+ function isCreateRequestStillCurrent(currentMetadata, requestCreatedAt) {
146
+ return currentMetadata
147
+ && sameTimestamp(currentMetadata.created_at, requestCreatedAt)
148
+ && !currentMetadata.updated_at
149
+ && !currentMetadata.deleted_at
150
+ }
151
+
152
+ async function applyUpdateAcknowledgement(uid, requestUpdatedAt, result) {
153
+ const [value, meta] = validateServiceResult(result, uid)
154
+ await db.transaction('rw', [db.values, db.metadata], async () => {
155
+ const currentMetadata = await db.metadata.get(uid)
156
+ if (!currentMetadata || !sameTimestamp(currentMetadata.updated_at, requestUpdatedAt)) return
157
+ if (meta?.deleted_at) {
158
+ await db.values.delete(uid)
159
+ await db.metadata.delete(uid)
160
+ return
161
+ }
162
+ if (value?.uid) await db.values.put(sanitizeServerValue(value))
163
+ await db.metadata.put({ ...meta, __dirty__: false })
164
+ })
165
+ }
166
+
167
+ async function applyDeleteAcknowledgement(uid, requestDeletedAt, result) {
168
+ const [value, meta] = validateServiceResult(result, uid)
169
+ await db.transaction('rw', [db.values, db.metadata], async () => {
170
+ const currentMetadata = await db.metadata.get(uid)
171
+ if (!currentMetadata || !sameTimestamp(currentMetadata.deleted_at, requestDeletedAt)) return
172
+ if (meta?.uid && compareMetadataTime(currentMetadata, meta) > 0) return
173
+ if (meta?.deleted_at && !sameTimestamp(meta.deleted_at, requestDeletedAt)) {
174
+ await db.values.delete(uid)
175
+ await db.metadata.delete(uid)
176
+ return
177
+ }
178
+ if (value?.uid && !meta?.deleted_at) {
179
+ const restoredValue = { ...value }
180
+ delete restoredValue.__deleted__
181
+ await db.values.put(restoredValue)
182
+ }
183
+ await db.metadata.put({ ...meta, __dirty__: false })
184
+ })
185
+ }
186
+
187
+ const update = async (uid, data) => {
188
+ const safeData = sanitizeMutationData(data)
189
+ let previousValue
190
+ let previousMetadata
191
+ let fullUpdatedData
192
+ let now
193
+ const updated = await db.transaction('rw', [db.values, db.metadata], async () => {
194
+ const existingValue = await db.values.get(uid)
195
+ if (!existingValue) return false
196
+ previousValue = { ...existingValue }
197
+ previousMetadata = { ...(await db.metadata.get(uid)) }
198
+ now = nextMutationTimestamp(previousMetadata)
199
+ await db.values.update(uid, safeData)
200
+ fullUpdatedData = sanitizeMutationData({ ...existingValue, ...safeData })
201
+ const operation = previousMetadata.__operation__ === 'create' ? 'create' : 'update'
202
+ await db.metadata.put({ uid, ...previousMetadata, updated_at: now, __dirty__: true, __operation__: operation })
203
+ return true
204
+ })
205
+ if (!updated) return undefined
206
+ // execute on server, asynchronously, if connection is active
207
+ if (app.isConnected) {
208
+ app.service(modelName).updateWithMeta(uid, fullUpdatedData, now)
209
+ .then(result => applyUpdateAcknowledgement(uid, now, result))
210
+ .catch(async err => {
211
+ console.log(`*** err sync ${modelName} update`, err)
212
+ if (!isDefinitiveServiceError(err)) return
213
+ await db.transaction('rw', [db.values, db.metadata], async () => {
214
+ const currentMetadata = await db.metadata.get(uid)
215
+ if (!currentMetadata || !sameTimestamp(currentMetadata.updated_at, now)) return
216
+ const currentValue = await db.values.get(uid)
217
+ const currentDeletedAt = currentMetadata.deleted_at ?? (currentValue?.__deleted__ ? new Date() : null)
218
+ delete previousValue.uid
219
+ await db.values.update(uid, previousValue)
220
+ const rollbackMetadata = {
221
+ updated_at: previousMetadata.updated_at ?? null,
222
+ __dirty__: currentDeletedAt ? currentMetadata.__dirty__ : (previousMetadata.__dirty__ ?? false),
223
+ }
224
+ if (currentDeletedAt) rollbackMetadata.deleted_at = currentDeletedAt
225
+ await db.metadata.update(uid, rollbackMetadata)
226
+ })
227
+ })
228
+ }
229
+ return await db.values.get(uid)
230
+ }
231
+
232
+ const remove = async (uid) => {
233
+ let previousMetadata
234
+ let previousDeletedMarker
235
+ let deleted_at
236
+ const removed = await db.transaction('rw', [db.values, db.metadata], async () => {
237
+ const existingValue = await db.values.get(uid)
238
+ if (!existingValue) return false
239
+ previousDeletedMarker = existingValue.__deleted__
240
+ previousMetadata = { ...(await db.metadata.get(uid)) }
241
+ deleted_at = nextMutationTimestamp(previousMetadata)
242
+ await db.values.update(uid, { __deleted__: true })
243
+ await db.metadata.put({ uid, ...previousMetadata, deleted_at, __dirty__: true, __operation__: 'delete' })
244
+ return true
245
+ })
246
+ if (!removed) return undefined
247
+ // and in database, if connected
248
+ if (app.isConnected) {
249
+ app.service(modelName).deleteWithMeta(uid, deleted_at)
250
+ .then(result => applyDeleteAcknowledgement(uid, deleted_at, result))
251
+ .catch(async err => {
252
+ console.log(`*** err sync ${modelName} remove`, err)
253
+ if (!isDefinitiveServiceError(err)) return
254
+ await db.transaction('rw', [db.values, db.metadata], async () => {
255
+ const currentMetadata = await db.metadata.get(uid)
256
+ if (!currentMetadata || !sameTimestamp(currentMetadata.deleted_at, deleted_at)) return
257
+ await db.values.update(uid, { __deleted__: previousDeletedMarker ?? null })
258
+ await db.metadata.put({ uid, ...previousMetadata })
259
+ })
260
+ })
261
+ }
262
+ }
263
+
264
+ ///////////// DIRECT CACHE ACCESS /////////////
265
+
266
+ async function findByUID(uid) {
267
+ const value = await db.values.get(uid)
268
+ return value?.__deleted__ ? undefined : value
269
+ }
270
+
271
+ function findWhere(where = {}) {
272
+ const predicate = wherePredicate(where)
273
+ return db.values.filter(value => !value.__deleted__ && predicate(value)).toArray()
274
+ }
275
+
276
+ ///////////// REAL-TIME OBSERVABLE /////////////
277
+
278
+ function getObservable(where = {}) {
279
+ const predicate = wherePredicate(where)
280
+ const liveQuery$ = from(liveQuery(() => db.values.filter(value => !value.__deleted__ && predicate(value)).toArray())).pipe(
281
+ distinctUntilChanged((prev, curr) => {
282
+ // Deep equality check to prevent unnecessary emissions (in particular on database write)
283
+ return JSON.stringify(prev) === JSON.stringify(curr)
284
+ })
285
+ )
286
+
287
+ // Delay subscribing to liveQuery until the cache is up to date: if `where` is a
288
+ // newly-registered filter and we're online, wait for synchronize() to complete
289
+ // first, so the first emission is already the fully synced data (or [] if truly
290
+ // empty). Otherwise (warm cache or offline) there's nothing to wait for.
291
+ //
292
+ // defer() gates the liveQuery subscription:
293
+ // - defer re-runs the setup on each subscription, calling addSynchroWhere(where) to get isNew.
294
+ // - If isNew && app.isConnected, it chains synchronize(...) before switching to liveQuery$, so the first emission already reflects
295
+ // the fully-synced cache ([] only if genuinely empty).
296
+ // - If !isNew or offline, the .then resolves to undefined immediately and switchMap subscribes to liveQuery$ right away — same
297
+ // behavior as before.
298
+ return defer(() => {
299
+ const ready = addSynchroWhere(where).then((isNew) => {
300
+ const whereKey = stringifyWithSortedKeys(where)
301
+ if (app.isConnected && (isNew || !synchronizedWhereKeys.has(whereKey))) {
302
+ return synchronizeWhere(where)
303
+ }
304
+ })
305
+ return from(ready).pipe(
306
+ switchMap(() => liveQuery$),
307
+ finalize(() => { void removeSynchroWhere(where) }),
308
+ )
309
+ })
310
+ }
311
+
312
+ let count = 0;
313
+
314
+ function addSynchroWhere(where) {
315
+ validateWhere(where)
316
+ const whereKey = stringifyWithSortedKeys(where)
317
+ ownedWhereCounts.set(whereKey, (ownedWhereCounts.get(whereKey) ?? 0) + 1)
318
+ const refKey = syncScopeRefKey(dbName, whereKey)
319
+ syncScopeRefCounts.set(refKey, (syncScopeRefCounts.get(refKey) ?? 0) + 1)
320
+ const promise = addSynchroDBWhere(where, db.whereList)
321
+ promise.then(isNew => isNew && console.log(`addSynchroWhere (${++count})`, dbName, modelName, where))
322
+ return promise
323
+ }
324
+
325
+ function removeSynchroWhere(where) {
326
+ console.log('removeSynchroWhere', dbName, modelName, where)
327
+ const whereKey = stringifyWithSortedKeys(where)
328
+ const ownedCount = ownedWhereCounts.get(whereKey) ?? 0
329
+ if (ownedCount > 1) {
330
+ ownedWhereCounts.set(whereKey, ownedCount - 1)
331
+ } else {
332
+ ownedWhereCounts.delete(whereKey)
333
+ synchronizedWhereKeys.delete(whereKey)
334
+ }
335
+
336
+ const refKey = syncScopeRefKey(dbName, whereKey)
337
+ const nextRefCount = (syncScopeRefCounts.get(refKey) ?? 1) - 1
338
+ if (nextRefCount > 0) {
339
+ syncScopeRefCounts.set(refKey, nextRefCount)
340
+ return Promise.resolve(false)
341
+ }
342
+
343
+ syncScopeRefCounts.delete(refKey)
344
+ count -= 1
345
+ return removeSynchroDBWhere(where, db.whereList)
346
+ }
347
+
348
+ async function synchronizeAll() {
349
+ await flushDirtyMutations(modelName, db.values, db.metadata)
350
+ await synchronizeModelWhereList(modelName, db.values, db.metadata, db.whereList, synchronizeWhere)
351
+ }
352
+
353
+ async function synchronizeWhere(where) {
354
+ const whereKey = stringifyWithSortedKeys(where)
355
+ if (!synchronizeWherePromises.has(whereKey)) {
356
+ const promise = synchronize(modelName, db.values, db.metadata, where)
357
+ .then(() => {
358
+ synchronizedWhereKeys.add(whereKey)
359
+ })
360
+ .finally(() => {
361
+ synchronizeWherePromises.delete(whereKey)
362
+ })
363
+ synchronizeWherePromises.set(whereKey, promise)
364
+ }
365
+ return synchronizeWherePromises.get(whereKey)
366
+ }
367
+
368
+ // Automatically clean up when the component using this composable unmounts
369
+ tryOnScopeDispose(async () => {
370
+ console.log('CLEANING', dbName, modelName)
371
+ modelSyncFunctions.delete(synchronizeAll)
372
+ removeCreateListener()
373
+ removeUpdateListener()
374
+ removeDeleteListener()
375
+ const ownedWhereEntries = [...ownedWhereCounts.entries()]
376
+ for (const [whereKey, ownedCount] of ownedWhereEntries) {
377
+ const where = JSON.parse(whereKey)
378
+ for (let i = 0; i < ownedCount; i++) {
379
+ await removeSynchroWhere(where)
380
+ }
381
+ }
382
+ })
383
+
384
+ modelSyncFunctions.add(synchronizeAll)
385
+
386
+ return {
387
+ db, reset,
388
+ create, update, remove,
389
+ findByUID, findWhere,
390
+ getObservable,
391
+ synchronizeAll,
392
+ addSynchroWhere,
393
+ }
394
+ }
395
+
396
+ let hasConnected = false
397
+
398
+ app.addConnectListener(async (_socket) => {
399
+ app.connectedDate = new Date()
400
+ console.log('onConnect', app.connectedDate)
401
+ app.isConnected = true
402
+ const disconnectedDate = app.disconnectedDate
403
+ const isInitialConnect = !hasConnected
404
+ hasConnected = true
405
+ if (disconnectedDate || isInitialConnect) {
406
+ const results = await Promise.allSettled([...modelSyncFunctions].map(sync => sync()))
407
+ const failures = results.filter(result => result.status === 'rejected')
408
+ if (failures.length > 0) {
409
+ console.error('err reconnect synchronizeAll', failures.map(result => result.reason))
410
+ return
411
+ }
412
+ }
413
+ app.disconnectedDate = null
414
+ })
415
+
416
+ app.addDisconnectListener(async (_socket) => {
417
+ app.connectedDate = null
418
+ app.disconnectedDate = new Date()
419
+ console.log('onDisconnect', app.disconnectedDate)
420
+ app.isConnected = false
421
+ })
422
+
423
+
424
+ const mutex = new Mutex()
425
+
426
+ async function flushDirtyMutations(modelName, idbValues, idbMetadata) {
427
+ await mutex.acquire()
428
+ try {
429
+ const dirtyMetadataList = await idbMetadata
430
+ .filter(metadata => metadata.__dirty__ && metadata.__operation__)
431
+ .toArray()
432
+
433
+ for (const requestMetadata of dirtyMetadataList) {
434
+ if (requestMetadata.uid == null) continue
435
+ const currentMetadata = await idbMetadata.get(requestMetadata.uid)
436
+ if (!metadataUnchangedSinceRequest(currentMetadata, requestMetadata)) continue
437
+
438
+ try {
439
+ let result
440
+ if (requestMetadata.__operation__ === 'delete' || requestMetadata.deleted_at) {
441
+ result = await app.service(modelName).deleteWithMeta(requestMetadata.uid, requestMetadata.deleted_at)
442
+ } else {
443
+ const fullValue = await idbValues.get(requestMetadata.uid)
444
+ if (fullValue == null || fullValue.__deleted__) continue
445
+ const safeData = sanitizeMutationData(fullValue)
446
+
447
+ if (requestMetadata.__operation__ === 'create' && requestMetadata.created_at) {
448
+ result = await app.service(modelName).createWithMeta(requestMetadata.uid, safeData, requestMetadata.created_at)
449
+ if (requestMetadata.updated_at) {
450
+ const [, createMeta] = validateServiceResult(result, requestMetadata.uid)
451
+ if (createMeta.deleted_at && compareMetadataTime(createMeta, requestMetadata) >= 0) {
452
+ // A newer/equal server tombstone wins; keep it as the final
453
+ // acknowledgement instead of resurrecting with the local update.
454
+ } else {
455
+ result = await app.service(modelName).updateWithMeta(requestMetadata.uid, safeData, requestMetadata.updated_at)
456
+ }
457
+ }
458
+ } else if (requestMetadata.updated_at) {
459
+ result = await app.service(modelName).updateWithMeta(requestMetadata.uid, safeData, requestMetadata.updated_at)
460
+ } else if (requestMetadata.created_at) {
461
+ result = await app.service(modelName).createWithMeta(requestMetadata.uid, safeData, requestMetadata.created_at)
462
+ } else {
463
+ continue
464
+ }
465
+ }
466
+
467
+ const [serverValue, serverMeta] = validateServiceResult(result, requestMetadata.uid)
468
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
469
+ const latestMetadata = await idbMetadata.get(requestMetadata.uid)
470
+ if (!metadataUnchangedSinceRequest(latestMetadata, requestMetadata)) return
471
+ if (serverMeta.deleted_at) {
472
+ await idbValues.delete(requestMetadata.uid)
473
+ await idbMetadata.delete(requestMetadata.uid)
474
+ return
475
+ }
476
+ if (serverValue?.uid) await idbValues.put(sanitizeServerValue(serverValue))
477
+ await idbMetadata.put({ ...serverMeta, __dirty__: false })
478
+ })
479
+ } catch(err) {
480
+ console.log("*** err flush dirty mutation", modelName, requestMetadata.uid, err)
481
+ // Keep the durable dirty marker. The mutation is idempotent and will
482
+ // be retried on the next reconnect or explicit synchronizeAll().
483
+ }
484
+ }
485
+ } finally {
486
+ mutex.release()
487
+ }
488
+ }
489
+
490
+ // ex: where = { uid: 'azer' }
491
+ async function synchronize(modelName, idbValues, idbMetadata, where) {
492
+ await mutex.acquire()
493
+ console.log('synchronize', modelName, where)
494
+
495
+ try {
496
+ const requestPredicate = wherePredicate(where)
497
+
498
+ // collect meta-data of local values
499
+ // NOTE: __delete__ on values allows to collect metadata from cache-deleted values
500
+ const valueList = await idbValues.filter(requestPredicate).toArray()
501
+ const clientMetadataDict = Object.create(null)
502
+ for (const value of valueList) {
503
+ const metadata = await idbMetadata.get(value.uid)
504
+ if (metadata) {
505
+ clientMetadataDict[value.uid] = metadata
506
+ } else {
507
+ // Repair old/corrupt IndexedDB state where a visible value exists
508
+ // without metadata; otherwise the server receives {} and cannot sync it.
509
+ // Use the oldest possible timestamp: a repaired cache row has unknown
510
+ // provenance and must not beat a real server version or tombstone.
511
+ const repairedMetadata = { uid: value.uid, created_at: new Date(0), __dirty__: true, __repaired__: true }
512
+ await idbMetadata.put(repairedMetadata)
513
+ clientMetadataDict[value.uid] = repairedMetadata
514
+ }
515
+ }
516
+ const dirtyMetadataList = await idbMetadata.filter(metadata => metadata.__dirty__).toArray()
517
+ for (const metadata of dirtyMetadataList) {
518
+ if (Object.hasOwn(clientMetadataDict, metadata.uid)) continue
519
+ const value = await idbValues.get(metadata.uid)
520
+ if (value) {
521
+ if (requestPredicate(value)) clientMetadataDict[metadata.uid] = metadata
522
+ } else if (metadata.deleted_at && Object.keys(where).length === 0) {
523
+ clientMetadataDict[metadata.uid] = metadata
524
+ }
525
+ }
526
+
527
+ // call sync service on `where` perimeter
528
+ const syncResult = validateSyncResult(
529
+ await app.service('sync').go(modelName, where, clientMetadataDict),
530
+ )
531
+ const { addClient, updateClient, deleteClient, addDatabase, updateDatabase } = syncResult
532
+ console.log('-> service.sync', modelName, where, addClient, updateClient, deleteClient, addDatabase, updateDatabase)
533
+
534
+ // 1- add missing elements in indexedDB cache
535
+ // Use a single transaction for all adds to ensure atomicity.
536
+ // put() instead of add() for metadata: a deleteWithMeta pub/sub event leaves
537
+ // an orphaned metadata row (value deleted, metadata kept with deleted_at).
538
+ // add() would throw a ConstraintError on that orphan; put() upserts safely.
539
+ if (addClient.length > 0) {
540
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
541
+ for (const [value, metaData] of addClient) {
542
+ // put() instead of add(): if create() ran concurrently and added this
543
+ // uid to Dexie between the idbValues.filter snapshot and this step,
544
+ // add() would throw ConstraintError and abort the entire transaction,
545
+ // silently dropping every other addClient record in the batch.
546
+ const currentMetadata = await idbMetadata.get(value.uid)
547
+ if (currentMetadata
548
+ && compareMetadataTime(metaData, currentMetadata) <= 0
549
+ && !(currentMetadata.deleted_at && !metaData.deleted_at)) continue
550
+ await idbValues.put(sanitizeServerValue(value))
551
+ await idbMetadata.put({ ...metaData, __dirty__: false })
552
+ }
553
+ })
554
+ }
555
+ // 2- delete elements from indexedDB cache
556
+ if (deleteClient.length > 0) {
557
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
558
+ for (const [uid, deletedAt] of deleteClient) {
559
+ const currentMetadata = await idbMetadata.get(uid)
560
+ // A deleteWithMeta pub/sub event may already have removed both rows
561
+ // while this sync request was in flight. Do not recreate a clean
562
+ // tombstone after that successful acknowledgement.
563
+ if (!currentMetadata && clientMetadataDict[uid]) continue
564
+ const unchanged = metadataUnchangedSinceRequest(currentMetadata, clientMetadataDict[uid])
565
+ if (!unchanged && compareMetadataTime(currentMetadata, { uid, deleted_at: deletedAt }) > 0) continue
566
+ await idbValues.delete(uid)
567
+ if (unchanged) await idbMetadata.delete(uid)
568
+ else await idbMetadata.put({ uid, deleted_at: deletedAt, __dirty__: false })
569
+ }
570
+ })
571
+ }
572
+ // 3- update elements of cache with server's newer version
573
+ for (const [elt, serverMeta] of updateClient) {
574
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
575
+ const currentMetadata = await idbMetadata.get(elt.uid)
576
+ if (!metadataUnchangedSinceRequest(currentMetadata, clientMetadataDict[elt.uid])
577
+ && compareMetadataTime(currentMetadata, serverMeta) > 0) return
578
+ const value = { ...elt }
579
+ delete value.__deleted__
580
+ await idbValues.put(value)
581
+ await idbMetadata.put({ uid: elt.uid, ...serverMeta, __dirty__: false })
582
+ })
583
+ }
584
+
585
+ // 4- create elements of `addDatabase` with full data from cache
586
+ for (const elt of addDatabase) {
587
+ // elt.uid is undefined when the clientMetadataDict fallback {} was used
588
+ // (record exists in idbValues but metadata is missing). Guard before the
589
+ // get() call: idbValues.get(undefined) itself throws before fullValue is
590
+ // assigned, so checking fullValue == null afterwards is too late.
591
+ if (elt.uid == null) continue
592
+ let currentMetadata = await idbMetadata.get(elt.uid)
593
+ if (!metadataUnchangedSinceRequest(currentMetadata, elt)) continue
594
+ const fullValue = await idbValues.get(elt.uid)
595
+ if (fullValue == null) continue // record deleted concurrently
596
+ if (!requestPredicate(fullValue)) continue
597
+ delete fullValue.uid
598
+ delete fullValue.__deleted__
599
+ try {
600
+ const result = await app.service(modelName).createWithMeta(elt.uid, fullValue, elt.created_at)
601
+ const [, serverMeta] = validateServiceResult(result, elt.uid)
602
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
603
+ currentMetadata = await idbMetadata.get(elt.uid)
604
+ if (!metadataUnchangedSinceRequest(currentMetadata, elt)) return
605
+ if (serverMeta.deleted_at) {
606
+ await idbValues.delete(elt.uid)
607
+ await idbMetadata.delete(elt.uid)
608
+ return
609
+ }
610
+ if (result[0]?.uid) {
611
+ const returnedValue = { ...result[0] }
612
+ delete returnedValue.__deleted__
613
+ await idbValues.put(returnedValue)
614
+ }
615
+ await idbMetadata.put({ ...serverMeta, __dirty__: false })
616
+ })
617
+ } catch(err) {
618
+ console.log("*** err sync user addDatabase", err, elt.uid, fullValue, elt.created_at)
619
+ // A failed request may be a timeout or disconnect after the server
620
+ // committed. Keep the local mutation dirty so the idempotent server
621
+ // operation can reconcile it on the next sync.
622
+ }
623
+ }
624
+
625
+ // 5- update elements of `updateDatabase` with full data from cache
626
+ for (const elt of updateDatabase) {
627
+ if (elt.uid == null) continue
628
+ let currentMetadata = await idbMetadata.get(elt.uid)
629
+ if (!metadataUnchangedSinceRequest(currentMetadata, elt)) continue
630
+ const fullValue = await idbValues.get(elt.uid)
631
+ if (fullValue == null) continue // record deleted concurrently
632
+ delete fullValue.uid
633
+ delete fullValue.__deleted__
634
+ try {
635
+ const updateTimestamp = elt.updated_at ?? elt.created_at
636
+ const result = await app.service(modelName).updateWithMeta(elt.uid, fullValue, updateTimestamp)
637
+ const [, serverMeta] = validateServiceResult(result, elt.uid)
638
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
639
+ currentMetadata = await idbMetadata.get(elt.uid)
640
+ if (!metadataUnchangedSinceRequest(currentMetadata, elt)) return
641
+ if (serverMeta.deleted_at) {
642
+ await idbValues.delete(elt.uid)
643
+ await idbMetadata.delete(elt.uid)
644
+ return
645
+ }
646
+ if (result[0]?.uid) {
647
+ const returnedValue = { ...result[0] }
648
+ delete returnedValue.__deleted__
649
+ await idbValues.put(returnedValue)
650
+ }
651
+ await idbMetadata.put({ ...serverMeta, __dirty__: false })
652
+ })
653
+ } catch(err) {
654
+ console.log("*** err sync user updateDatabase", err)
655
+ // Leave client's local version intact; it will be retried on the next sync.
656
+ }
657
+ }
658
+ } catch(err) {
659
+ console.log('err synchronize', modelName, where, err)
660
+ throw err
661
+ } finally {
662
+ mutex.release()
663
+ }
664
+ }
665
+
666
+ function metadataUnchangedSinceRequest(currentMetadata, requestMetadata) {
667
+ return currentMetadata
668
+ && requestMetadata
669
+ && sameTimestamp(currentMetadata.created_at, requestMetadata.created_at)
670
+ && sameTimestamp(currentMetadata.updated_at, requestMetadata.updated_at)
671
+ && sameTimestamp(currentMetadata.deleted_at, requestMetadata.deleted_at)
672
+ }
673
+
674
+ // Singleton map to reuse Dexie instances per database name
675
+ const dbInstances = new Map();
676
+
677
+ function getOrCreateDB(dbName, fields) {
678
+ if (!dbInstances.has(dbName)) {
679
+ const db = new Dexie(dbName);
680
+ db.version(1).stores({
681
+ whereList: "sortedjson",
682
+ values: ['uid', '__deleted__', ...fields].join(','),
683
+ metadata: "uid, created_at, updated_at, deleted_at",
684
+ });
685
+ dbInstances.set(dbName, db);
686
+ }
687
+ return dbInstances.get(dbName);
688
+ }
689
+
690
+ async function getWhereList(whereDb) {
691
+ const list = await whereDb.toArray()
692
+ return list.map(elt => JSON.parse(elt.sortedjson))
693
+ }
694
+
695
+ async function addSynchroDBWhere(where, whereDb) {
696
+ await mutex.acquire()
697
+ let modified = false
698
+ try {
699
+ const sortedjson = stringifyWithSortedKeys(where)
700
+ const existing = await whereDb.get(sortedjson)
701
+ if (!existing) {
702
+ // sortedjson is used as a unique standardized representation of a 'where' object ; it is used both as key and value in 'wheredb' database
703
+ await whereDb.add({ sortedjson })
704
+ modified = true
705
+ }
706
+ } catch(err) {
707
+ console.log('err addSynchroDBWhere', where, err)
708
+ } finally {
709
+ mutex.release()
710
+ }
711
+ return modified
712
+ }
713
+
714
+ async function removeSynchroDBWhere(where, whereDb) {
715
+ await mutex.acquire()
716
+ try {
717
+ const swhere = stringifyWithSortedKeys(where)
718
+ await whereDb.filter(value => (value.sortedjson === swhere)).delete()
719
+ } catch(err) {
720
+ console.log('err removeSynchroDBWhere', err)
721
+ } finally {
722
+ mutex.release()
723
+ }
724
+ }
725
+
726
+ async function synchronizeModelWhereList(modelName, idbValues, idbMetadata, whereDb, syncWhere = null) {
727
+ const whereList = await getWhereList(whereDb)
728
+ for (const where of whereList) {
729
+ if (syncWhere) await syncWhere(where)
730
+ else await synchronize(modelName, idbValues, idbMetadata, where)
731
+ }
732
+ }
733
+
734
+ // enrich `app` with new methods and attributes
735
+ return Object.assign(app, {
736
+ createOfflineModel,
737
+ })
738
+ }
739
+
740
+
741
+ ////////////////////////// UTILITIES //////////////////////////
742
+
743
+
744
+ function stringifyWithSortedKeys(obj, space = null) {
745
+ return JSON.stringify(obj, (key, value) => {
746
+ // If the value is a plain object (not an array, null, or other object type like Date)
747
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.toString.call(value) === '[object Object]') {
748
+ const sorted = {};
749
+ // Get all keys, sort them, and then re-add them to a new object
750
+ // This new object will maintain the sorted order when stringified
751
+ Object.keys(value).sort().forEach(k => {
752
+ sorted[k] = value[k];
753
+ });
754
+ return sorted;
755
+ }
756
+ // For all other types (primitives, arrays, null, etc.), return the value as is
757
+ return value;
758
+ }, space); // 'space' is optional for pretty-printing (e.g., 2 or 4)
759
+ }
760
+ // console.log('stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" }})', stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" } }))
761
+
762
+ function sameTimestamp(a, b) {
763
+ if (!a || !b) return a === b
764
+ return new Date(a).getTime() === new Date(b).getTime()
765
+ }
766
+
767
+ function compareMetadataTime(a, b) {
768
+ const aTime = metadataTime(a)
769
+ const bTime = metadataTime(b)
770
+ if (aTime == null || bTime == null) return 0
771
+ return aTime - bTime
772
+ }
773
+
774
+ function syncScopeRefKey(dbName, whereKey) {
775
+ return `${dbName}\0${whereKey}`
776
+ }
777
+
778
+ function metadataTime(meta) {
779
+ const value = meta?.deleted_at ?? meta?.updated_at ?? meta?.created_at
780
+ if (!value) return null
781
+ const time = new Date(value).getTime()
782
+ return Number.isNaN(time) ? null : time
783
+ }
784
+
785
+ function nextMutationTimestamp(meta) {
786
+ const previousTime = metadataTime(meta)
787
+ const now = Date.now()
788
+ return new Date(previousTime == null ? now : Math.max(now, previousTime + 1))
789
+ }
790
+
791
+ function isDefinitiveServiceError(err) {
792
+ // Server responses are serialized plain objects. Socket.IO timeouts and
793
+ // disconnects reject with Error instances and are ambiguous: the server may
794
+ // already have committed the mutation before the acknowledgement was lost.
795
+ return Boolean(
796
+ err
797
+ && typeof err === 'object'
798
+ && !(err instanceof Error)
799
+ && typeof err.code === 'string',
800
+ )
801
+ }
802
+
803
+ function sanitizeMutationData(data) {
804
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
805
+ throw new TypeError('mutation data must be an object')
806
+ }
807
+ const { uid: _uid, __deleted__: _deleted, ...safeData } = data
808
+ return safeData
809
+ }
810
+
811
+ function isValidServiceResultIdentity(value, meta) {
812
+ return Boolean(
813
+ meta
814
+ && typeof meta === 'object'
815
+ && typeof meta.uid === 'string'
816
+ && (!value || (typeof value === 'object' && value.uid === meta.uid)),
817
+ )
818
+ }
819
+
820
+ function validateServiceResult(result, expectedUid) {
821
+ if (!Array.isArray(result) || result.length < 2) {
822
+ throw new TypeError('service mutation result must be a [value, metadata] tuple')
823
+ }
824
+ const [value, meta] = result
825
+ if (!isValidServiceResultIdentity(value, meta) || meta.uid !== expectedUid) {
826
+ throw new TypeError(`service mutation result uid must equal '${expectedUid}'`)
827
+ }
828
+ validateMetadata(meta)
829
+ return [value, meta]
830
+ }
831
+
832
+ function validateSyncResult(result) {
833
+ if (!result || typeof result !== 'object') throw new TypeError('sync result must be an object')
834
+ const keys = ['addClient', 'updateClient', 'deleteClient', 'addDatabase', 'updateDatabase']
835
+ for (const key of keys) {
836
+ if (!Array.isArray(result[key])) throw new TypeError(`sync result '${key}' must be an array`)
837
+ }
838
+ for (const tuple of [...result.addClient, ...result.updateClient]) {
839
+ const uid = tuple?.[0]?.uid
840
+ if (typeof uid !== 'string') throw new TypeError('sync value uid must be a string')
841
+ validateServiceResult(tuple, uid)
842
+ }
843
+ for (const tuple of result.deleteClient) {
844
+ if (!Array.isArray(tuple) || typeof tuple[0] !== 'string'
845
+ || Number.isNaN(new Date(tuple[1]).getTime())) {
846
+ throw new TypeError('sync deleteClient entry must be a [uid, timestamp] tuple')
847
+ }
848
+ }
849
+ for (const metadata of [...result.addDatabase, ...result.updateDatabase]) {
850
+ validateMetadata(metadata)
851
+ }
852
+ return result
853
+ }
854
+
855
+ function validateMetadata(metadata) {
856
+ if (!metadata || typeof metadata !== 'object' || typeof metadata.uid !== 'string') {
857
+ throw new TypeError('sync metadata must contain a string uid')
858
+ }
859
+ const timestampFields = ['created_at', 'updated_at', 'deleted_at']
860
+ if (!timestampFields.some(field => metadata[field] != null)) {
861
+ throw new TypeError(`sync metadata '${metadata.uid}' must contain a timestamp`)
862
+ }
863
+ for (const field of timestampFields) {
864
+ if (metadata[field] != null && Number.isNaN(new Date(metadata[field]).getTime())) {
865
+ throw new TypeError(`sync metadata '${metadata.uid}.${field}' must be a valid timestamp`)
866
+ }
867
+ }
868
+ }
869
+
870
+ function sanitizeServerValue(value) {
871
+ const { __deleted__: _deleted, ...safeValue } = value
872
+ return safeValue
873
+ }
874
+
875
+ function validateWhere(where) {
876
+ if (!where || typeof where !== 'object' || Array.isArray(where)
877
+ || Object.prototype.toString.call(where) !== '[object Object]') {
878
+ throw new TypeError('where must be a plain object')
879
+ }
880
+ validateJsonFilterValue(where, 'where')
881
+ return where
882
+ }
883
+
884
+ function validateJsonFilterValue(value, path) {
885
+ if (value === undefined || typeof value === 'function'
886
+ || typeof value === 'symbol' || typeof value === 'bigint') {
887
+ throw new TypeError(`${path} contains a non-serializable value`)
888
+ }
889
+ if (typeof value === 'number' && !Number.isFinite(value)) {
890
+ throw new TypeError(`${path} contains a non-finite number`)
891
+ }
892
+ if (value instanceof Date) {
893
+ if (Number.isNaN(value.getTime())) throw new TypeError(`${path} contains an invalid Date`)
894
+ return
895
+ }
896
+ if (Array.isArray(value)) {
897
+ value.forEach((entry, index) => validateJsonFilterValue(entry, `${path}[${index}]`))
898
+ return
899
+ }
900
+ if (value && typeof value === 'object') {
901
+ if (Object.prototype.toString.call(value) !== '[object Object]') {
902
+ throw new TypeError(`${path} contains a non-plain object`)
903
+ }
904
+ for (const [key, entry] of Object.entries(value)) {
905
+ validateJsonFilterValue(entry, `${path}.${key}`)
906
+ }
907
+ }
908
+ }
909
+
910
+ export class Mutex {
911
+ constructor() {
912
+ this.locked = false;
913
+ this.queue = [];
914
+ }
915
+
916
+ async acquire() {
917
+ if (this.locked) {
918
+ return new Promise(resolve => this.queue.push(resolve));
919
+ }
920
+ this.locked = true;
921
+ }
922
+
923
+ release() {
924
+ if (this.queue.length > 0) {
925
+ const next = this.queue.shift();
926
+ next();
927
+ } else {
928
+ this.locked = false;
929
+ }
930
+ }
931
+ }
932
+
933
+ function wherePredicate(where) {
934
+ return (elt) => {
935
+ for (const [attr, value] of Object.entries(where)) {
936
+ const eltAttrValue = elt[attr]
937
+
938
+ if (typeof(value) === 'string' || typeof(value) === 'number' || typeof(value) === 'boolean') {
939
+ // 'attr = value' clause
940
+ if (!sameWhereValue(eltAttrValue, value)) return false
941
+
942
+ } else if (value === null) {
943
+ // 'attr = null' clause
944
+ if (eltAttrValue !== null) return false
945
+
946
+ } else if (hasRangeOperator(value)) {
947
+ // 'attr = { lt/lte/gt/gte: value }' clause — all bounds apply.
948
+ // A missing (undefined) or null field never satisfies a range constraint,
949
+ // consistent with SQL NULL behaviour (NULL op anything = NULL = unknown).
950
+ // JS coerces null → 0 so range guards like `null > 10` silently pass;
951
+ // undefined coerces to NaN and all NaN comparisons return false — both
952
+ // must be excluded explicitly.
953
+ if (eltAttrValue === undefined || eltAttrValue === null) return false
954
+ if ('lte' in value && compareWhereValues(eltAttrValue, value.lte) > 0) return false
955
+ if ('lt' in value && compareWhereValues(eltAttrValue, value.lt) >= 0) return false
956
+ if ('gte' in value && compareWhereValues(eltAttrValue, value.gte) < 0) return false
957
+ if ('gt' in value && compareWhereValues(eltAttrValue, value.gt) <= 0) return false
958
+ } else if (!sameWhereValue(eltAttrValue, value)) {
959
+ return false
960
+ }
961
+ }
962
+ return true
963
+ }
964
+ }
965
+
966
+ function hasRangeOperator(value) {
967
+ return value
968
+ && typeof value === 'object'
969
+ && !Array.isArray(value)
970
+ && Object.prototype.toString.call(value) === '[object Object]'
971
+ && ['gte', 'gt', 'lte', 'lt'].some(key => key in value)
972
+ }
973
+
974
+ function sameWhereValue(a, b) {
975
+ if (a instanceof Date || b instanceof Date) {
976
+ const aTime = new Date(a).getTime()
977
+ const bTime = new Date(b).getTime()
978
+ return !Number.isNaN(aTime) && !Number.isNaN(bTime) && aTime === bTime
979
+ }
980
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
981
+ return stringifyWithSortedKeys(a) === stringifyWithSortedKeys(b)
982
+ }
983
+ return a === b
984
+ }
985
+
986
+ function compareWhereValues(a, b) {
987
+ if (a instanceof Date || b instanceof Date) {
988
+ const aTime = new Date(a).getTime()
989
+ const bTime = new Date(b).getTime()
990
+ if (!Number.isNaN(aTime) && !Number.isNaN(bTime)) return aTime - bTime
991
+ }
992
+ if (a === b) return 0
993
+ return a > b ? 1 : -1
994
+ }
995
+
996
+ function isSubset(subset, fullObject) {
997
+ for (const key in fullObject) {
998
+ const fVal = fullObject[key]
999
+ const sVal = subset[key]
1000
+ // Primitive values: use reference/value equality (works for string, number, boolean).
1001
+ // Object values (e.g. range operators { gte: 1 }): use structural equality via
1002
+ // sorted JSON so that two freshly-created identical objects compare as equal.
1003
+ if (typeof fVal === 'object' && fVal !== null) {
1004
+ if (stringifyWithSortedKeys(fVal) !== stringifyWithSortedKeys(sVal)) return false
1005
+ } else {
1006
+ if (fVal !== sVal) return false
1007
+ }
1008
+ }
1009
+ return true
1010
+ }
1011
+ // console.log('isSubset({a: 1, b: 2}, {b: 2})=true', isSubset({a: 1, b: 2}, {b: 2}))
1012
+ // console.log('isSubset({}, {})=true', isSubset({}, {}))
1013
+ // console.log('isSubset({a: 1}, {})=true', isSubset({a: 1}, {}))
1014
+ // console.log('isSubset({a: 1}, {b: 2})=false', isSubset({a: 1}, {b: 2}))
1015
+ // console.log('isSubset({a: 1}, {a: 1})=true', isSubset({a: 1}, {a: 1}))
1016
+
1017
+ function isSubsetAmong(subset, fullObjectList) {
1018
+ return fullObjectList.some(fullObject => isSubset(subset, fullObject));
1019
+ }
1020
+ // console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
1021
+
1022
+ export { offlinePlugin as syncClientPlugin }
1023
+ export default offlinePlugin