@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-client",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "Client library for ExpressX framework",
6
6
  "main": "src/client.mts",
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 removeDisonnectListener(func) {
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
- // create a promise which will resolve or reject by an event 'client-response'
93
- const uid = generateUID(20)
94
- const promise = new Promise((resolve, reject) => {
95
- waitingPromisesByUid[uid] = [resolve, reject]
96
- // a timeout may also reject the promise
97
- if (serviceOptions.timeout && !serviceOptions.volatile) {
98
- setTimeout(() => {
99
- delete waitingPromisesByUid[uid]
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
- removeDisonnectListener,
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
- // enrich `app` with listeners handling socket data transfer on page reload
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
- // enrich `app` with methods, attributes and listeners to handle offline-first database access
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
- await db.values.put(value);
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
- await db.values.delete(value.uid)
262
- await db.metadata.put(meta)
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
- delete previousMetadata.uid
302
- await db.metadata.update(uid, previousMetadata)
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 syncResult = await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
433
- toAdd = syncResult.toAdd
434
- const { toUpdate, toDelete, addDatabase, updateDatabase } = syncResult
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
- if (toAdd.length > 0) {
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 toAdd) {
442
- await idbValues.add(value)
443
- await idbMetadata.add(metaData)
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, deleted_at] of toDelete) {
419
+ for (const [uid] of deleteClient) {
449
420
  await idbValues.delete(uid)
450
- await idbMetadata.update(uid, { deleted_at })
421
+ await idbMetadata.delete(uid)
451
422
  }
452
- // 3- update elements of cache
453
- for (const elt of toUpdate) {
454
- // get full value of element to update
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
- const metadata = await idbMetadata.get(elt.uid)
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
- // rollback
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
- function wherePredicate(where) {
504
- return (elt) => {
505
- for (const [attr, value] of Object.entries(where)) {
506
- const eltAttrValue = elt[attr]
507
-
508
- if (typeof(value) === 'string' || typeof(value) === 'number') {
509
- // 'attr = value' clause
510
- if (eltAttrValue !== value) return false
511
-
512
- } else if (typeof(value) === 'object') {
513
- // 'attr = { lt/lte/gt/gte: value }' clause
514
- if (value.lte) {
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
- if (fullObject[key] !== subset[key]) return false
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");
@@ -1,3 +0,0 @@
1
- # Please do not edit this file manually
2
- # It should be added in your version-control system (i.e. Git)
3
- provider = "sqlite"
@@ -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
- }