@jcbuisson/express-x-client 2.2.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/client.mts +657 -0
- package/src/index.mjs +0 -163
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x-client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Client library for ExpressX framework",
|
|
6
|
-
"main": "src/
|
|
6
|
+
"main": "src/client.mts",
|
|
7
7
|
"test": "node --test",
|
|
8
8
|
"scripts": {
|
|
9
9
|
},
|
package/src/client.mts
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import Dexie from "dexie";
|
|
2
|
+
import { from } from 'rxjs';
|
|
3
|
+
import { distinctUntilChanged } from 'rxjs/operators';
|
|
4
|
+
import { liveQuery } from "dexie";
|
|
5
|
+
// uuidv7 are monotonically increasing and much improve database performance amid B-tree indexes
|
|
6
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
7
|
+
import { tryOnScopeDispose } from '@vueuse/core';
|
|
8
|
+
import { useSessionStorage } from '@vueuse/core'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
////////////////////////// UTILITIES //////////////////////////
|
|
12
|
+
|
|
13
|
+
function generateUID(length) {
|
|
14
|
+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
15
|
+
let uid = ''
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < length; i++) {
|
|
18
|
+
const randomIndex = Math.floor(Math.random() * characters.length)
|
|
19
|
+
uid += characters.charAt(randomIndex)
|
|
20
|
+
}
|
|
21
|
+
return uid
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
////////////////////////// EXPRESSX //////////////////////////
|
|
26
|
+
|
|
27
|
+
export function createClient(socket, options={}) {
|
|
28
|
+
if (options.debug === undefined) options.debug = false
|
|
29
|
+
|
|
30
|
+
const waitingPromisesByUid = {}
|
|
31
|
+
const action2service2handlers = {}
|
|
32
|
+
const type2appHandler = {}
|
|
33
|
+
let connectListeners = []
|
|
34
|
+
let disconnectListeners = []
|
|
35
|
+
let errorListeners = []
|
|
36
|
+
|
|
37
|
+
function configure(callback, ...args) {
|
|
38
|
+
callback(app, ...args)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
socket.on("connect", async () => {
|
|
42
|
+
if (options.debug) console.log("socket connected", socket.id)
|
|
43
|
+
for (const func of connectListeners) {
|
|
44
|
+
func(socket)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
socket.on("connect_error", async (err) => {
|
|
49
|
+
if (options.debug) console.log("socket connection error", socket.id)
|
|
50
|
+
for (const func of errorListeners) {
|
|
51
|
+
func(socket)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
socket.on("disconnect", async () => {
|
|
56
|
+
if (options.debug) console.log("socket disconnected", socket.id)
|
|
57
|
+
for (const func of disconnectListeners) {
|
|
58
|
+
func(socket)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
function addConnectListener(func) {
|
|
63
|
+
connectListeners.push(func)
|
|
64
|
+
}
|
|
65
|
+
function removeConnectListener(func) {
|
|
66
|
+
connectListeners = connectListeners.filter(f !== func)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addDisconnectListener(func) {
|
|
70
|
+
disconnectListeners.push(func)
|
|
71
|
+
}
|
|
72
|
+
function removeDisonnectListener(func) {
|
|
73
|
+
disconnectListeners = disconnectListeners.filter(f !== func)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addErrorListener(func) {
|
|
77
|
+
errorListeners.push(func)
|
|
78
|
+
}
|
|
79
|
+
function removeErrorListener(func) {
|
|
80
|
+
errorListeners = errorListeners.filter(f !== func)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// on receiving response from service request
|
|
84
|
+
socket.on('client-response', ({ uid, error, result }) => {
|
|
85
|
+
if (options.debug) console.log('client-response', uid, error, result)
|
|
86
|
+
if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
|
|
87
|
+
const [resolve, reject] = waitingPromisesByUid[uid]
|
|
88
|
+
if (error) {
|
|
89
|
+
reject(error)
|
|
90
|
+
} else {
|
|
91
|
+
resolve(result)
|
|
92
|
+
}
|
|
93
|
+
delete waitingPromisesByUid[uid]
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// on receiving service events from pub/sub
|
|
97
|
+
socket.on('service-event', ({ name, action, result }) => {
|
|
98
|
+
if (options.debug) console.log('service-event', name, action, result)
|
|
99
|
+
if (!action2service2handlers[action]) action2service2handlers[action] = {}
|
|
100
|
+
const serviceHandlers = action2service2handlers[action]
|
|
101
|
+
const handler = serviceHandlers[name]
|
|
102
|
+
if (handler) handler(result)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
async function serviceMethodRequest(name, action, serviceOptions, ...args) {
|
|
106
|
+
// create a promise which will resolve or reject by an event 'client-response'
|
|
107
|
+
const uid = generateUID(20)
|
|
108
|
+
const promise = new Promise((resolve, reject) => {
|
|
109
|
+
waitingPromisesByUid[uid] = [resolve, reject]
|
|
110
|
+
// a timeout may also reject the promise
|
|
111
|
+
if (serviceOptions.timeout && !serviceOptions.volatile) {
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
delete waitingPromisesByUid[uid]
|
|
114
|
+
reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
|
|
115
|
+
}, serviceOptions.timeout)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
// send request to server through websocket
|
|
119
|
+
if (options.debug) console.log('client-request', uid, name, action, args)
|
|
120
|
+
if (serviceOptions.volatile) {
|
|
121
|
+
// event is not sent if connection is not active
|
|
122
|
+
socket.volatile.emit('client-request', { uid, name, action, args, })
|
|
123
|
+
} else {
|
|
124
|
+
// event is buffered if connection is not active (default)
|
|
125
|
+
socket.emit('client-request', { uid, name, action, args, })
|
|
126
|
+
}
|
|
127
|
+
return promise
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function service(name, serviceOptions={}) {
|
|
131
|
+
if (serviceOptions.timeout === undefined) serviceOptions.timeout = 20000
|
|
132
|
+
const service = {
|
|
133
|
+
// associate a handler to a pub/sub event for this service
|
|
134
|
+
on: (action, handler) => {
|
|
135
|
+
if (!action2service2handlers[action]) action2service2handlers[action] = {}
|
|
136
|
+
const serviceHandlers = action2service2handlers[action]
|
|
137
|
+
serviceHandlers[name] = handler
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
// use a Proxy to allow for any method name for a service
|
|
141
|
+
const handler = {
|
|
142
|
+
get(service, action) {
|
|
143
|
+
if (!(action in service)) {
|
|
144
|
+
// newly used property `action`: define it as a service method request function
|
|
145
|
+
service[action] = (...args) => serviceMethodRequest(name, action, serviceOptions, ...args)
|
|
146
|
+
}
|
|
147
|
+
return service[action]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return new Proxy(service, handler)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
//-------------------- APPLICATION-LEVEL EVENTS --------------------
|
|
154
|
+
|
|
155
|
+
// There is a need for application-wide events sent outside any service method call, for example when backend state changes
|
|
156
|
+
// without front-end interactions
|
|
157
|
+
socket.on('app-event', ({ type, value }) => {
|
|
158
|
+
if (options.debug) console.log('app-event', type, value)
|
|
159
|
+
if (!type2appHandler[type]) type2appHandler[type] = {}
|
|
160
|
+
const handler = type2appHandler[type]
|
|
161
|
+
if (handler) handler(value)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// add a handler for application-wide events
|
|
165
|
+
function on(type, handler) {
|
|
166
|
+
type2appHandler[type] = handler
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const app = {
|
|
170
|
+
configure,
|
|
171
|
+
addConnectListener,
|
|
172
|
+
removeConnectListener,
|
|
173
|
+
addDisconnectListener,
|
|
174
|
+
removeDisonnectListener,
|
|
175
|
+
addErrorListener,
|
|
176
|
+
removeErrorListener,
|
|
177
|
+
|
|
178
|
+
service,
|
|
179
|
+
on,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return app
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
////////////////////////// RELOAD PLUGIN //////////////////////////
|
|
187
|
+
// enrich `app` with listeners handling socket data transfer on page reload
|
|
188
|
+
|
|
189
|
+
export async function reloadPlugin(app) {
|
|
190
|
+
|
|
191
|
+
const cnxid = useSessionStorage('cnxid', '')
|
|
192
|
+
|
|
193
|
+
app.addConnectListener(async (socket) => {
|
|
194
|
+
const socketId = socket.id
|
|
195
|
+
console.log('connect', socketId)
|
|
196
|
+
// handle reconnections & reloads
|
|
197
|
+
// look for a previously stored connection id
|
|
198
|
+
const prevSocketId = cnxid.value
|
|
199
|
+
if (prevSocketId) {
|
|
200
|
+
// it's a connection after a reload/refresh
|
|
201
|
+
// ask server to transfer all data from connection `prevSocketId` to connection `socketId`
|
|
202
|
+
console.log('cnx-transfer', prevSocketId, 'to', socketId)
|
|
203
|
+
await socket.emit('cnx-transfer', prevSocketId, socketId)
|
|
204
|
+
// update connection id
|
|
205
|
+
cnxid.value = socketId
|
|
206
|
+
|
|
207
|
+
} else {
|
|
208
|
+
// set connection id
|
|
209
|
+
cnxid.value = socketId
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
socket.on('cnx-transfer-ack', async (fromSocketId, toSocketId) => {
|
|
213
|
+
console.log('ACK ACK!!!', fromSocketId, toSocketId)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
socket.on('cnx-transfer-error', async (fromSocketId, toSocketId) => {
|
|
217
|
+
console.log('ERR ERR!!!', fromSocketId, toSocketId)
|
|
218
|
+
// appState.value.unrecoverableError = true
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
////////////////////////// OFFLINE PLUGIN //////////////////////////
|
|
225
|
+
// enrich `app` with methods, attributes and listeners to handle offline-first database access
|
|
226
|
+
|
|
227
|
+
export function offlinePlugin(app) {
|
|
228
|
+
|
|
229
|
+
function createOfflineModel(modelName, fields) {
|
|
230
|
+
|
|
231
|
+
const dbName = modelName;
|
|
232
|
+
const db = getOrCreateDB(dbName, fields);
|
|
233
|
+
|
|
234
|
+
db.open().then(() => {
|
|
235
|
+
// console.log('db ready', dbName, modelName)
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
db.values.hook("updating", (changes, primaryKey, previousValue) => {
|
|
239
|
+
// console.log("CHANGES", primaryKey, changes, previousValue);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const reset = async () => {
|
|
243
|
+
console.log('reset', modelName);
|
|
244
|
+
await db.whereList.clear();
|
|
245
|
+
await db.values.clear();
|
|
246
|
+
await db.metadata.clear();
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
///////////// PUB / SUB /////////////
|
|
251
|
+
|
|
252
|
+
app.service(modelName).on('createWithMeta', async ([value, meta]) => {
|
|
253
|
+
console.log(`${modelName} EVENT createWithMeta`, value);
|
|
254
|
+
await db.values.put(value);
|
|
255
|
+
await db.metadata.put(meta);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
|
|
259
|
+
console.log(`${modelName} EVENT updateWithMeta`, value);
|
|
260
|
+
await db.values.put(value);
|
|
261
|
+
await db.metadata.put(meta);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
|
|
265
|
+
console.log(`${modelName} EVENT deleteWithMeta`, value)
|
|
266
|
+
await db.values.delete(value.uid)
|
|
267
|
+
await db.metadata.put(meta)
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
///////////// CREATE/UPDATE/REMOVE /////////////
|
|
272
|
+
|
|
273
|
+
async function create(data) {
|
|
274
|
+
const uid = uuidv7()
|
|
275
|
+
// optimistic update
|
|
276
|
+
const now = new Date()
|
|
277
|
+
await db.values.add({ uid, ...data })
|
|
278
|
+
await db.metadata.add({ uid, created_at: now })
|
|
279
|
+
// execute on server, asynchronously, if connection is active
|
|
280
|
+
if (app.isConnected) {
|
|
281
|
+
app.service(modelName).createWithMeta(uid, data, now)
|
|
282
|
+
.catch(async err => {
|
|
283
|
+
console.log(`*** err sync ${modelName} create`, err)
|
|
284
|
+
// rollback
|
|
285
|
+
await db.values.delete(uid)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
return await db.values.get(uid)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const update = async (uid: string, data: object) => {
|
|
292
|
+
const previousValue = { ...(await db.values.get(uid)) }
|
|
293
|
+
const previousMetadata = { ...(await db.metadata.get(uid)) }
|
|
294
|
+
// optimistic update of cache
|
|
295
|
+
const now = new Date()
|
|
296
|
+
await db.values.update(uid, data)
|
|
297
|
+
await db.metadata.update(uid, { updated_at: now })
|
|
298
|
+
// execute on server, asynchronously, if connection is active
|
|
299
|
+
if (app.isConnected) {
|
|
300
|
+
app.service(modelName).updateWithMeta(uid, data, now)
|
|
301
|
+
.catch(async err => {
|
|
302
|
+
console.log(`*** err sync ${modelName} update`, err)
|
|
303
|
+
// rollback
|
|
304
|
+
delete previousValue.uid
|
|
305
|
+
await db.values.update(uid, previousValue)
|
|
306
|
+
delete previousMetadata.uid
|
|
307
|
+
await db.metadata.update(uid, previousMetadata)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
return await db.values.get(uid)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const remove = async (uid: string) => {
|
|
314
|
+
const deleted_at = new Date()
|
|
315
|
+
// optimistic delete in cache
|
|
316
|
+
await db.values.update(uid, { __deleted__: true })
|
|
317
|
+
await db.metadata.update(uid, { deleted_at })
|
|
318
|
+
// and in database, if connected
|
|
319
|
+
if (app.isConnected) {
|
|
320
|
+
app.service(modelName).deleteWithMeta(uid, deleted_at)
|
|
321
|
+
.catch(async err => {
|
|
322
|
+
console.log(`*** err sync ${modelName} remove`, err)
|
|
323
|
+
// rollback
|
|
324
|
+
await db.values.update(uid, { __deleted__: null })
|
|
325
|
+
await db.metadata.update(uid, { deleted_at: null })
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
///////////// DIRECT CACHE ACCESS /////////////
|
|
331
|
+
|
|
332
|
+
function findByUID(uid) {
|
|
333
|
+
return db.values.get(uid)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function findWhere(where = {}) {
|
|
337
|
+
const predicate = wherePredicate(where)
|
|
338
|
+
return db.values.filter(value => !value.__deleted__ && predicate(value)).toArray()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
///////////// REAL-TIME OBSERVABLE /////////////
|
|
342
|
+
|
|
343
|
+
function getObservable(where = {}) {
|
|
344
|
+
addSynchroWhere(where).then((isNew: boolean) => {
|
|
345
|
+
// console.log('getObservable addSynchroWhere', modelName, where, isNew);
|
|
346
|
+
if (isNew && app.isConnected) {
|
|
347
|
+
synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
const predicate = wherePredicate(where)
|
|
351
|
+
return from(liveQuery(() => db.values.filter(value => !value.__deleted__ && predicate(value)).toArray())).pipe(
|
|
352
|
+
distinctUntilChanged((prev, curr) => {
|
|
353
|
+
// Deep equality check to prevent unnecessary emissions (in particular on database write)
|
|
354
|
+
return JSON.stringify(prev) === JSON.stringify(curr)
|
|
355
|
+
})
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let count = 0;
|
|
360
|
+
|
|
361
|
+
function addSynchroWhere(where: object) {
|
|
362
|
+
const promise = addSynchroDBWhere(where, db.whereList)
|
|
363
|
+
promise.then(isNew => isNew && count++ && console.log(`addSynchroWhere (${count})`, dbName, modelName, where))
|
|
364
|
+
return promise
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function removeSynchroWhere(where: object) {
|
|
368
|
+
console.log('removeSynchroWhere', dbName, modelName, where)
|
|
369
|
+
count -= 1
|
|
370
|
+
return removeSynchroDBWhere(where, db.whereList)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// async function synchronizeAll() {
|
|
374
|
+
// await synchronizeModelWhereList(app, modelName, db.values, db.metadata, app.getDisconnectedDate(), db.whereList)
|
|
375
|
+
// }
|
|
376
|
+
|
|
377
|
+
// Automatically clean up when the component using this composable unmounts
|
|
378
|
+
tryOnScopeDispose(async () => {
|
|
379
|
+
console.log('CLEANING', dbName, modelName)
|
|
380
|
+
const whereList = await db.whereList.toArray()
|
|
381
|
+
for (const where of whereList) {
|
|
382
|
+
removeSynchroWhere(JSON.parse(where.sortedjson))
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
db, reset,
|
|
388
|
+
create, update, remove,
|
|
389
|
+
findByUID, findWhere,
|
|
390
|
+
getObservable,
|
|
391
|
+
// synchronizeAll,
|
|
392
|
+
addSynchroWhere,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
app.addConnectListener(async (_socket) => {
|
|
397
|
+
app.connectedDate = new Date()
|
|
398
|
+
console.log('onConnect', app.connectedDate)
|
|
399
|
+
app.disconnectedDate = null
|
|
400
|
+
app.isConnected = true
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
app.addDisconnectListener(async (_socket) => {
|
|
404
|
+
app.connectedDate = null
|
|
405
|
+
app.disconnectedDate = new Date()
|
|
406
|
+
console.log('onDisconnect', app.disconnectedDate)
|
|
407
|
+
app.isConnected = false
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
const mutex = new Mutex()
|
|
412
|
+
|
|
413
|
+
// ex: where = { uid: 'azer' }
|
|
414
|
+
async function synchronize(app, modelName, idbValues, idbMetadata, where, cutoffDate) {
|
|
415
|
+
await mutex.acquire()
|
|
416
|
+
console.log('synchronize', modelName, where)
|
|
417
|
+
|
|
418
|
+
let toAdd = []
|
|
419
|
+
try {
|
|
420
|
+
const requestPredicate = wherePredicate(where)
|
|
421
|
+
|
|
422
|
+
// collect meta-data of local values
|
|
423
|
+
// NOTE: __delete__ on values allows to collect metadata from cache-deleted values
|
|
424
|
+
const valueList = await idbValues.filter(requestPredicate).toArray()
|
|
425
|
+
const clientMetadataDict = {}
|
|
426
|
+
for (const value of valueList) {
|
|
427
|
+
const metadata = await idbMetadata.get(value.uid)
|
|
428
|
+
if (metadata) {
|
|
429
|
+
clientMetadataDict[value.uid] = metadata
|
|
430
|
+
} else {
|
|
431
|
+
// should not happen
|
|
432
|
+
clientMetadataDict[value.uid] = {}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// call sync service on `where` perimeter
|
|
437
|
+
const syncResult = await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
|
|
438
|
+
toAdd = syncResult.toAdd
|
|
439
|
+
const { toUpdate, toDelete, addDatabase, updateDatabase } = syncResult
|
|
440
|
+
console.log('-> service.sync', modelName, where, toAdd, toUpdate, toDelete, addDatabase, updateDatabase)
|
|
441
|
+
|
|
442
|
+
// 1- add missing elements in indexedDB cache
|
|
443
|
+
// Use a single transaction for all adds to ensure atomicity
|
|
444
|
+
if (toAdd.length > 0) {
|
|
445
|
+
await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
|
|
446
|
+
for (const [value, metaData] of toAdd) {
|
|
447
|
+
await idbValues.add(value)
|
|
448
|
+
await idbMetadata.add(metaData)
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
// 2- delete elements from indexedDB cache
|
|
453
|
+
for (const [uid, deleted_at] of toDelete) {
|
|
454
|
+
await idbValues.delete(uid)
|
|
455
|
+
await idbMetadata.update(uid, { deleted_at })
|
|
456
|
+
}
|
|
457
|
+
// 3- update elements of cache
|
|
458
|
+
for (const elt of toUpdate) {
|
|
459
|
+
// get full value of element to update
|
|
460
|
+
const value = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
|
|
461
|
+
delete value.uid
|
|
462
|
+
delete value.__deleted__
|
|
463
|
+
await idbValues.update(elt.uid, value)
|
|
464
|
+
const metadata = await idbMetadata.get(elt.uid)
|
|
465
|
+
await idbMetadata.update(elt.uid, { updated_at: metadata.updated_at })
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 4- create elements of `addDatabase` with full data from cache
|
|
469
|
+
for (const elt of addDatabase) {
|
|
470
|
+
const fullValue = await idbValues.get(elt.uid)
|
|
471
|
+
const meta = await idbMetadata.get(elt.uid)
|
|
472
|
+
delete fullValue.uid
|
|
473
|
+
delete fullValue.__deleted__
|
|
474
|
+
try {
|
|
475
|
+
await app.service(modelName).createWithMeta(elt.uid, fullValue, meta.created_at)
|
|
476
|
+
} catch(err) {
|
|
477
|
+
console.log("*** err sync user addDatabase", err, elt.uid, fullValue, meta.created_at)
|
|
478
|
+
// rollback
|
|
479
|
+
await idbValues.delete(elt.uid)
|
|
480
|
+
await idbMetadata.delete(elt.uid)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 5- update elements of `updateDatabase` with full data from cache
|
|
485
|
+
for (const elt of updateDatabase) {
|
|
486
|
+
const fullValue = await idbValues.get(elt.uid)
|
|
487
|
+
const meta = await idbMetadata.get(elt.uid)
|
|
488
|
+
delete fullValue.uid
|
|
489
|
+
delete fullValue.__deleted__
|
|
490
|
+
try {
|
|
491
|
+
await app.service(modelName).updateWithMeta(elt.uid, fullValue, meta.updated_at)
|
|
492
|
+
} catch(err) {
|
|
493
|
+
console.log("*** err sync user updateDatabase", err)
|
|
494
|
+
// rollback
|
|
495
|
+
const previousDatabaseValue = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
|
|
496
|
+
const previousDatabaseMetadata = await app.service('metadata').findUnique({ where:{ uid: elt.uid }})
|
|
497
|
+
await idbValues.update(elt.uid, previousDatabaseValue)
|
|
498
|
+
await idbMetadata.update(elt.uid, previousDatabaseMetadata)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} catch(err) {
|
|
502
|
+
console.log('err synchronize', modelName, where, err)
|
|
503
|
+
} finally {
|
|
504
|
+
mutex.release()
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function wherePredicate(where) {
|
|
509
|
+
return (elt) => {
|
|
510
|
+
for (const [attr, value] of Object.entries(where)) {
|
|
511
|
+
const eltAttrValue = elt[attr]
|
|
512
|
+
|
|
513
|
+
if (typeof(value) === 'string' || typeof(value) === 'number') {
|
|
514
|
+
// 'attr = value' clause
|
|
515
|
+
if (eltAttrValue !== value) return false
|
|
516
|
+
|
|
517
|
+
} else if (typeof(value) === 'object') {
|
|
518
|
+
// 'attr = { lt/lte/gt/gte: value }' clause
|
|
519
|
+
if (value.lte) {
|
|
520
|
+
if (eltAttrValue > value.lte) return false
|
|
521
|
+
} else if (value.lt) {
|
|
522
|
+
if (eltAttrValue >= value.lt) return false
|
|
523
|
+
} else if (value.gte) {
|
|
524
|
+
if (eltAttrValue < value.gte) return false
|
|
525
|
+
} else if (value.gt) {
|
|
526
|
+
if (eltAttrValue <= value.gt) return false
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return true
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function getWhereList(whereDb) {
|
|
535
|
+
const list = await whereDb.toArray()
|
|
536
|
+
return list.map(elt => JSON.parse(elt.sortedjson))
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function addSynchroDBWhere(where, whereDb) {
|
|
540
|
+
await mutex.acquire()
|
|
541
|
+
let modified = false
|
|
542
|
+
try {
|
|
543
|
+
const whereList = await getWhereList(whereDb)
|
|
544
|
+
if (!isSubsetAmong(where, whereList)) {
|
|
545
|
+
// sortedjson is used as a unique standardized representation of a 'where' object ; it is used both as key and value in 'wheredb' database
|
|
546
|
+
await whereDb.add({ sortedjson: stringifyWithSortedKeys(where) })
|
|
547
|
+
modified = true
|
|
548
|
+
}
|
|
549
|
+
} catch(err) {
|
|
550
|
+
console.log('err addSynchroDBWhere', where, err)
|
|
551
|
+
} finally {
|
|
552
|
+
mutex.release()
|
|
553
|
+
}
|
|
554
|
+
return modified
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function removeSynchroDBWhere(where, whereDb) {
|
|
558
|
+
await mutex.acquire()
|
|
559
|
+
try {
|
|
560
|
+
const swhere = stringifyWithSortedKeys(where)
|
|
561
|
+
await whereDb.filter(value => (value.sortedjson === swhere)).delete()
|
|
562
|
+
} catch(err) {
|
|
563
|
+
console.log('err removeSynchroDBWhere', err)
|
|
564
|
+
} finally {
|
|
565
|
+
mutex.release()
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// async function synchronizeModelWhereList(modelName, idbValues, idbMetadata, cutoffDate, whereDb) {
|
|
570
|
+
// const whereList = await getWhereList(whereDb)
|
|
571
|
+
// for (const where of whereList) {
|
|
572
|
+
// await synchronize(modelName, idbValues, idbMetadata, where, cutoffDate)
|
|
573
|
+
// }
|
|
574
|
+
// }
|
|
575
|
+
|
|
576
|
+
// Singleton map to reuse Dexie instances per database name
|
|
577
|
+
const dbInstances = new Map();
|
|
578
|
+
|
|
579
|
+
function getOrCreateDB(dbName: string, fields: string[]) {
|
|
580
|
+
if (!dbInstances.has(dbName)) {
|
|
581
|
+
const db = new Dexie(dbName);
|
|
582
|
+
db.version(1).stores({
|
|
583
|
+
whereList: "sortedjson",
|
|
584
|
+
values: ['uid', '__deleted__', ...fields].join(','),
|
|
585
|
+
metadata: "uid, created_at, updated_at, deleted_at",
|
|
586
|
+
});
|
|
587
|
+
dbInstances.set(dbName, db);
|
|
588
|
+
}
|
|
589
|
+
return dbInstances.get(dbName);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// enrich `app` with new methods and attributes
|
|
593
|
+
return Object.assign(app, {
|
|
594
|
+
createOfflineModel,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
function stringifyWithSortedKeys(obj, space = null) {
|
|
600
|
+
return JSON.stringify(obj, (key, value) => {
|
|
601
|
+
// If the value is a plain object (not an array, null, or other object type like Date)
|
|
602
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.toString.call(value) === '[object Object]') {
|
|
603
|
+
const sorted = {};
|
|
604
|
+
// Get all keys, sort them, and then re-add them to a new object
|
|
605
|
+
// This new object will maintain the sorted order when stringified
|
|
606
|
+
Object.keys(value).sort().forEach(k => {
|
|
607
|
+
sorted[k] = value[k];
|
|
608
|
+
});
|
|
609
|
+
return sorted;
|
|
610
|
+
}
|
|
611
|
+
// For all other types (primitives, arrays, null, etc.), return the value as is
|
|
612
|
+
return value;
|
|
613
|
+
}, space); // 'space' is optional for pretty-printing (e.g., 2 or 4)
|
|
614
|
+
}
|
|
615
|
+
// console.log('stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" }})', stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" } }))
|
|
616
|
+
|
|
617
|
+
export class Mutex {
|
|
618
|
+
constructor() {
|
|
619
|
+
this.locked = false;
|
|
620
|
+
this.queue = [];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async acquire() {
|
|
624
|
+
if (this.locked) {
|
|
625
|
+
return new Promise(resolve => this.queue.push(resolve));
|
|
626
|
+
}
|
|
627
|
+
this.locked = true;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
release() {
|
|
631
|
+
if (this.queue.length > 0) {
|
|
632
|
+
const next = this.queue.shift();
|
|
633
|
+
next();
|
|
634
|
+
} else {
|
|
635
|
+
this.locked = false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function isSubset(subset, fullObject) {
|
|
641
|
+
// return Object.entries(subset).some(([key, value]) => fullObject[key] === value)
|
|
642
|
+
for (const key in fullObject) {
|
|
643
|
+
if (fullObject[key] !== subset[key]) return false
|
|
644
|
+
}
|
|
645
|
+
return true
|
|
646
|
+
}
|
|
647
|
+
// console.log('isSubset({a: 1, b: 2}, {b: 2})=true', isSubset({a: 1, b: 2}, {b: 2}))
|
|
648
|
+
// console.log('isSubset({}, {})=true', isSubset({}, {}))
|
|
649
|
+
// console.log('isSubset({a: 1}, {})=true', isSubset({a: 1}, {}))
|
|
650
|
+
// console.log('isSubset({a: 1}, {b: 2})=false', isSubset({a: 1}, {b: 2}))
|
|
651
|
+
// console.log('isSubset({a: 1}, {a: 1})=true', isSubset({a: 1}, {a: 1}))
|
|
652
|
+
|
|
653
|
+
function isSubsetAmong(subset, fullObjectList) {
|
|
654
|
+
return fullObjectList.some(fullObject => isSubset(subset, fullObject));
|
|
655
|
+
}
|
|
656
|
+
// console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
|
|
657
|
+
|
package/src/index.mjs
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
function generateUID(length) {
|
|
3
|
-
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
4
|
-
let uid = ''
|
|
5
|
-
|
|
6
|
-
for (let i = 0; i < length; i++) {
|
|
7
|
-
const randomIndex = Math.floor(Math.random() * characters.length)
|
|
8
|
-
uid += characters.charAt(randomIndex)
|
|
9
|
-
}
|
|
10
|
-
return uid
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export default function expressXClient(socket, options={}) {
|
|
15
|
-
if (options.debug === undefined) options.debug = false
|
|
16
|
-
|
|
17
|
-
const waitingPromisesByUid = {}
|
|
18
|
-
const action2service2handlers = {}
|
|
19
|
-
const type2appHandler = {}
|
|
20
|
-
let connectListeners = []
|
|
21
|
-
let disconnectListeners = []
|
|
22
|
-
let errorListeners = []
|
|
23
|
-
|
|
24
|
-
socket.on("connect", async () => {
|
|
25
|
-
if (options.debug) console.log("socket connected", socket.id)
|
|
26
|
-
for (const func of connectListeners) {
|
|
27
|
-
func(socket)
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
socket.on("connect_error", async (err) => {
|
|
32
|
-
if (options.debug) console.log("socket connection error", socket.id)
|
|
33
|
-
for (const func of errorListeners) {
|
|
34
|
-
func(socket)
|
|
35
|
-
}
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
socket.on("disconnect", async () => {
|
|
39
|
-
if (options.debug) console.log("socket disconnected", socket.id)
|
|
40
|
-
for (const func of disconnectListeners) {
|
|
41
|
-
func(socket)
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
function addConnectListener(func) {
|
|
46
|
-
connectListeners.push(func)
|
|
47
|
-
}
|
|
48
|
-
function removeConnectListener(func) {
|
|
49
|
-
connectListeners = connectListeners.filter(f !== func)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function addDisconnectListener(func) {
|
|
53
|
-
disconnectListeners.push(func)
|
|
54
|
-
}
|
|
55
|
-
function removeDisonnectListener(func) {
|
|
56
|
-
disconnectListeners = disconnectListeners.filter(f !== func)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function addErrorListener(func) {
|
|
60
|
-
errorListeners.push(func)
|
|
61
|
-
}
|
|
62
|
-
function removeErrorListener(func) {
|
|
63
|
-
errorListeners = errorListeners.filter(f !== func)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// on receiving response from service request
|
|
67
|
-
socket.on('client-response', ({ uid, error, result }) => {
|
|
68
|
-
if (options.debug) console.log('client-response', uid, error, result)
|
|
69
|
-
if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
|
|
70
|
-
const [resolve, reject] = waitingPromisesByUid[uid]
|
|
71
|
-
if (error) {
|
|
72
|
-
reject(error)
|
|
73
|
-
} else {
|
|
74
|
-
resolve(result)
|
|
75
|
-
}
|
|
76
|
-
delete waitingPromisesByUid[uid]
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
// on receiving service events from pub/sub
|
|
80
|
-
socket.on('service-event', ({ name, action, result }) => {
|
|
81
|
-
if (options.debug) console.log('service-event', name, action, result)
|
|
82
|
-
if (!action2service2handlers[action]) action2service2handlers[action] = {}
|
|
83
|
-
const serviceHandlers = action2service2handlers[action]
|
|
84
|
-
const handler = serviceHandlers[name]
|
|
85
|
-
if (handler) handler(result)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
async function serviceMethodRequest(name, action, serviceOptions, ...args) {
|
|
89
|
-
// create a promise which will resolve or reject by an event 'client-response'
|
|
90
|
-
const uid = generateUID(20)
|
|
91
|
-
const promise = new Promise((resolve, reject) => {
|
|
92
|
-
waitingPromisesByUid[uid] = [resolve, reject]
|
|
93
|
-
// a timeout may also reject the promise
|
|
94
|
-
if (serviceOptions.timeout && !serviceOptions.volatile) {
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
delete waitingPromisesByUid[uid]
|
|
97
|
-
reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
|
|
98
|
-
}, serviceOptions.timeout)
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
// send request to server through websocket
|
|
102
|
-
if (options.debug) console.log('client-request', uid, name, action, args)
|
|
103
|
-
if (serviceOptions.volatile) {
|
|
104
|
-
// event is not sent if connection is not active
|
|
105
|
-
socket.volatile.emit('client-request', { uid, name, action, args, })
|
|
106
|
-
} else {
|
|
107
|
-
// event is buffered if connection is not active (default)
|
|
108
|
-
socket.emit('client-request', { uid, name, action, args, })
|
|
109
|
-
}
|
|
110
|
-
return promise
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function service(name, serviceOptions={}) {
|
|
114
|
-
if (serviceOptions.timeout === undefined) serviceOptions.timeout = 20000
|
|
115
|
-
const service = {
|
|
116
|
-
// associate a handler to a pub/sub event for this service
|
|
117
|
-
on: (action, handler) => {
|
|
118
|
-
if (!action2service2handlers[action]) action2service2handlers[action] = {}
|
|
119
|
-
const serviceHandlers = action2service2handlers[action]
|
|
120
|
-
serviceHandlers[name] = handler
|
|
121
|
-
},
|
|
122
|
-
}
|
|
123
|
-
// use a Proxy to allow for any method name for a service
|
|
124
|
-
const handler = {
|
|
125
|
-
get(service, action) {
|
|
126
|
-
if (!(action in service)) {
|
|
127
|
-
// newly used property `action`: define it as a service method request function
|
|
128
|
-
service[action] = (...args) => serviceMethodRequest(name, action, serviceOptions, ...args)
|
|
129
|
-
}
|
|
130
|
-
return service[action]
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return new Proxy(service, handler)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/////////////// APPLICATION-LEVEL EVENTS /////////////////
|
|
137
|
-
|
|
138
|
-
// There is a need for application-wide events sent outside any service method call, for example when backend state changes
|
|
139
|
-
// without front-end interactions
|
|
140
|
-
socket.on('app-event', ({ type, value }) => {
|
|
141
|
-
if (options.debug) console.log('app-event', type, value)
|
|
142
|
-
if (!type2appHandler[type]) type2appHandler[type] = {}
|
|
143
|
-
const handler = type2appHandler[type]
|
|
144
|
-
if (handler) handler(value)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// add a handler for application-wide events
|
|
148
|
-
function on(type, handler) {
|
|
149
|
-
type2appHandler[type] = handler
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
addConnectListener,
|
|
154
|
-
removeConnectListener,
|
|
155
|
-
addDisconnectListener,
|
|
156
|
-
removeDisonnectListener,
|
|
157
|
-
addErrorListener,
|
|
158
|
-
removeErrorListener,
|
|
159
|
-
|
|
160
|
-
service,
|
|
161
|
-
on,
|
|
162
|
-
}
|
|
163
|
-
}
|