@jcbuisson/express-x-client 3.0.3 → 3.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.
- package/CLAUDE.md +71 -0
- package/package.json +1 -1
- package/src/client.mts +129 -155
- package/prisma/dev.db +0 -0
- package/prisma/migrations/20230629052809_init/migration.sql +0 -17
- package/prisma/migrations/migration_lock.toml +0 -3
- package/prisma/schema.prisma +0 -22
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@jcbuisson/express-x-client` is the browser-side client library for the ExpressX framework. It wraps a socket.io socket and provides service proxies, pub/sub, and optional offline-first sync. The entire library lives in a single file: `src/client.mts`.
|
|
8
|
+
|
|
9
|
+
The package is ESM-only (`"type": "module"`). The `main` field in `package.json` points directly to `src/client.mts` — there is no compilation or build step. No build, lint, or test scripts are defined.
|
|
10
|
+
|
|
11
|
+
The file uses TypeScript syntax (type annotations) despite the absence of a `tsconfig.json`.
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
The library exports three main factory functions and one utility class that follow a plugin composition pattern:
|
|
16
|
+
|
|
17
|
+
### `createClient(socket, options)`
|
|
18
|
+
Core factory. Wraps a socket.io `socket` and returns an `app` object. Communication uses two custom socket events:
|
|
19
|
+
- `client-request` / `client-response` — request/response using socket.io acknowledgments to correlate responses to waiting promises
|
|
20
|
+
- `service-event` — server-to-client pub/sub notifications
|
|
21
|
+
- `app-event` — application-wide broadcast events (outside any service)
|
|
22
|
+
|
|
23
|
+
`app.configure(callback)` is the standard plugin composition hook — it calls `callback(app)` and is how plugins extend the app.
|
|
24
|
+
|
|
25
|
+
The `service(name, serviceOptions)` method returns a `Proxy` that intercepts any property access and turns it into a `serviceMethodRequest` call, so callers can write `app.service('user').findMany(...)` without pre-declaring methods. `serviceOptions` supports:
|
|
26
|
+
- `timeout` (default 20000 ms) — socket acknowledgment timeout
|
|
27
|
+
- `volatile: true` — uses `socket.volatile` (fire-and-forget, drops if disconnected)
|
|
28
|
+
|
|
29
|
+
### `reloadPlugin(app)`
|
|
30
|
+
Enriches `app` with page-reload session continuity. On reconnect it emits `cnx-transfer` carrying the previous socket ID (persisted in `sessionStorage` via `@vueuse/core`'s `useSessionStorage`) so the server can migrate state.
|
|
31
|
+
|
|
32
|
+
### `offlinePlugin(app)`
|
|
33
|
+
Enriches `app` with offline-first IndexedDB CRUD via Dexie. Call `app.createOfflineModel(modelName, fields)` to get a model object.
|
|
34
|
+
|
|
35
|
+
This plugin also adds three dynamic attributes to `app`:
|
|
36
|
+
- `app.isConnected` — boolean, updated on socket connect/disconnect
|
|
37
|
+
- `app.connectedDate` — `Date` of the last connection, or `null`
|
|
38
|
+
- `app.disconnectedDate` — `Date` of the last disconnection, or `null` (used as `cutoffDate` for sync)
|
|
39
|
+
|
|
40
|
+
Each model maintains three Dexie stores under the same DB name:
|
|
41
|
+
- `values` — the actual records (indexed on `uid`, `__deleted__`)
|
|
42
|
+
- `metadata` — per-record `created_at / updated_at / deleted_at`
|
|
43
|
+
- `whereList` — set of active `where` filters to scope synchronization
|
|
44
|
+
|
|
45
|
+
`createOfflineModel` auto-registers pub/sub handlers for the service named `modelName`:
|
|
46
|
+
- `createWithMeta` / `updateWithMeta` / `deleteWithMeta` — keep the local cache in sync with server-pushed events
|
|
47
|
+
|
|
48
|
+
**Optimistic writes**: `create`, `update`, `remove` write to IndexedDB immediately, then call the server service method. On server error they roll back the local change.
|
|
49
|
+
|
|
50
|
+
**Sync on reconnect**: When the socket reconnects, every registered model calls `synchronizeAll`, which iterates its `whereList` and calls the server's `sync.go(modelName, where, cutoffDate, clientMetadataDict)` service. The response contains five buckets (`addClient`, `updateClient`, `deleteClient`, `addDatabase`, `updateDatabase`) that are applied in order.
|
|
51
|
+
|
|
52
|
+
**Real-time observables**: `getObservable(where)` returns an RxJS `Observable` backed by Dexie's `liveQuery`. It also registers the `where` in `whereList` and triggers a sync if it is a new, unregistered filter. Vue component lifecycle cleanup is handled by `tryOnScopeDispose`.
|
|
53
|
+
|
|
54
|
+
A shared `Mutex` serializes all sync and `whereList` mutations to avoid concurrent IndexedDB race conditions.
|
|
55
|
+
|
|
56
|
+
### `where` filter syntax
|
|
57
|
+
Used throughout for querying local cache and scoping server sync:
|
|
58
|
+
- Equality: `{ field: value }`
|
|
59
|
+
- Range: `{ field: { lt, lte, gt, gte } }` — `null`/`undefined` fields never satisfy range clauses (matches SQL NULL semantics)
|
|
60
|
+
|
|
61
|
+
### Utilities
|
|
62
|
+
- `Mutex` — exported; simple async mutex backed by a promise queue
|
|
63
|
+
- `wherePredicate(where)` — (module-private) turns a `where` object into a filter function
|
|
64
|
+
- `isSubset` / `isSubsetAmong` — (module-private) checks if a `where` is covered by an existing entry in `whereList` (avoids redundant syncs)
|
|
65
|
+
- `stringifyWithSortedKeys` — (module-private) deterministic JSON stringify used as canonical `whereList` keys
|
|
66
|
+
- `generateUID(length)` — (module-private) alphanumeric random string used to correlate request/response pairs
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
- `uuidv7` is used for client-side record IDs (monotonically increasing, good for B-tree indexes).
|
|
70
|
+
- The `prisma/` directory with a SQLite schema is an unrelated artifact and not part of the library.
|
|
71
|
+
- All imported packages (`dexie`, `rxjs`, `uuid`, `@vueuse/core`) are consumed by the library but are not listed as `dependencies` — they are expected to be provided by the consuming application.
|
package/package.json
CHANGED
package/src/client.mts
CHANGED
|
@@ -13,7 +13,6 @@ import { useSessionStorage } from '@vueuse/core'
|
|
|
13
13
|
export function createClient(socket, options={}) {
|
|
14
14
|
if (options.debug === undefined) options.debug = false
|
|
15
15
|
|
|
16
|
-
const waitingPromisesByUid = {}
|
|
17
16
|
const action2service2handlers = {}
|
|
18
17
|
const type2appHandler = {}
|
|
19
18
|
let connectListeners = []
|
|
@@ -49,36 +48,23 @@ export function createClient(socket, options={}) {
|
|
|
49
48
|
connectListeners.push(func)
|
|
50
49
|
}
|
|
51
50
|
function removeConnectListener(func) {
|
|
52
|
-
connectListeners = connectListeners.filter(f !== func)
|
|
51
|
+
connectListeners = connectListeners.filter(f => f !== func)
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
function addDisconnectListener(func) {
|
|
56
55
|
disconnectListeners.push(func)
|
|
57
56
|
}
|
|
58
|
-
function
|
|
59
|
-
disconnectListeners = disconnectListeners.filter(f !== func)
|
|
57
|
+
function removeDisconnectListener(func) {
|
|
58
|
+
disconnectListeners = disconnectListeners.filter(f => f !== func)
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
function addErrorListener(func) {
|
|
63
62
|
errorListeners.push(func)
|
|
64
63
|
}
|
|
65
64
|
function removeErrorListener(func) {
|
|
66
|
-
errorListeners = errorListeners.filter(f !== func)
|
|
65
|
+
errorListeners = errorListeners.filter(f => f !== func)
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
// on receiving response from service request
|
|
70
|
-
socket.on('client-response', ({ uid, error, result }) => {
|
|
71
|
-
if (options.debug) console.log('client-response', uid, error, result)
|
|
72
|
-
if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
|
|
73
|
-
const [resolve, reject] = waitingPromisesByUid[uid]
|
|
74
|
-
if (error) {
|
|
75
|
-
reject(error)
|
|
76
|
-
} else {
|
|
77
|
-
resolve(result)
|
|
78
|
-
}
|
|
79
|
-
delete waitingPromisesByUid[uid]
|
|
80
|
-
})
|
|
81
|
-
|
|
82
68
|
// on receiving service events from pub/sub
|
|
83
69
|
socket.on('service-event', ({ name, action, result }) => {
|
|
84
70
|
if (options.debug) console.log('service-event', name, action, result)
|
|
@@ -89,28 +75,14 @@ export function createClient(socket, options={}) {
|
|
|
89
75
|
})
|
|
90
76
|
|
|
91
77
|
async function serviceMethodRequest(name, action, serviceOptions, ...args) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
|
|
101
|
-
}, serviceOptions.timeout)
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
// send request to server through websocket
|
|
105
|
-
if (options.debug) console.log('client-request', uid, name, action, args)
|
|
106
|
-
if (serviceOptions.volatile) {
|
|
107
|
-
// event is not sent if connection is not active
|
|
108
|
-
socket.volatile.emit('client-request', { uid, name, action, args, })
|
|
109
|
-
} else {
|
|
110
|
-
// event is buffered if connection is not active (default)
|
|
111
|
-
socket.emit('client-request', { uid, name, action, args, })
|
|
112
|
-
}
|
|
113
|
-
return promise
|
|
78
|
+
if (options.debug) console.log('client-request', name, action, args)
|
|
79
|
+
// use socket.io acknowledgment for request/response correlation
|
|
80
|
+
const emitter = serviceOptions.volatile
|
|
81
|
+
? socket.volatile
|
|
82
|
+
: socket.timeout(serviceOptions.timeout || 20000)
|
|
83
|
+
const { error, result } = await emitter.emitWithAck('client-request', { name, action, args })
|
|
84
|
+
if (error) throw error
|
|
85
|
+
return result
|
|
114
86
|
}
|
|
115
87
|
|
|
116
88
|
function service(name, serviceOptions={}) {
|
|
@@ -136,13 +108,14 @@ export function createClient(socket, options={}) {
|
|
|
136
108
|
return new Proxy(service, handler)
|
|
137
109
|
}
|
|
138
110
|
|
|
111
|
+
//-------------------- APPLICATION-LEVEL EVENTS --------------------
|
|
112
|
+
|
|
139
113
|
// There is a need for application-wide events sent outside any service method call, for example when backend state changes
|
|
140
114
|
// without front-end interactions
|
|
141
115
|
socket.on('app-event', ({ type, value }) => {
|
|
142
116
|
if (options.debug) console.log('app-event', type, value)
|
|
143
|
-
if (!type2appHandler[type]) type2appHandler[type] = {}
|
|
144
117
|
const handler = type2appHandler[type]
|
|
145
|
-
if (handler) handler(value)
|
|
118
|
+
if (typeof handler === 'function') handler(value)
|
|
146
119
|
})
|
|
147
120
|
|
|
148
121
|
// add a handler for application-wide events
|
|
@@ -150,28 +123,17 @@ export function createClient(socket, options={}) {
|
|
|
150
123
|
type2appHandler[type] = handler
|
|
151
124
|
}
|
|
152
125
|
|
|
153
|
-
function connect() {
|
|
154
|
-
if (options.debug) console.log('connecting...')
|
|
155
|
-
socket.connect()
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function disconnect() {
|
|
159
|
-
if (options.debug) console.log('disconnecting...')
|
|
160
|
-
socket.disconnect()
|
|
161
|
-
}
|
|
162
|
-
|
|
163
126
|
const app = {
|
|
164
127
|
configure,
|
|
165
128
|
addConnectListener,
|
|
166
129
|
removeConnectListener,
|
|
167
130
|
addDisconnectListener,
|
|
168
|
-
|
|
131
|
+
removeDisconnectListener,
|
|
169
132
|
addErrorListener,
|
|
170
133
|
removeErrorListener,
|
|
171
134
|
|
|
172
135
|
service,
|
|
173
136
|
on,
|
|
174
|
-
connect, disconnect,
|
|
175
137
|
}
|
|
176
138
|
|
|
177
139
|
return app
|
|
@@ -179,7 +141,7 @@ export function createClient(socket, options={}) {
|
|
|
179
141
|
|
|
180
142
|
|
|
181
143
|
////////////////////////// RELOAD PLUGIN //////////////////////////
|
|
182
|
-
//
|
|
144
|
+
// Enrich `app` with listeners handling socket data transfer on page reload
|
|
183
145
|
|
|
184
146
|
export async function reloadPlugin(app) {
|
|
185
147
|
|
|
@@ -188,19 +150,12 @@ export async function reloadPlugin(app) {
|
|
|
188
150
|
app.addConnectListener(async (socket) => {
|
|
189
151
|
const socketId = socket.id
|
|
190
152
|
console.log('connect', socketId)
|
|
191
|
-
// handle reconnections & reloads
|
|
192
|
-
// look for a previously stored connection id
|
|
193
153
|
const prevSocketId = cnxid.value
|
|
194
154
|
if (prevSocketId) {
|
|
195
|
-
// it's a connection after a reload/refresh
|
|
196
|
-
// ask server to transfer all data from connection `prevSocketId` to connection `socketId`
|
|
197
155
|
console.log('cnx-transfer', prevSocketId, 'to', socketId)
|
|
198
156
|
await socket.emit('cnx-transfer', prevSocketId, socketId)
|
|
199
|
-
// update connection id
|
|
200
157
|
cnxid.value = socketId
|
|
201
|
-
|
|
202
158
|
} else {
|
|
203
|
-
// set connection id
|
|
204
159
|
cnxid.value = socketId
|
|
205
160
|
}
|
|
206
161
|
|
|
@@ -210,30 +165,23 @@ export async function reloadPlugin(app) {
|
|
|
210
165
|
|
|
211
166
|
socket.on('cnx-transfer-error', async (fromSocketId, toSocketId) => {
|
|
212
167
|
console.log('ERR ERR!!!', fromSocketId, toSocketId)
|
|
213
|
-
// appState.value.unrecoverableError = true
|
|
214
168
|
})
|
|
215
169
|
})
|
|
216
170
|
}
|
|
217
171
|
|
|
218
172
|
|
|
219
173
|
////////////////////////// OFFLINE PLUGIN //////////////////////////
|
|
220
|
-
//
|
|
174
|
+
// Enrich `app` with methods, attributes and listeners to handle offline-first crud database access
|
|
221
175
|
|
|
222
176
|
export function offlinePlugin(app) {
|
|
223
177
|
|
|
178
|
+
const modelSyncFunctions = []
|
|
179
|
+
|
|
224
180
|
function createOfflineModel(modelName, fields) {
|
|
225
181
|
|
|
226
182
|
const dbName = modelName;
|
|
227
183
|
const db = getOrCreateDB(dbName, fields);
|
|
228
184
|
|
|
229
|
-
db.open().then(() => {
|
|
230
|
-
// console.log('db ready', dbName, modelName)
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
db.values.hook("updating", (changes, primaryKey, previousValue) => {
|
|
234
|
-
// console.log("CHANGES", primaryKey, changes, previousValue);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
185
|
const reset = async () => {
|
|
238
186
|
console.log('reset', modelName);
|
|
239
187
|
await db.whereList.clear();
|
|
@@ -252,20 +200,30 @@ export function offlinePlugin(app) {
|
|
|
252
200
|
|
|
253
201
|
app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
|
|
254
202
|
console.log(`${modelName} EVENT updateWithMeta`, value);
|
|
255
|
-
|
|
203
|
+
// value may be undefined when the server's UPDATE RETURNING yielded 0 rows
|
|
204
|
+
// (concurrent delete race: record was removed between the sync's findMany
|
|
205
|
+
// snapshot and the actual UPDATE). Guard to avoid a TypeError crash that
|
|
206
|
+
// would prevent db.metadata.put(meta) from running.
|
|
207
|
+
if (value?.uid) await db.values.put(value);
|
|
256
208
|
await db.metadata.put(meta);
|
|
257
209
|
});
|
|
258
210
|
|
|
259
211
|
app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
|
|
260
212
|
console.log(`${modelName} EVENT deleteWithMeta`, value)
|
|
261
|
-
|
|
262
|
-
|
|
213
|
+
// value may be undefined when the server's DELETE RETURNING yielded 0 rows
|
|
214
|
+
// (double-delete race). Guard before accessing .uid.
|
|
215
|
+
if (value?.uid) await db.values.delete(value.uid)
|
|
216
|
+
// delete, not put: synchronize() step 2 also deletes idbMetadata for the same
|
|
217
|
+
// uid. If the pub/sub handler fires AFTER step 2, put() would re-create the
|
|
218
|
+
// metadata row as a permanent orphan. delete() is idempotent regardless of order.
|
|
219
|
+
await db.metadata.delete(meta.uid)
|
|
263
220
|
});
|
|
264
221
|
|
|
265
222
|
|
|
266
223
|
///////////// CREATE/UPDATE/REMOVE /////////////
|
|
267
224
|
|
|
268
225
|
async function create(data) {
|
|
226
|
+
// in offline-first context, uid is created client-side, since server may not be accessible
|
|
269
227
|
const uid = uuidv7()
|
|
270
228
|
// optimistic update
|
|
271
229
|
const now = new Date()
|
|
@@ -278,6 +236,7 @@ export function offlinePlugin(app) {
|
|
|
278
236
|
console.log(`*** err sync ${modelName} create`, err)
|
|
279
237
|
// rollback
|
|
280
238
|
await db.values.delete(uid)
|
|
239
|
+
await db.metadata.delete(uid)
|
|
281
240
|
})
|
|
282
241
|
}
|
|
283
242
|
return await db.values.get(uid)
|
|
@@ -298,8 +257,11 @@ export function offlinePlugin(app) {
|
|
|
298
257
|
// rollback
|
|
299
258
|
delete previousValue.uid
|
|
300
259
|
await db.values.update(uid, previousValue)
|
|
301
|
-
|
|
302
|
-
|
|
260
|
+
// Only restore updated_at — the optimistic write only touched that field.
|
|
261
|
+
// Restoring the full previousMetadata snapshot would overwrite any
|
|
262
|
+
// deleted_at that remove() set while the socket round-trip was in flight,
|
|
263
|
+
// silently un-deleting the record.
|
|
264
|
+
await db.metadata.update(uid, { updated_at: previousMetadata.updated_at ?? null })
|
|
303
265
|
})
|
|
304
266
|
}
|
|
305
267
|
return await db.values.get(uid)
|
|
@@ -337,7 +299,6 @@ export function offlinePlugin(app) {
|
|
|
337
299
|
|
|
338
300
|
function getObservable(where = {}) {
|
|
339
301
|
addSynchroWhere(where).then((isNew: boolean) => {
|
|
340
|
-
// console.log('getObservable addSynchroWhere', modelName, where, isNew);
|
|
341
302
|
if (isNew && app.isConnected) {
|
|
342
303
|
synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
|
|
343
304
|
}
|
|
@@ -378,6 +339,8 @@ export function offlinePlugin(app) {
|
|
|
378
339
|
}
|
|
379
340
|
})
|
|
380
341
|
|
|
342
|
+
modelSyncFunctions.push(synchronizeAll)
|
|
343
|
+
|
|
381
344
|
return {
|
|
382
345
|
db, reset,
|
|
383
346
|
create, update, remove,
|
|
@@ -391,8 +354,11 @@ export function offlinePlugin(app) {
|
|
|
391
354
|
app.addConnectListener(async (_socket) => {
|
|
392
355
|
app.connectedDate = new Date()
|
|
393
356
|
console.log('onConnect', app.connectedDate)
|
|
394
|
-
app.disconnectedDate = null
|
|
395
357
|
app.isConnected = true
|
|
358
|
+
if (app.disconnectedDate) {
|
|
359
|
+
modelSyncFunctions.forEach(sync => sync())
|
|
360
|
+
}
|
|
361
|
+
app.disconnectedDate = null
|
|
396
362
|
})
|
|
397
363
|
|
|
398
364
|
app.addDisconnectListener(async (_socket) => {
|
|
@@ -410,7 +376,6 @@ export function offlinePlugin(app) {
|
|
|
410
376
|
await mutex.acquire()
|
|
411
377
|
console.log('synchronize', modelName, where)
|
|
412
378
|
|
|
413
|
-
let toAdd = []
|
|
414
379
|
try {
|
|
415
380
|
const requestPredicate = wherePredicate(where)
|
|
416
381
|
|
|
@@ -429,41 +394,51 @@ export function offlinePlugin(app) {
|
|
|
429
394
|
}
|
|
430
395
|
|
|
431
396
|
// call sync service on `where` perimeter
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
console.log('-> service.sync', modelName, where, toAdd, toUpdate, toDelete, addDatabase, updateDatabase)
|
|
397
|
+
const { addClient, updateClient, deleteClient, addDatabase, updateDatabase } =
|
|
398
|
+
await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
|
|
399
|
+
console.log('-> service.sync', modelName, where, addClient, updateClient, deleteClient, addDatabase, updateDatabase)
|
|
436
400
|
|
|
437
401
|
// 1- add missing elements in indexedDB cache
|
|
438
|
-
// Use a single transaction for all adds to ensure atomicity
|
|
439
|
-
|
|
402
|
+
// Use a single transaction for all adds to ensure atomicity.
|
|
403
|
+
// put() instead of add() for metadata: a deleteWithMeta pub/sub event leaves
|
|
404
|
+
// an orphaned metadata row (value deleted, metadata kept with deleted_at).
|
|
405
|
+
// add() would throw a ConstraintError on that orphan; put() upserts safely.
|
|
406
|
+
if (addClient.length > 0) {
|
|
440
407
|
await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
|
|
441
|
-
for (const [value, metaData] of
|
|
442
|
-
|
|
443
|
-
|
|
408
|
+
for (const [value, metaData] of addClient) {
|
|
409
|
+
// put() instead of add(): if create() ran concurrently and added this
|
|
410
|
+
// uid to Dexie between the idbValues.filter snapshot and this step,
|
|
411
|
+
// add() would throw ConstraintError and abort the entire transaction,
|
|
412
|
+
// silently dropping every other addClient record in the batch.
|
|
413
|
+
await idbValues.put(value)
|
|
414
|
+
await idbMetadata.put(metaData)
|
|
444
415
|
}
|
|
445
416
|
})
|
|
446
417
|
}
|
|
447
418
|
// 2- delete elements from indexedDB cache
|
|
448
|
-
for (const [uid
|
|
419
|
+
for (const [uid] of deleteClient) {
|
|
449
420
|
await idbValues.delete(uid)
|
|
450
|
-
await idbMetadata.
|
|
421
|
+
await idbMetadata.delete(uid)
|
|
451
422
|
}
|
|
452
|
-
// 3- update elements of cache
|
|
453
|
-
for (const elt of
|
|
454
|
-
|
|
455
|
-
const value = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
|
|
423
|
+
// 3- update elements of cache with server's newer version
|
|
424
|
+
for (const [elt, serverMeta] of updateClient) {
|
|
425
|
+
const value = { ...elt }
|
|
456
426
|
delete value.uid
|
|
457
427
|
delete value.__deleted__
|
|
458
428
|
await idbValues.update(elt.uid, value)
|
|
459
|
-
|
|
460
|
-
await idbMetadata.update(elt.uid, { updated_at: metadata.updated_at })
|
|
429
|
+
await idbMetadata.update(elt.uid, { updated_at: serverMeta.updated_at })
|
|
461
430
|
}
|
|
462
431
|
|
|
463
432
|
// 4- create elements of `addDatabase` with full data from cache
|
|
464
433
|
for (const elt of addDatabase) {
|
|
434
|
+
// elt.uid is undefined when the clientMetadataDict fallback {} was used
|
|
435
|
+
// (record exists in idbValues but metadata is missing). Guard before the
|
|
436
|
+
// get() call: idbValues.get(undefined) itself throws before fullValue is
|
|
437
|
+
// assigned, so checking fullValue == null afterwards is too late.
|
|
438
|
+
if (elt.uid == null) continue
|
|
465
439
|
const fullValue = await idbValues.get(elt.uid)
|
|
466
440
|
const meta = await idbMetadata.get(elt.uid)
|
|
441
|
+
if (fullValue == null) continue // record deleted concurrently
|
|
467
442
|
delete fullValue.uid
|
|
468
443
|
delete fullValue.__deleted__
|
|
469
444
|
try {
|
|
@@ -478,19 +453,17 @@ export function offlinePlugin(app) {
|
|
|
478
453
|
|
|
479
454
|
// 5- update elements of `updateDatabase` with full data from cache
|
|
480
455
|
for (const elt of updateDatabase) {
|
|
456
|
+
if (elt.uid == null) continue
|
|
481
457
|
const fullValue = await idbValues.get(elt.uid)
|
|
482
458
|
const meta = await idbMetadata.get(elt.uid)
|
|
459
|
+
if (fullValue == null) continue // record deleted concurrently
|
|
483
460
|
delete fullValue.uid
|
|
484
461
|
delete fullValue.__deleted__
|
|
485
462
|
try {
|
|
486
463
|
await app.service(modelName).updateWithMeta(elt.uid, fullValue, meta.updated_at)
|
|
487
464
|
} catch(err) {
|
|
488
465
|
console.log("*** err sync user updateDatabase", err)
|
|
489
|
-
//
|
|
490
|
-
const previousDatabaseValue = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
|
|
491
|
-
const previousDatabaseMetadata = await app.service('metadata').findUnique({ where:{ uid: elt.uid }})
|
|
492
|
-
await idbValues.update(elt.uid, previousDatabaseValue)
|
|
493
|
-
await idbMetadata.update(elt.uid, previousDatabaseMetadata)
|
|
466
|
+
// Leave client's local version intact; it will be retried on the next sync.
|
|
494
467
|
}
|
|
495
468
|
}
|
|
496
469
|
} catch(err) {
|
|
@@ -500,30 +473,20 @@ export function offlinePlugin(app) {
|
|
|
500
473
|
}
|
|
501
474
|
}
|
|
502
475
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (eltAttrValue > value.lte) return false
|
|
516
|
-
} else if (value.lt) {
|
|
517
|
-
if (eltAttrValue >= value.lt) return false
|
|
518
|
-
} else if (value.gte) {
|
|
519
|
-
if (eltAttrValue < value.gte) return false
|
|
520
|
-
} else if (value.gt) {
|
|
521
|
-
if (eltAttrValue <= value.gt) return false
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return true
|
|
476
|
+
// Singleton map to reuse Dexie instances per database name
|
|
477
|
+
const dbInstances = new Map();
|
|
478
|
+
|
|
479
|
+
function getOrCreateDB(dbName: string, fields: string[]) {
|
|
480
|
+
if (!dbInstances.has(dbName)) {
|
|
481
|
+
const db = new Dexie(dbName);
|
|
482
|
+
db.version(1).stores({
|
|
483
|
+
whereList: "sortedjson",
|
|
484
|
+
values: ['uid', '__deleted__', ...fields].join(','),
|
|
485
|
+
metadata: "uid, created_at, updated_at, deleted_at",
|
|
486
|
+
});
|
|
487
|
+
dbInstances.set(dbName, db);
|
|
526
488
|
}
|
|
489
|
+
return dbInstances.get(dbName);
|
|
527
490
|
}
|
|
528
491
|
|
|
529
492
|
async function getWhereList(whereDb) {
|
|
@@ -568,22 +531,6 @@ export function offlinePlugin(app) {
|
|
|
568
531
|
}
|
|
569
532
|
}
|
|
570
533
|
|
|
571
|
-
// Singleton map to reuse Dexie instances per database name
|
|
572
|
-
const dbInstances = new Map();
|
|
573
|
-
|
|
574
|
-
function getOrCreateDB(dbName: string, fields: string[]) {
|
|
575
|
-
if (!dbInstances.has(dbName)) {
|
|
576
|
-
const db = new Dexie(dbName);
|
|
577
|
-
db.version(1).stores({
|
|
578
|
-
whereList: "sortedjson",
|
|
579
|
-
values: ['uid', '__deleted__', ...fields].join(','),
|
|
580
|
-
metadata: "uid, created_at, updated_at, deleted_at",
|
|
581
|
-
});
|
|
582
|
-
dbInstances.set(dbName, db);
|
|
583
|
-
}
|
|
584
|
-
return dbInstances.get(dbName);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
534
|
// enrich `app` with new methods and attributes
|
|
588
535
|
return Object.assign(app, {
|
|
589
536
|
createOfflineModel,
|
|
@@ -593,17 +540,6 @@ export function offlinePlugin(app) {
|
|
|
593
540
|
|
|
594
541
|
////////////////////////// UTILITIES //////////////////////////
|
|
595
542
|
|
|
596
|
-
function generateUID(length) {
|
|
597
|
-
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
598
|
-
let uid = ''
|
|
599
|
-
|
|
600
|
-
for (let i = 0; i < length; i++) {
|
|
601
|
-
const randomIndex = Math.floor(Math.random() * characters.length)
|
|
602
|
-
uid += characters.charAt(randomIndex)
|
|
603
|
-
}
|
|
604
|
-
return uid
|
|
605
|
-
}
|
|
606
|
-
|
|
607
543
|
|
|
608
544
|
function stringifyWithSortedKeys(obj, space = null) {
|
|
609
545
|
return JSON.stringify(obj, (key, value) => {
|
|
@@ -646,10 +582,49 @@ export class Mutex {
|
|
|
646
582
|
}
|
|
647
583
|
}
|
|
648
584
|
|
|
585
|
+
function wherePredicate(where) {
|
|
586
|
+
return (elt) => {
|
|
587
|
+
for (const [attr, value] of Object.entries(where)) {
|
|
588
|
+
const eltAttrValue = elt[attr]
|
|
589
|
+
|
|
590
|
+
if (typeof(value) === 'string' || typeof(value) === 'number' || typeof(value) === 'boolean') {
|
|
591
|
+
// 'attr = value' clause
|
|
592
|
+
if (eltAttrValue !== value) return false
|
|
593
|
+
|
|
594
|
+
} else if (value === null) {
|
|
595
|
+
// 'attr = null' clause
|
|
596
|
+
if (eltAttrValue !== null) return false
|
|
597
|
+
|
|
598
|
+
} else if (typeof(value) === 'object') {
|
|
599
|
+
// 'attr = { lt/lte/gt/gte: value }' clause — all bounds apply.
|
|
600
|
+
// A missing (undefined) or null field never satisfies a range constraint,
|
|
601
|
+
// consistent with SQL NULL behaviour (NULL op anything = NULL = unknown).
|
|
602
|
+
// JS coerces null → 0 so range guards like `null > 10` silently pass;
|
|
603
|
+
// undefined coerces to NaN and all NaN comparisons return false — both
|
|
604
|
+
// must be excluded explicitly.
|
|
605
|
+
if (eltAttrValue === undefined || eltAttrValue === null) return false
|
|
606
|
+
if ('lte' in value && eltAttrValue > value.lte) return false
|
|
607
|
+
if ('lt' in value && eltAttrValue >= value.lt) return false
|
|
608
|
+
if ('gte' in value && eltAttrValue < value.gte) return false
|
|
609
|
+
if ('gt' in value && eltAttrValue <= value.gt) return false
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return true
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
649
616
|
function isSubset(subset, fullObject) {
|
|
650
|
-
// return Object.entries(subset).some(([key, value]) => fullObject[key] === value)
|
|
651
617
|
for (const key in fullObject) {
|
|
652
|
-
|
|
618
|
+
const fVal = fullObject[key]
|
|
619
|
+
const sVal = subset[key]
|
|
620
|
+
// Primitive values: use reference/value equality (works for string, number, boolean).
|
|
621
|
+
// Object values (e.g. range operators { gte: 1 }): use structural equality via
|
|
622
|
+
// sorted JSON so that two freshly-created identical objects compare as equal.
|
|
623
|
+
if (typeof fVal === 'object' && fVal !== null) {
|
|
624
|
+
if (stringifyWithSortedKeys(fVal) !== stringifyWithSortedKeys(sVal)) return false
|
|
625
|
+
} else {
|
|
626
|
+
if (fVal !== sVal) return false
|
|
627
|
+
}
|
|
653
628
|
}
|
|
654
629
|
return true
|
|
655
630
|
}
|
|
@@ -663,4 +638,3 @@ function isSubsetAmong(subset, fullObjectList) {
|
|
|
663
638
|
return fullObjectList.some(fullObject => isSubset(subset, fullObject));
|
|
664
639
|
}
|
|
665
640
|
// console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
|
|
666
|
-
|
package/prisma/dev.db
DELETED
|
Binary file
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
-- CreateTable
|
|
2
|
-
CREATE TABLE "User" (
|
|
3
|
-
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
4
|
-
"name" TEXT NOT NULL,
|
|
5
|
-
"email" TEXT NOT NULL
|
|
6
|
-
);
|
|
7
|
-
|
|
8
|
-
-- CreateTable
|
|
9
|
-
CREATE TABLE "Post" (
|
|
10
|
-
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
11
|
-
"text" TEXT NOT NULL,
|
|
12
|
-
"authorId" INTEGER NOT NULL,
|
|
13
|
-
CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
-- CreateIndex
|
|
17
|
-
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
package/prisma/schema.prisma
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
generator client {
|
|
2
|
-
provider = "prisma-client-js"
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
model User {
|
|
6
|
-
id Int @default(autoincrement()) @id
|
|
7
|
-
name String
|
|
8
|
-
email String @unique
|
|
9
|
-
posts Post[]
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
model Post {
|
|
13
|
-
id Int @default(autoincrement()) @id
|
|
14
|
-
text String
|
|
15
|
-
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
16
|
-
authorId Int
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
datasource db {
|
|
20
|
-
provider = "sqlite"
|
|
21
|
-
url = "file:./dev.db"
|
|
22
|
-
}
|