@jcbuisson/express-x-plugins 5.0.6 → 5.0.8
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/README.md +127 -3
- package/docs/electric-fsm.md +244 -0
- package/docs/sync-fsm.md +117 -0
- package/package.json +32 -2
- package/src/electric-client-plugin.mjs +4 -4
- package/src/electric-managed-sync.mjs +11 -3
- package/src/electric-offline-model.mjs +14 -2
- package/src/electric-server-plugin.mjs +22 -6
- package/src/electric-sync-relations.mjs +99 -0
- package/src/sync-algorithm.mjs +146 -0
- package/src/sync-client-plugin.mjs +1023 -0
- package/src/sync-server-plugin.mjs +541 -0
- package/test/electric-managed-sync.test.mjs +27 -14
- package/test/electric-offline-model.test.mjs +5 -5
- package/test/electric-relations-postgres.test.mjs +113 -0
- package/test/electric-relations.test.mjs +220 -0
- package/test/electric-server-plugin.test.mjs +11 -3
- package/test/sync-algorithm.test.mjs +229 -0
- package/test/sync-offline.test.mjs +4643 -0
|
@@ -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
|