@jcbuisson/express-x 3.0.3 → 3.0.5
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 +57 -67
- package/package.json +18 -3
- package/src/server.mjs +58 -0
- package/test/offline.test.mjs +1368 -0
- package/test/sync.test.mjs +127 -0
- package/.claude/settings.local.json +0 -9
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
// Must be imported before Dexie so it patches globalThis.indexedDB first
|
|
2
|
+
import 'fake-indexeddb/auto'
|
|
3
|
+
|
|
4
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
5
|
+
console.error('UNHANDLED REJECTION:', reason)
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
import { test, describe } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { io as ioc } from 'socket.io-client'
|
|
11
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
12
|
+
import { drizzle } from 'drizzle-orm/pglite'
|
|
13
|
+
import { pgTable, text, timestamp, integer } from 'drizzle-orm/pg-core'
|
|
14
|
+
import { eq } from 'drizzle-orm'
|
|
15
|
+
|
|
16
|
+
import { expressX, computeSyncResult } from '@jcbuisson/express-x'
|
|
17
|
+
import { createClient, offlinePlugin } from '@jcbuisson/express-x-client'
|
|
18
|
+
|
|
19
|
+
import { drizzleOfflinePlugin } from '@jcbuisson/express-x-drizzle'
|
|
20
|
+
|
|
21
|
+
const T0 = new Date('2026-01-01T00:00:00Z')
|
|
22
|
+
const T1 = new Date('2026-01-02T00:00:00Z')
|
|
23
|
+
const T2 = new Date('2026-01-03T00:00:00Z')
|
|
24
|
+
|
|
25
|
+
let dbCounter = 0
|
|
26
|
+
|
|
27
|
+
// ─── In-memory DB helper ──────────────────────────────────────────────────────
|
|
28
|
+
// Each test gets a fresh PGlite instance with a unique model table so tests
|
|
29
|
+
// are fully isolated. Model names use underscores (PG-safe identifiers).
|
|
30
|
+
|
|
31
|
+
async function createTestDb(modelName) {
|
|
32
|
+
const pglite = new PGlite()
|
|
33
|
+
await pglite.exec(`
|
|
34
|
+
CREATE TABLE metadata (
|
|
35
|
+
uid TEXT PRIMARY KEY,
|
|
36
|
+
created_at TIMESTAMP,
|
|
37
|
+
updated_at TIMESTAMP,
|
|
38
|
+
deleted_at TIMESTAMP
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE "${modelName}" (
|
|
41
|
+
uid TEXT PRIMARY KEY,
|
|
42
|
+
label TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
`)
|
|
45
|
+
const db = drizzle(pglite)
|
|
46
|
+
const metaTable = pgTable('metadata', {
|
|
47
|
+
uid: text('uid').primaryKey(),
|
|
48
|
+
created_at: timestamp(),
|
|
49
|
+
updated_at: timestamp(),
|
|
50
|
+
deleted_at: timestamp(),
|
|
51
|
+
})
|
|
52
|
+
const modelTable = pgTable(modelName, {
|
|
53
|
+
uid: text('uid').primaryKey(),
|
|
54
|
+
label: text('label').notNull(),
|
|
55
|
+
})
|
|
56
|
+
return { db, metaTable, modelTable }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Test context helper ──────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async function createTestContext(registerServices, { useOfflinePlugin = false } = {}) {
|
|
62
|
+
const serverApp = expressX({})
|
|
63
|
+
registerServices(serverApp)
|
|
64
|
+
|
|
65
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
66
|
+
const port = serverApp.httpServer.address().port
|
|
67
|
+
|
|
68
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
69
|
+
transports: ['websocket'],
|
|
70
|
+
autoConnect: false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const clientApp = createClient(socket, { debug: false })
|
|
74
|
+
if (useOfflinePlugin) offlinePlugin(clientApp)
|
|
75
|
+
|
|
76
|
+
socket.connect()
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
socket.on('connect', resolve)
|
|
79
|
+
socket.on('connect_error', reject)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const cleanup = () => new Promise(resolve => {
|
|
83
|
+
socket.disconnect()
|
|
84
|
+
serverApp.io.close(resolve)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return { clientApp, cleanup }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('Full offline-first client ↔ server protocol', () => {
|
|
93
|
+
|
|
94
|
+
test('service call is routed through client-request / client-response', async () => {
|
|
95
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
96
|
+
serverApp.createService('greet', {
|
|
97
|
+
hello: async (name) => `Hello, ${name}!`,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const result = await clientApp.service('greet').hello('World')
|
|
103
|
+
assert.equal(result, 'Hello, World!')
|
|
104
|
+
} finally {
|
|
105
|
+
await cleanup()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('server error is propagated to the client as a rejection', async () => {
|
|
110
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
111
|
+
serverApp.createService('broken', {
|
|
112
|
+
explode: async () => { throw new Error('something went wrong') },
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await assert.rejects(
|
|
118
|
+
() => clientApp.service('broken').explode(),
|
|
119
|
+
err => {
|
|
120
|
+
assert.match(err.message, /something went wrong/)
|
|
121
|
+
return true
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
} finally {
|
|
125
|
+
await cleanup()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('sync.go through socket: server records pulled into real Dexie', async () => {
|
|
130
|
+
const modelName = `model${++dbCounter}`
|
|
131
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
132
|
+
|
|
133
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'Vacances' })
|
|
134
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
135
|
+
|
|
136
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
137
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
138
|
+
{ useOfflinePlugin: true },
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
143
|
+
await model.addSynchroWhere({})
|
|
144
|
+
await model.synchronizeAll()
|
|
145
|
+
|
|
146
|
+
const r1 = await model.db.values.get('r1')
|
|
147
|
+
assert.ok(r1, 'Dexie should contain the record pulled from server via socket')
|
|
148
|
+
assert.equal(r1.label, 'Vacances')
|
|
149
|
+
} finally {
|
|
150
|
+
await cleanup()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('sync.go through socket: local Dexie record pushed to server', async () => {
|
|
155
|
+
const modelName = `model${++dbCounter}`
|
|
156
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
157
|
+
|
|
158
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
159
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
160
|
+
{ useOfflinePlugin: true },
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
165
|
+
await model.db.values.add({ uid: 'x1', label: 'Formation' })
|
|
166
|
+
await model.db.metadata.add({ uid: 'x1', created_at: T1 })
|
|
167
|
+
await model.addSynchroWhere({})
|
|
168
|
+
await model.synchronizeAll()
|
|
169
|
+
|
|
170
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'x1'))
|
|
171
|
+
assert.ok(rows.length > 0, 'server should have received the pushed record')
|
|
172
|
+
assert.equal(rows[0].label, 'Formation')
|
|
173
|
+
} finally {
|
|
174
|
+
await cleanup()
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('record only on client, deleted → ignored on both sides', async () => {
|
|
179
|
+
const modelName = `model${++dbCounter}`
|
|
180
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
181
|
+
|
|
182
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
183
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
184
|
+
{ useOfflinePlugin: true },
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
189
|
+
await model.db.values.add({ uid: 'd1', label: 'Gone', __deleted__: true })
|
|
190
|
+
await model.db.metadata.add({ uid: 'd1', created_at: T0, deleted_at: T1 })
|
|
191
|
+
await model.addSynchroWhere({})
|
|
192
|
+
await model.synchronizeAll()
|
|
193
|
+
|
|
194
|
+
const rows = await db.select().from(modelTable)
|
|
195
|
+
assert.ok(!rows.find(r => r.uid === 'd1'), 'server should not have the deleted-only record')
|
|
196
|
+
const d1 = await model.db.values.get('d1')
|
|
197
|
+
assert.ok(!d1, 'Dexie should no longer hold the deleted record')
|
|
198
|
+
} finally {
|
|
199
|
+
await cleanup()
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('create() rollback removes metadata when server rejects', async () => {
|
|
204
|
+
const modelName = `model${++dbCounter}`
|
|
205
|
+
|
|
206
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
207
|
+
serverApp.createService(modelName, {
|
|
208
|
+
createWithMeta: async () => { throw new Error('server rejected') },
|
|
209
|
+
findMany: async () => [],
|
|
210
|
+
updateWithMeta: async () => {},
|
|
211
|
+
deleteWithMeta: async () => {},
|
|
212
|
+
})
|
|
213
|
+
serverApp.createService('sync', {
|
|
214
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
215
|
+
})
|
|
216
|
+
}, { useOfflinePlugin: true })
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
220
|
+
|
|
221
|
+
// create() is optimistic: value + metadata are written to Dexie before the
|
|
222
|
+
// server responds, then rolled back if the server rejects.
|
|
223
|
+
const record = await model.create({ label: 'test' })
|
|
224
|
+
const uid = record.uid
|
|
225
|
+
|
|
226
|
+
assert.ok(await model.db.values.get(uid), 'value should exist optimistically')
|
|
227
|
+
assert.ok(await model.db.metadata.get(uid), 'metadata should exist optimistically')
|
|
228
|
+
|
|
229
|
+
// Poll until the rollback removes the value (server rejection processed)
|
|
230
|
+
for (let i = 0; i < 50; i++) {
|
|
231
|
+
await new Promise(r => setTimeout(r, 10))
|
|
232
|
+
if (!await model.db.values.get(uid)) break
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
assert.ok(!await model.db.values.get(uid), 'value should be removed after rollback')
|
|
236
|
+
// Metadata must also be cleaned up — currently it is not
|
|
237
|
+
assert.ok(!await model.db.metadata.get(uid), 'metadata should also be removed after rollback')
|
|
238
|
+
} finally {
|
|
239
|
+
await cleanup()
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('deleted record is fully removed from Dexie metadata after sync', async () => {
|
|
244
|
+
const modelName = `model${++dbCounter}`
|
|
245
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
246
|
+
|
|
247
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
248
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
249
|
+
{ useOfflinePlugin: true },
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
254
|
+
// Record created and deleted locally before ever reaching the server
|
|
255
|
+
await model.db.values.add({ uid: 'd1', label: 'Gone', __deleted__: true })
|
|
256
|
+
await model.db.metadata.add({ uid: 'd1', created_at: T0, deleted_at: T1 })
|
|
257
|
+
await model.addSynchroWhere({})
|
|
258
|
+
await model.synchronizeAll()
|
|
259
|
+
|
|
260
|
+
const d1 = await model.db.values.get('d1')
|
|
261
|
+
assert.ok(!d1, 'Dexie values should not hold deleted record')
|
|
262
|
+
|
|
263
|
+
// Metadata must also be removed — orphaned rows waste space and cause
|
|
264
|
+
// a ConstraintError if a record with the same uid is ever re-created
|
|
265
|
+
const d1Meta = await model.db.metadata.get('d1')
|
|
266
|
+
assert.ok(!d1Meta, 'Dexie metadata should also be removed for deleted record')
|
|
267
|
+
} finally {
|
|
268
|
+
await cleanup()
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('record in both, DB newer → client cache is updated with server value', async () => {
|
|
273
|
+
const modelName = `model${++dbCounter}`
|
|
274
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
275
|
+
|
|
276
|
+
// Server has s1 at T2 (newer than client)
|
|
277
|
+
await db.insert(modelTable).values({ uid: 's1', label: 'server-v2' })
|
|
278
|
+
await db.insert(metaTable).values({ uid: 's1', created_at: T0, updated_at: T2 })
|
|
279
|
+
|
|
280
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
281
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
282
|
+
{ useOfflinePlugin: true },
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
287
|
+
// Client has s1 at T1 (stale)
|
|
288
|
+
await model.db.values.add({ uid: 's1', label: 'client-v1' })
|
|
289
|
+
await model.db.metadata.add({ uid: 's1', created_at: T0, updated_at: T1 })
|
|
290
|
+
await model.addSynchroWhere({})
|
|
291
|
+
await model.synchronizeAll()
|
|
292
|
+
|
|
293
|
+
const s1 = await model.db.values.get('s1')
|
|
294
|
+
assert.equal(s1.label, 'server-v2', 'client Dexie should be updated with server\'s newer value')
|
|
295
|
+
|
|
296
|
+
// A second sync must be a no-op — proves the client timestamp was
|
|
297
|
+
// correctly updated to the server's, avoiding an infinite re-sync loop.
|
|
298
|
+
await model.synchronizeAll()
|
|
299
|
+
const s1Again = await model.db.values.get('s1')
|
|
300
|
+
assert.equal(s1Again.label, 'server-v2', 'second sync should not overwrite client value')
|
|
301
|
+
} finally {
|
|
302
|
+
await cleanup()
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('one failed updateWithMeta does not abort remaining updateDatabase entries', async () => {
|
|
307
|
+
const modelName = `model${++dbCounter}`
|
|
308
|
+
const serverUpdated = {}
|
|
309
|
+
|
|
310
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
311
|
+
serverApp.createService('sync', {
|
|
312
|
+
// Tell the client both u1 and u2 need to be pushed (client is newer for both)
|
|
313
|
+
go: async (mn, where, cutoff, clientMetadataDict) => ({
|
|
314
|
+
addClient: [], updateClient: [], deleteClient: [], addDatabase: [],
|
|
315
|
+
updateDatabase: [ clientMetadataDict['u1'], clientMetadataDict['u2'] ],
|
|
316
|
+
}),
|
|
317
|
+
})
|
|
318
|
+
serverApp.createService(modelName, {
|
|
319
|
+
updateWithMeta: async (uid, data) => {
|
|
320
|
+
if (uid === 'u1') throw new Error('server rejected u1')
|
|
321
|
+
serverUpdated[uid] = data // u2 succeeds
|
|
322
|
+
},
|
|
323
|
+
createWithMeta: async () => {},
|
|
324
|
+
deleteWithMeta: async () => {},
|
|
325
|
+
findUnique: async () => null,
|
|
326
|
+
findMany: async () => [],
|
|
327
|
+
})
|
|
328
|
+
}, { useOfflinePlugin: true })
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
332
|
+
await model.db.values.add({ uid: 'u1', label: 'new-u1' })
|
|
333
|
+
await model.db.metadata.add({ uid: 'u1', created_at: T0, updated_at: T2 })
|
|
334
|
+
await model.db.values.add({ uid: 'u2', label: 'new-u2' })
|
|
335
|
+
await model.db.metadata.add({ uid: 'u2', created_at: T0, updated_at: T2 })
|
|
336
|
+
await model.addSynchroWhere({})
|
|
337
|
+
await model.synchronizeAll()
|
|
338
|
+
|
|
339
|
+
// u2's push must succeed even though u1's updateWithMeta threw
|
|
340
|
+
assert.ok(serverUpdated['u2'], 'u2 should be pushed to server despite u1 failure')
|
|
341
|
+
assert.equal(serverUpdated['u2'].label, 'new-u2')
|
|
342
|
+
} finally {
|
|
343
|
+
await cleanup()
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('record in both, client newer → server is updated via socket', async () => {
|
|
348
|
+
const modelName = `model${++dbCounter}`
|
|
349
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
350
|
+
|
|
351
|
+
await db.insert(modelTable).values({ uid: 'u1', label: 'old' })
|
|
352
|
+
await db.insert(metaTable).values({ uid: 'u1', created_at: T0, updated_at: T1 })
|
|
353
|
+
|
|
354
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
355
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
356
|
+
{ useOfflinePlugin: true },
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
361
|
+
await model.db.values.add({ uid: 'u1', label: 'new' })
|
|
362
|
+
await model.db.metadata.add({ uid: 'u1', created_at: T0, updated_at: T2 })
|
|
363
|
+
await model.addSynchroWhere({})
|
|
364
|
+
await model.synchronizeAll()
|
|
365
|
+
|
|
366
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'u1'))
|
|
367
|
+
assert.equal(rows[0].label, 'new', 'server should have the updated label')
|
|
368
|
+
const clientValue = await model.db.values.get('u1')
|
|
369
|
+
assert.equal(clientValue.label, 'new', 'client Dexie should be unchanged')
|
|
370
|
+
} finally {
|
|
371
|
+
await cleanup()
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('record deleted on server while client was offline is re-created on reconnect', async () => {
|
|
376
|
+
const modelName = `model${++dbCounter}`
|
|
377
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
378
|
+
|
|
379
|
+
// r1 exists on server initially
|
|
380
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
|
|
381
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
382
|
+
|
|
383
|
+
// Server-side delete while client was offline: hard-delete from model table,
|
|
384
|
+
// but metadata row stays with deleted_at set (this is what deleteWithMeta does)
|
|
385
|
+
await db.delete(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
386
|
+
await db.update(metaTable).set({ deleted_at: T1 }).where(eq(metaTable.uid, 'r1'))
|
|
387
|
+
|
|
388
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
389
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
390
|
+
{ useOfflinePlugin: true },
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
395
|
+
// Client has r1 — it was offline when the server deleted it
|
|
396
|
+
await model.db.values.add({ uid: 'r1', label: 'keep me' })
|
|
397
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
398
|
+
await model.addSynchroWhere({})
|
|
399
|
+
await model.synchronizeAll()
|
|
400
|
+
|
|
401
|
+
// Client's copy should have been pushed back to the server
|
|
402
|
+
const rows = await db.select().from(modelTable)
|
|
403
|
+
assert.ok(rows.find(r => r.uid === 'r1'), 'server should have r1 after client pushes it back')
|
|
404
|
+
assert.equal(rows.find(r => r.uid === 'r1').label, 'keep me')
|
|
405
|
+
// And client's Dexie should still have it
|
|
406
|
+
const r1 = await model.db.values.get('r1')
|
|
407
|
+
assert.ok(r1, 'client Dexie should still have r1 after sync')
|
|
408
|
+
} finally {
|
|
409
|
+
await cleanup()
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('offline changes are synced after server restart', async () => {
|
|
414
|
+
const modelName = `model${++dbCounter}`
|
|
415
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
416
|
+
|
|
417
|
+
// ─ Phase 1: start server, connect, register synchro scope ─
|
|
418
|
+
const serverApp1 = expressX({})
|
|
419
|
+
serverApp1.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
420
|
+
await new Promise(resolve => serverApp1.httpServer.listen(0, resolve))
|
|
421
|
+
const port = serverApp1.httpServer.address().port
|
|
422
|
+
|
|
423
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
424
|
+
transports: ['websocket'],
|
|
425
|
+
autoConnect: false,
|
|
426
|
+
reconnectionDelay: 100,
|
|
427
|
+
reconnectionDelayMax: 500,
|
|
428
|
+
})
|
|
429
|
+
const clientApp = createClient(socket, { debug: false })
|
|
430
|
+
offlinePlugin(clientApp)
|
|
431
|
+
socket.connect()
|
|
432
|
+
await new Promise((resolve, reject) => {
|
|
433
|
+
socket.on('connect', resolve)
|
|
434
|
+
socket.on('connect_error', reject)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
438
|
+
await model.addSynchroWhere({})
|
|
439
|
+
await model.synchronizeAll() // initial sync — server is empty
|
|
440
|
+
|
|
441
|
+
// ─ Phase 2: stop server; disconnect all clients ─
|
|
442
|
+
// io.disconnectSockets() gives 'io server disconnect' which suppresses
|
|
443
|
+
// socket.io auto-reconnect, so we reconnect manually in phase 5.
|
|
444
|
+
const disconnected = new Promise(resolve => socket.once('disconnect', resolve))
|
|
445
|
+
serverApp1.io.disconnectSockets(true)
|
|
446
|
+
await new Promise(resolve => serverApp1.io.close(resolve))
|
|
447
|
+
await disconnected
|
|
448
|
+
|
|
449
|
+
assert.equal(clientApp.isConnected, false, 'client should be offline')
|
|
450
|
+
|
|
451
|
+
// ─ Phase 3: write to Dexie while offline ─
|
|
452
|
+
await model.db.values.add({ uid: 'y1', label: 'Offline change' })
|
|
453
|
+
await model.db.metadata.add({ uid: 'y1', created_at: T1 })
|
|
454
|
+
|
|
455
|
+
// ─ Phase 4: restart server on the same port ─
|
|
456
|
+
const serverApp2 = expressX({})
|
|
457
|
+
serverApp2.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
458
|
+
await new Promise(resolve => serverApp2.httpServer.listen(port, resolve))
|
|
459
|
+
|
|
460
|
+
// ─ Phase 5: reconnect manually (auto-reconnect is suppressed after
|
|
461
|
+
// server-initiated disconnect) and wait for offlinePlugin to fire ─
|
|
462
|
+
const reconnected = new Promise((resolve, reject) => {
|
|
463
|
+
const timer = setTimeout(() => reject(new Error('reconnect timeout')), 5000)
|
|
464
|
+
socket.once('connect', () => { clearTimeout(timer); resolve() })
|
|
465
|
+
})
|
|
466
|
+
socket.connect()
|
|
467
|
+
await reconnected
|
|
468
|
+
|
|
469
|
+
// ─ Phase 6: offlinePlugin's connect listener fires synchronizeAll in the
|
|
470
|
+
// background; awaiting it here serialises behind the mutex so assertions
|
|
471
|
+
// run only after the offline change has been pushed to the server. ─
|
|
472
|
+
await model.synchronizeAll()
|
|
473
|
+
|
|
474
|
+
// ─ Phase 7: assert ─
|
|
475
|
+
assert.equal(clientApp.isConnected, true, 'client should be online again')
|
|
476
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'y1'))
|
|
477
|
+
assert.ok(rows.length > 0, 'offline change should reach the server after reconnect')
|
|
478
|
+
assert.equal(rows[0].label, 'Offline change')
|
|
479
|
+
|
|
480
|
+
socket.disconnect()
|
|
481
|
+
await new Promise(resolve => serverApp2.io.close(resolve))
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test('updateWithMeta pub/sub handler tolerates undefined value (concurrent delete race)', async () => {
|
|
485
|
+
// updateWithMeta does `const [value] = UPDATE ... RETURNING`. If the model row
|
|
486
|
+
// was deleted by a concurrent operation between the sync's findMany snapshot and
|
|
487
|
+
// the actual UPDATE, RETURNING yields 0 rows → value = undefined. The server
|
|
488
|
+
// broadcasts [undefined, meta]. The handler crashes on `db.values.put(undefined)`
|
|
489
|
+
// (TypeError: Cannot read properties of undefined) before db.metadata.put(meta)
|
|
490
|
+
// can run, leaving metadata inconsistent.
|
|
491
|
+
const modelName = `model${++dbCounter}`
|
|
492
|
+
|
|
493
|
+
const serverApp = expressX({})
|
|
494
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
495
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
496
|
+
const port = serverApp.httpServer.address().port
|
|
497
|
+
|
|
498
|
+
function connectClient() {
|
|
499
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
500
|
+
const app = createClient(socket, { debug: false })
|
|
501
|
+
offlinePlugin(app)
|
|
502
|
+
socket.connect()
|
|
503
|
+
return new Promise((resolve, reject) => {
|
|
504
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
505
|
+
socket.on('connect_error', reject)
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
510
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
511
|
+
|
|
512
|
+
await modelB.db.values.add({ uid: 'r1', label: 'old' })
|
|
513
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
514
|
+
|
|
515
|
+
// Simulate the server broadcasting updateWithMeta with undefined value
|
|
516
|
+
serverApp.io.to('all').emit('service-event', {
|
|
517
|
+
name: modelName,
|
|
518
|
+
action: 'updateWithMeta',
|
|
519
|
+
result: [undefined, { uid: 'r1', created_at: T0, updated_at: T1, deleted_at: null }],
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
await new Promise(r => setTimeout(r, 200))
|
|
523
|
+
|
|
524
|
+
// With bug: TypeError on undefined crashes before db.metadata.put(meta) runs
|
|
525
|
+
// → updated_at stays T0 (unchanged).
|
|
526
|
+
// With fix: handler guards value, skips the put, still updates metadata
|
|
527
|
+
// → updated_at becomes T1.
|
|
528
|
+
const meta = await modelB.db.metadata.get('r1')
|
|
529
|
+
assert.ok(meta?.updated_at, 'metadata updated_at must be set even when value is undefined')
|
|
530
|
+
|
|
531
|
+
socketB.disconnect()
|
|
532
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('deleteWithMeta pub/sub handler uses delete not put to avoid orphan metadata', async () => {
|
|
536
|
+
// synchronize() step 2 deletes both idbValues and idbMetadata for deleteClient uids.
|
|
537
|
+
// The deleteWithMeta pub/sub event may arrive on the SAME tab either before or after
|
|
538
|
+
// that cleanup. The handler uses db.metadata.put(meta) (upsert with deleted_at),
|
|
539
|
+
// so if it runs AFTER step 2 it RE-CREATES the metadata row as an orphan that can
|
|
540
|
+
// never be cleaned up. Fix: delete the metadata instead of putting it.
|
|
541
|
+
const modelName = `model${++dbCounter}`
|
|
542
|
+
|
|
543
|
+
const serverApp = expressX({})
|
|
544
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
545
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
546
|
+
const port = serverApp.httpServer.address().port
|
|
547
|
+
|
|
548
|
+
function connectClient() {
|
|
549
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
550
|
+
const app = createClient(socket, { debug: false })
|
|
551
|
+
offlinePlugin(app)
|
|
552
|
+
socket.connect()
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
555
|
+
socket.on('connect_error', reject)
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
560
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
561
|
+
|
|
562
|
+
// r1 exists in client B's Dexie
|
|
563
|
+
await modelB.db.values.add({ uid: 'r1', label: 'test' })
|
|
564
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
565
|
+
|
|
566
|
+
// Simulate step 2 running first (synchronize already cleaned up r1)
|
|
567
|
+
await modelB.db.values.delete('r1')
|
|
568
|
+
await modelB.db.metadata.delete('r1')
|
|
569
|
+
|
|
570
|
+
// Pub/sub arrives AFTER step 2 — handler must not re-create metadata as an orphan
|
|
571
|
+
serverApp.io.to('all').emit('service-event', {
|
|
572
|
+
name: modelName,
|
|
573
|
+
action: 'deleteWithMeta',
|
|
574
|
+
result: [{ uid: 'r1', label: 'test' }, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
await new Promise(r => setTimeout(r, 200))
|
|
578
|
+
|
|
579
|
+
// With bug: put(meta) re-creates metadata with deleted_at → orphan persists
|
|
580
|
+
// With fix: delete(uid) is a no-op → no orphan
|
|
581
|
+
const orphan = await modelB.db.metadata.get('r1')
|
|
582
|
+
assert.ok(!orphan, 'pub/sub handler must not leave an orphan metadata row after step-2 cleanup')
|
|
583
|
+
|
|
584
|
+
socketB.disconnect()
|
|
585
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test('deleteWithMeta pub/sub handler tolerates undefined value (double-delete race)', async () => {
|
|
589
|
+
// deleteWithMeta does `const [value] = DELETE ... RETURNING`. When the record
|
|
590
|
+
// is already gone (double-delete race now possible with pub/sub enabled) the
|
|
591
|
+
// RETURNING clause yields 0 rows → value = undefined. The server then broadcasts
|
|
592
|
+
// [undefined, meta]. The handler crashes on `undefined.uid` (TypeError) before
|
|
593
|
+
// db.metadata.put(meta) runs, leaving other tabs' caches inconsistent.
|
|
594
|
+
const modelName = `model${++dbCounter}`
|
|
595
|
+
|
|
596
|
+
const serverApp = expressX({})
|
|
597
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
598
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
599
|
+
const port = serverApp.httpServer.address().port
|
|
600
|
+
|
|
601
|
+
function connectClient() {
|
|
602
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
603
|
+
const app = createClient(socket, { debug: false })
|
|
604
|
+
offlinePlugin(app)
|
|
605
|
+
socket.connect()
|
|
606
|
+
return new Promise((resolve, reject) => {
|
|
607
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
608
|
+
socket.on('connect_error', reject)
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
613
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
614
|
+
|
|
615
|
+
// Pre-populate so we can verify the metadata gets the server's deleted_at
|
|
616
|
+
await modelB.db.values.add({ uid: 'r1', label: 'existing' })
|
|
617
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
618
|
+
|
|
619
|
+
// Simulate the server broadcasting deleteWithMeta with undefined value
|
|
620
|
+
// (what happens when the DELETE RETURNING yields 0 rows)
|
|
621
|
+
serverApp.io.to('all').emit('service-event', {
|
|
622
|
+
name: modelName,
|
|
623
|
+
action: 'deleteWithMeta',
|
|
624
|
+
result: [undefined, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
await new Promise(r => setTimeout(r, 200))
|
|
628
|
+
|
|
629
|
+
// With original bug: TypeError on undefined.uid aborts before db.metadata.*
|
|
630
|
+
// runs at all → r1 metadata stays unchanged.
|
|
631
|
+
// With delete fix: handler guards value, then deletes the metadata row
|
|
632
|
+
// → r1 metadata is gone (no orphan, clean state).
|
|
633
|
+
const meta = await modelB.db.metadata.get('r1')
|
|
634
|
+
assert.ok(!meta, 'metadata must be removed even when value is undefined')
|
|
635
|
+
|
|
636
|
+
socketB.disconnect()
|
|
637
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
test('update() rollback only restores updated_at, not deleted_at set by a concurrent remove()', async () => {
|
|
641
|
+
// The optimistic write in update() only touches updated_at. The rollback on
|
|
642
|
+
// server rejection spreads the full previousMetadata snapshot — including
|
|
643
|
+
// deleted_at: null — overwriting any deleted_at that remove() set while the
|
|
644
|
+
// socket round-trip was in flight. Only updated_at should be restored.
|
|
645
|
+
const modelName = `model${++dbCounter}`
|
|
646
|
+
|
|
647
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
648
|
+
// updateWithMeta delays then rejects so remove() can run in the gap
|
|
649
|
+
serverApp.createService(modelName, {
|
|
650
|
+
updateWithMeta: async () => { await new Promise(r => setTimeout(r, 100)); throw new Error('rejected') },
|
|
651
|
+
deleteWithMeta: async () => {},
|
|
652
|
+
createWithMeta: async () => {},
|
|
653
|
+
findMany: async () => [],
|
|
654
|
+
})
|
|
655
|
+
serverApp.createService('sync', {
|
|
656
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
657
|
+
})
|
|
658
|
+
}, { useOfflinePlugin: true })
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
662
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
663
|
+
// Explicitly store deleted_at: null (simulates what remove() rollback leaves
|
|
664
|
+
// behind). Dexie stores the key so previousMetadata includes it, and the
|
|
665
|
+
// rollback then spreads it back — wiping any concurrent remove()'s value.
|
|
666
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, deleted_at: null })
|
|
667
|
+
|
|
668
|
+
// Fire update() — server will delay 100 ms then reject
|
|
669
|
+
model.update('r1', { label: 'updated' })
|
|
670
|
+
|
|
671
|
+
// While updateWithMeta is in flight, remove() sets deleted_at
|
|
672
|
+
await model.remove('r1')
|
|
673
|
+
|
|
674
|
+
// Wait for the rejection and rollback to complete
|
|
675
|
+
await new Promise(r => setTimeout(r, 300))
|
|
676
|
+
|
|
677
|
+
// deleted_at must survive the update() rollback
|
|
678
|
+
const meta = await model.db.metadata.get('r1')
|
|
679
|
+
assert.ok(meta?.deleted_at, 'deleted_at set by remove() must not be cleared by update() rollback')
|
|
680
|
+
} finally {
|
|
681
|
+
await cleanup()
|
|
682
|
+
}
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
test('addClient uses put (upsert) so a concurrent create does not cause ConstraintError', async () => {
|
|
686
|
+
// create() adds a uid to Dexie between synchronize()'s idbValues.filter snapshot
|
|
687
|
+
// and the addClient step. Because the uid missed the snapshot it is absent from
|
|
688
|
+
// clientMetadataDict, so the server puts it in addClient. idbValues.add() then
|
|
689
|
+
// throws ConstraintError which aborts the entire transaction, silently dropping all
|
|
690
|
+
// other addClient records in the same batch.
|
|
691
|
+
// Fix: use idbValues.put() (upsert) so a pre-existing uid is handled gracefully.
|
|
692
|
+
const modelName = `model${++dbCounter}`
|
|
693
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
694
|
+
|
|
695
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-r1' })
|
|
696
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
697
|
+
await db.insert(modelTable).values({ uid: 'r2', label: 'server-r2' })
|
|
698
|
+
await db.insert(metaTable).values({ uid: 'r2', created_at: T0 })
|
|
699
|
+
|
|
700
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
701
|
+
// Custom sync that always returns BOTH r1 and r2 in addClient,
|
|
702
|
+
// regardless of what the client already has — simulating the race where
|
|
703
|
+
// r1 was added to Dexie after the clientMetadataDict snapshot.
|
|
704
|
+
serverApp.createService('sync', {
|
|
705
|
+
go: async () => ({
|
|
706
|
+
addClient: [
|
|
707
|
+
[{ uid: 'r1', label: 'server-r1' }, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: null }],
|
|
708
|
+
[{ uid: 'r2', label: 'server-r2' }, { uid: 'r2', created_at: T0, updated_at: null, deleted_at: null }],
|
|
709
|
+
],
|
|
710
|
+
updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [],
|
|
711
|
+
}),
|
|
712
|
+
})
|
|
713
|
+
}, { useOfflinePlugin: true })
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
717
|
+
|
|
718
|
+
// r1 is already in Dexie (simulates what create() does concurrently)
|
|
719
|
+
await model.db.values.add({ uid: 'r1', label: 'client-r1' })
|
|
720
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
721
|
+
|
|
722
|
+
await model.addSynchroWhere({})
|
|
723
|
+
await model.synchronizeAll()
|
|
724
|
+
|
|
725
|
+
// r2 must still be in Dexie even though r1 caused a uid conflict —
|
|
726
|
+
// the put() upsert must not abort the whole transaction
|
|
727
|
+
const r2 = await model.db.values.get('r2')
|
|
728
|
+
assert.ok(r2, 'r2 must be added despite r1 already existing in Dexie')
|
|
729
|
+
assert.equal(r2.label, 'server-r2')
|
|
730
|
+
} finally {
|
|
731
|
+
await cleanup()
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test('missing server metadata does not cause infinite updateClient loop', async () => {
|
|
736
|
+
// When a record exists in the model table but has no metadata row,
|
|
737
|
+
// computeSyncResult falls back to { uid, created_at: new Date() }.
|
|
738
|
+
// new Date() is always "now", so databaseUpdatedAt > clientUpdatedAt forever →
|
|
739
|
+
// updateClient fires on every sync even though nothing changed.
|
|
740
|
+
// Fix: use null instead of new Date() so the client's version "wins" once
|
|
741
|
+
// (updateDatabase), the server gets metadata, and subsequent syncs see diff=0.
|
|
742
|
+
const modelName = `model${++dbCounter}`
|
|
743
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
744
|
+
|
|
745
|
+
// Server has s1 in model table but intentionally NO metadata row
|
|
746
|
+
await db.insert(modelTable).values({ uid: 's1', label: 'server-label' })
|
|
747
|
+
|
|
748
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
749
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
750
|
+
{ useOfflinePlugin: true },
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
755
|
+
// Client has s1 with its own timestamp
|
|
756
|
+
await model.db.values.add({ uid: 's1', label: 'client-label' })
|
|
757
|
+
await model.db.metadata.add({ uid: 's1', created_at: T0 })
|
|
758
|
+
await model.addSynchroWhere({})
|
|
759
|
+
|
|
760
|
+
// First sync
|
|
761
|
+
await model.synchronizeAll()
|
|
762
|
+
|
|
763
|
+
// Second sync — with the bug, updateClient fires again (infinite loop);
|
|
764
|
+
// with the fix the second sync is a no-op (client pushed its data, diff=0)
|
|
765
|
+
let secondSyncUpdateClientCount = 0
|
|
766
|
+
const origUpdateWithMeta = db // just a marker — we measure via server state
|
|
767
|
+
|
|
768
|
+
await model.synchronizeAll()
|
|
769
|
+
|
|
770
|
+
// After two syncs the server metadata must now exist (created by updateWithMeta
|
|
771
|
+
// on the first sync) so a third sync should be a true no-op
|
|
772
|
+
const metaRows = await db.select().from(metaTable)
|
|
773
|
+
const s1Meta = metaRows.find(r => r.uid === 's1')
|
|
774
|
+
assert.ok(s1Meta, 'server metadata must be created after the first sync (updateDatabase + upsert)')
|
|
775
|
+
} finally {
|
|
776
|
+
await cleanup()
|
|
777
|
+
}
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
test('app-event for unregistered type is silently ignored, not TypeError', async () => {
|
|
781
|
+
// When an app-event arrives for a type with no registered handler,
|
|
782
|
+
// type2appHandler[type] is undefined. The guard
|
|
783
|
+
// if (!type2appHandler[type]) type2appHandler[type] = {}
|
|
784
|
+
// sets it to an empty object {}. {} is truthy, so the next line
|
|
785
|
+
// if (handler) handler(value)
|
|
786
|
+
// tries to call {} as a function → TypeError: {} is not a function.
|
|
787
|
+
const received = []
|
|
788
|
+
|
|
789
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
790
|
+
serverApp.addConnectListener(socket => {
|
|
791
|
+
// Delay so the test code below can register handlers before the events fire
|
|
792
|
+
setTimeout(() => {
|
|
793
|
+
// First: an event for a type WITH no registered handler (triggers the bug)
|
|
794
|
+
serverApp.sendAppEvent(socket.id, 'unregistered-type', 'ignored')
|
|
795
|
+
// Second: an event for a type WITH a registered handler (must still work)
|
|
796
|
+
serverApp.sendAppEvent(socket.id, 'registered-type', 'hello')
|
|
797
|
+
}, 100)
|
|
798
|
+
})
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
try {
|
|
802
|
+
clientApp.on('registered-type', val => received.push(val))
|
|
803
|
+
await new Promise(r => setTimeout(r, 400))
|
|
804
|
+
assert.deepEqual(received, ['hello'],
|
|
805
|
+
'registered handler must be called even after an unregistered-type event')
|
|
806
|
+
} finally {
|
|
807
|
+
await cleanup()
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
test('removeDisconnectListener is callable and removes the listener', async () => {
|
|
812
|
+
// removeDisonnectListener (misspelled — missing 'c') was exported on the app object
|
|
813
|
+
// instead of removeDisconnectListener. app.removeDisconnectListener is therefore
|
|
814
|
+
// undefined, so any call to it throws TypeError: not a function.
|
|
815
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
816
|
+
serverApp.createService('greet', { hello: async (name) => `Hello, ${name}!` })
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
let callCount = 0
|
|
821
|
+
const listener = () => { callCount++ }
|
|
822
|
+
|
|
823
|
+
clientApp.addDisconnectListener(listener)
|
|
824
|
+
|
|
825
|
+
// Must not throw — the correctly-spelled method must exist
|
|
826
|
+
assert.ok(
|
|
827
|
+
typeof clientApp.removeDisconnectListener === 'function',
|
|
828
|
+
'removeDisconnectListener must be a function on the app object',
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
clientApp.removeDisconnectListener(listener)
|
|
832
|
+
|
|
833
|
+
// After removal the listener must no longer be registered
|
|
834
|
+
// (we can inspect indirectly: add a fresh listener, but the removed one
|
|
835
|
+
// should not fire — verified by callCount staying at 0)
|
|
836
|
+
assert.equal(callCount, 0, 'removed listener must not have been called')
|
|
837
|
+
} finally {
|
|
838
|
+
await cleanup()
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
test('addDatabase TypeError on missing fullValue does not abort remaining entries', async () => {
|
|
843
|
+
// delete fullValue.uid and delete fullValue.__deleted__ are OUTSIDE the inner
|
|
844
|
+
// try/catch in steps 4 and 5 of synchronize(). If idbValues.get(elt.uid)
|
|
845
|
+
// returns undefined — because elt.uid is itself undefined (the clientMetadataDict
|
|
846
|
+
// fallback {} has no uid property when metadata is missing) or because the record
|
|
847
|
+
// was deleted from Dexie concurrently — the `delete undefined.uid` throws a
|
|
848
|
+
// TypeError that escapes to the outer try/catch, aborting the entire sync and
|
|
849
|
+
// silently skipping every remaining addDatabase / updateDatabase entry.
|
|
850
|
+
const modelName = `model${++dbCounter}`
|
|
851
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
852
|
+
|
|
853
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
854
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
855
|
+
{ useOfflinePlugin: true },
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
860
|
+
|
|
861
|
+
// a1: value exists in Dexie but metadata is MISSING → synchronize builds
|
|
862
|
+
// clientMetadataDict['a1'] = {} (the "should not happen" fallback)
|
|
863
|
+
// → computeSyncResult puts {} into addDatabase (elt.uid = undefined)
|
|
864
|
+
// → delete undefined.uid throws → outer catch → sync aborts
|
|
865
|
+
await model.db.values.add({ uid: 'a1', label: 'no-meta' })
|
|
866
|
+
// a2: normal record with both value and metadata
|
|
867
|
+
await model.db.values.add({ uid: 'a2', label: 'normal' })
|
|
868
|
+
await model.db.metadata.add({ uid: 'a2', created_at: T0 })
|
|
869
|
+
|
|
870
|
+
await model.addSynchroWhere({})
|
|
871
|
+
await model.synchronizeAll()
|
|
872
|
+
|
|
873
|
+
// a2 must be pushed despite the corrupted a1 entry aborting processing for it
|
|
874
|
+
const rows = await db.select().from(modelTable)
|
|
875
|
+
assert.ok(rows.find(r => r.uid === 'a2'),
|
|
876
|
+
'a2 must be pushed to the server even though a1 has missing metadata')
|
|
877
|
+
} finally {
|
|
878
|
+
await cleanup()
|
|
879
|
+
}
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
test('wherePredicate range check excludes records whose field is null', async () => {
|
|
883
|
+
// The previous fix excluded undefined (NaN comparisons) but missed null.
|
|
884
|
+
// JS coerces null → 0 for numeric comparisons, so for { score: { lte: 10 } }:
|
|
885
|
+
// null > 10 → 0 > 10 → false → the lte guard doesn't fire
|
|
886
|
+
// → null-score record passes the filter (wrong; SQL NULL never matches ranges).
|
|
887
|
+
// That record enters clientMetadataDict, appears as addDatabase to the server,
|
|
888
|
+
// and if score is NOT NULL the insert fails, rolling back the Dexie record.
|
|
889
|
+
const modelName = `model${++dbCounter}`
|
|
890
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
891
|
+
|
|
892
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
893
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
894
|
+
{ useOfflinePlugin: true },
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
try {
|
|
898
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
899
|
+
|
|
900
|
+
// Add directly to Dexie to simulate records with various score values
|
|
901
|
+
await model.db.values.add({ uid: 'null-score', label: 'null', score: null })
|
|
902
|
+
await model.db.values.add({ uid: 'five-score', label: 'five', score: 5 })
|
|
903
|
+
|
|
904
|
+
// score=null should NOT match { lte: 10 } even though (null coerced to 0) <= 10
|
|
905
|
+
const results = await model.findWhere({ score: { lte: 10 } })
|
|
906
|
+
const uids = results.map(r => r.uid)
|
|
907
|
+
|
|
908
|
+
assert.ok(!uids.includes('null-score'), 'null score must NOT match { lte: 10 } (SQL NULL behaviour)')
|
|
909
|
+
assert.ok( uids.includes('five-score'), 'score=5 must match { lte: 10 }')
|
|
910
|
+
} finally {
|
|
911
|
+
await cleanup()
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
test('wherePredicate range check excludes records whose field is absent (undefined)', async () => {
|
|
916
|
+
// Comparing undefined with a number via >, <, >=, <= always returns false in JS
|
|
917
|
+
// (undefined coerces to NaN, and NaN comparisons are always false). So none of
|
|
918
|
+
// the range return-false guards fire and a record WITHOUT the filtered field
|
|
919
|
+
// incorrectly passes { score: { gte: 1 } }. In PostgreSQL, NULL values are
|
|
920
|
+
// correctly excluded by range operators, creating a client/server mismatch:
|
|
921
|
+
// the client includes the record in clientMetadataDict → addDatabase → if the
|
|
922
|
+
// column is NOT NULL the insert fails → rollback deletes the record from Dexie.
|
|
923
|
+
const modelName = `model${++dbCounter}`
|
|
924
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
925
|
+
|
|
926
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
927
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
928
|
+
{ useOfflinePlugin: true },
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
933
|
+
|
|
934
|
+
// r1 has no 'score' field; r2 has score=5
|
|
935
|
+
await model.db.values.add({ uid: 'r1', label: 'no-score' })
|
|
936
|
+
await model.db.values.add({ uid: 'r2', label: 'has-score', score: 5 })
|
|
937
|
+
|
|
938
|
+
const results = await model.findWhere({ score: { gte: 1 } })
|
|
939
|
+
const uids = results.map(r => r.uid)
|
|
940
|
+
|
|
941
|
+
assert.ok(!uids.includes('r1'), 'record without score field must NOT match { gte: 1 }')
|
|
942
|
+
assert.ok( uids.includes('r2'), 'record with score=5 must match { gte: 1 }')
|
|
943
|
+
} finally {
|
|
944
|
+
await cleanup()
|
|
945
|
+
}
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
test('addSynchroWhere with range-operator where does not log ConstraintError when already covered', async () => {
|
|
949
|
+
// isSubset uses !== (reference equality) for all values. For primitive where
|
|
950
|
+
// values (strings, numbers) this is fine. For object values like { gte: 1 },
|
|
951
|
+
// two structurally identical objects from different call sites have different
|
|
952
|
+
// references, so isSubset returns false even though the where is already in the
|
|
953
|
+
// list. addSynchroDBWhere then calls whereDb.add on an already-existing key →
|
|
954
|
+
// ConstraintError → caught and logged. modified=false for the wrong reason.
|
|
955
|
+
// In a real browser with persistent IndexedDB the error fires on every page load.
|
|
956
|
+
const modelName = `model${++dbCounter}`
|
|
957
|
+
const pglite = new PGlite()
|
|
958
|
+
await pglite.exec(`
|
|
959
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
960
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, score INTEGER NOT NULL);
|
|
961
|
+
`)
|
|
962
|
+
const db = drizzle(pglite)
|
|
963
|
+
const metaTable = pgTable('metadata', { uid: text('uid').primaryKey(), created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp() })
|
|
964
|
+
const modelTable = pgTable(modelName, { uid: text('uid').primaryKey(), label: text('label').notNull(), score: integer('score').notNull() })
|
|
965
|
+
|
|
966
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
967
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
968
|
+
{ useOfflinePlugin: true },
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
try {
|
|
972
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
973
|
+
|
|
974
|
+
// Intercept console.log to detect spurious ConstraintError logs
|
|
975
|
+
const errorLogs = []
|
|
976
|
+
const origLog = console.log
|
|
977
|
+
console.log = (...args) => {
|
|
978
|
+
if (typeof args[0] === 'string' && args[0].startsWith('err addSynchroDBWhere')) errorLogs.push(args)
|
|
979
|
+
origLog(...args)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
try {
|
|
983
|
+
await model.addSynchroWhere({ score: { gte: 1 } }) // first call: adds to list
|
|
984
|
+
await model.addSynchroWhere({ score: { gte: 1 } }) // second call: should be detected as already covered
|
|
985
|
+
assert.equal(errorLogs.length, 0, 'adding a where already in the list must not throw a ConstraintError')
|
|
986
|
+
} finally {
|
|
987
|
+
console.log = origLog
|
|
988
|
+
}
|
|
989
|
+
} finally {
|
|
990
|
+
await cleanup()
|
|
991
|
+
}
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
test('compound range { gte, lte } filters consistently on both client and server', async () => {
|
|
995
|
+
// wherePredicate applies `lte` first (else-if chain); whereToDrizzleFilters applies
|
|
996
|
+
// `gte` first (return on first match). For { gte:1, lte:10 } this means:
|
|
997
|
+
// client includes score=0 (only lte:10 checked → 0≤10 passes)
|
|
998
|
+
// server includes score=15 (only gte:1 checked → 15≥1 passes)
|
|
999
|
+
// score=0 → addDatabase → pushed to server (wrong, outside range)
|
|
1000
|
+
// score=15 → addClient → added to client (wrong, outside range)
|
|
1001
|
+
const modelName = `model${++dbCounter}`
|
|
1002
|
+
const pglite = new PGlite()
|
|
1003
|
+
await pglite.exec(`
|
|
1004
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
1005
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, score INTEGER NOT NULL);
|
|
1006
|
+
`)
|
|
1007
|
+
const db = drizzle(pglite)
|
|
1008
|
+
const metaTable = pgTable('metadata', {
|
|
1009
|
+
uid: text('uid').primaryKey(),
|
|
1010
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
1011
|
+
})
|
|
1012
|
+
const modelTable = pgTable(modelName, {
|
|
1013
|
+
uid: text('uid').primaryKey(),
|
|
1014
|
+
label: text('label').notNull(),
|
|
1015
|
+
score: integer('score').notNull(),
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
// Server has records below, inside, and above the range 1–10
|
|
1019
|
+
await db.insert(modelTable).values([
|
|
1020
|
+
{ uid: 'lo', label: 'low', score: 0 },
|
|
1021
|
+
{ uid: 'mid', label: 'middle', score: 5 },
|
|
1022
|
+
{ uid: 'hi', label: 'high', score: 15 },
|
|
1023
|
+
])
|
|
1024
|
+
await db.insert(metaTable).values([
|
|
1025
|
+
{ uid: 'lo', created_at: T0 },
|
|
1026
|
+
{ uid: 'mid', created_at: T0 },
|
|
1027
|
+
{ uid: 'hi', created_at: T0 },
|
|
1028
|
+
])
|
|
1029
|
+
|
|
1030
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1031
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1032
|
+
{ useOfflinePlugin: true },
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1037
|
+
await model.addSynchroWhere({ score: { gte: 1, lte: 10 } })
|
|
1038
|
+
await model.synchronizeAll()
|
|
1039
|
+
|
|
1040
|
+
const vals = await model.db.values.toArray()
|
|
1041
|
+
const uids = vals.map(v => v.uid)
|
|
1042
|
+
|
|
1043
|
+
assert.ok(!uids.includes('lo'), 'score=0 is below range and must NOT be in Dexie')
|
|
1044
|
+
assert.ok( uids.includes('mid'), 'score=5 is in range and must be in Dexie')
|
|
1045
|
+
assert.ok(!uids.includes('hi'), 'score=15 is above range and must NOT be in Dexie')
|
|
1046
|
+
} finally {
|
|
1047
|
+
await cleanup()
|
|
1048
|
+
}
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
test('whereToDrizzleFilters supports range operators used in addSynchroWhere', async () => {
|
|
1052
|
+
// wherePredicate (client) handles { score: { gte: 5 } } correctly after earlier fixes,
|
|
1053
|
+
// but whereToDrizzleFilters (server) only calls eq() for every value.
|
|
1054
|
+
// Passing an object like { gte: 5 } to eq() makes Drizzle try to bind a plain
|
|
1055
|
+
// object as a PostgreSQL parameter → query throws → sync.go catch swallows the error
|
|
1056
|
+
// and returns undefined → client gets a TypeError → sync silently fails.
|
|
1057
|
+
const modelName = `model${++dbCounter}`
|
|
1058
|
+
const pglite = new PGlite()
|
|
1059
|
+
await pglite.exec(`
|
|
1060
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
1061
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, score INTEGER NOT NULL);
|
|
1062
|
+
`)
|
|
1063
|
+
const db = drizzle(pglite)
|
|
1064
|
+
const metaTable = pgTable('metadata', {
|
|
1065
|
+
uid: text('uid').primaryKey(),
|
|
1066
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
1067
|
+
})
|
|
1068
|
+
const modelTable = pgTable(modelName, {
|
|
1069
|
+
uid: text('uid').primaryKey(),
|
|
1070
|
+
label: text('label').notNull(),
|
|
1071
|
+
score: integer('score').notNull(),
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
await db.insert(modelTable).values([
|
|
1075
|
+
{ uid: 'lo', label: 'low', score: 3 },
|
|
1076
|
+
{ uid: 'hi', label: 'high', score: 7 },
|
|
1077
|
+
])
|
|
1078
|
+
await db.insert(metaTable).values([
|
|
1079
|
+
{ uid: 'lo', created_at: T0 },
|
|
1080
|
+
{ uid: 'hi', created_at: T0 },
|
|
1081
|
+
])
|
|
1082
|
+
|
|
1083
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1084
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1085
|
+
{ useOfflinePlugin: true },
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1090
|
+
await model.addSynchroWhere({ score: { gte: 5 } })
|
|
1091
|
+
await model.synchronizeAll()
|
|
1092
|
+
|
|
1093
|
+
const vals = await model.db.values.toArray()
|
|
1094
|
+
const uids = vals.map(v => v.uid)
|
|
1095
|
+
assert.ok(!uids.includes('lo'), 'score=3 should NOT be pulled (< 5)')
|
|
1096
|
+
assert.ok( uids.includes('hi'), 'score=7 should be pulled (≥ 5)')
|
|
1097
|
+
} finally {
|
|
1098
|
+
await cleanup()
|
|
1099
|
+
}
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
test('update() rollback clears stale updated_at from metadata', async () => {
|
|
1103
|
+
// Optimistic update sets updated_at = now in metadata before the server responds.
|
|
1104
|
+
// On rejection the rollback does db.metadata.update(uid, previousMetadata), but
|
|
1105
|
+
// previousMetadata captured before the update has no updated_at key, so Dexie's
|
|
1106
|
+
// partial update leaves updated_at = now in place.
|
|
1107
|
+
// On the next sync: new Date(stale_now) - new Date(T0) > 0 → spurious updateDatabase.
|
|
1108
|
+
const modelName = `model${++dbCounter}`
|
|
1109
|
+
|
|
1110
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1111
|
+
serverApp.createService(modelName, {
|
|
1112
|
+
updateWithMeta: async () => { throw new Error('server rejected update') },
|
|
1113
|
+
createWithMeta: async () => {},
|
|
1114
|
+
deleteWithMeta: async () => {},
|
|
1115
|
+
findMany: async () => [],
|
|
1116
|
+
findUnique: async () => null,
|
|
1117
|
+
})
|
|
1118
|
+
serverApp.createService('sync', {
|
|
1119
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
1120
|
+
})
|
|
1121
|
+
}, { useOfflinePlugin: true })
|
|
1122
|
+
|
|
1123
|
+
try {
|
|
1124
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1125
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
1126
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 }) // no updated_at
|
|
1127
|
+
|
|
1128
|
+
// update() optimistically sets updated_at = now, then server rejects
|
|
1129
|
+
await model.update('r1', { label: 'new' })
|
|
1130
|
+
|
|
1131
|
+
// Poll until the rollback restores the value
|
|
1132
|
+
for (let i = 0; i < 50; i++) {
|
|
1133
|
+
await new Promise(r => setTimeout(r, 10))
|
|
1134
|
+
const v = await model.db.values.get('r1')
|
|
1135
|
+
if (v?.label === 'original') break
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
assert.equal((await model.db.values.get('r1'))?.label, 'original', 'value should be rolled back')
|
|
1139
|
+
|
|
1140
|
+
// updated_at must be null/undefined after rollback — a stale timestamp would
|
|
1141
|
+
// cause every subsequent sync to fire a spurious updateDatabase
|
|
1142
|
+
const meta = await model.db.metadata.get('r1')
|
|
1143
|
+
assert.ok(!meta.updated_at, 'updated_at should be null after rollback, not a stale timestamp')
|
|
1144
|
+
} finally {
|
|
1145
|
+
await cleanup()
|
|
1146
|
+
}
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
test('createWithMeta is idempotent: duplicate call does not erase the Dexie record', async () => {
|
|
1150
|
+
// Race condition: create() fires createWithMeta_A and returns immediately.
|
|
1151
|
+
// A concurrent synchronize() snapshots findMany() before _A lands on the server,
|
|
1152
|
+
// so the record appears in addDatabase. The sync fires createWithMeta_B.
|
|
1153
|
+
// The server processes _A first (success), then _B hits a PK conflict on the
|
|
1154
|
+
// model table → throws → addDatabase catch deletes the record from Dexie.
|
|
1155
|
+
// Fix: model INSERT should use ON CONFLICT DO UPDATE just like the metadata INSERT.
|
|
1156
|
+
const modelName = `model${++dbCounter}`
|
|
1157
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1158
|
+
|
|
1159
|
+
// Server already has r1 (createWithMeta_A already landed)
|
|
1160
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
|
|
1161
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
1162
|
+
|
|
1163
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1164
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1165
|
+
{ useOfflinePlugin: true },
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
try {
|
|
1169
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1170
|
+
// Client has r1 in Dexie from the optimistic create
|
|
1171
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
1172
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1173
|
+
|
|
1174
|
+
// Simulate createWithMeta_B: same call the addDatabase step would make
|
|
1175
|
+
let threw = false
|
|
1176
|
+
try {
|
|
1177
|
+
await clientApp.service(modelName).createWithMeta('r1', { label: 'original' }, T0)
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
threw = true
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
assert.ok(!threw, 'createWithMeta should not throw when uid already exists on server')
|
|
1183
|
+
|
|
1184
|
+
// The rollback must NOT have run — r1 must still be in Dexie
|
|
1185
|
+
const r1 = await model.db.values.get('r1')
|
|
1186
|
+
assert.ok(r1, 'client Dexie should retain r1 after idempotent createWithMeta')
|
|
1187
|
+
} finally {
|
|
1188
|
+
await cleanup()
|
|
1189
|
+
}
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
test('addClient succeeds even when orphaned metadata already exists in Dexie', async () => {
|
|
1193
|
+
// The deleteWithMeta pub/sub handler does db.metadata.put(meta) which leaves
|
|
1194
|
+
// an orphaned metadata row after the value is deleted. If the same record is
|
|
1195
|
+
// later re-created on the server, addClient tries idbMetadata.add() which
|
|
1196
|
+
// throws a ConstraintError (PK already taken by the orphan), aborting the
|
|
1197
|
+
// entire addClient transaction — the record never arrives in the client cache.
|
|
1198
|
+
const modelName = `model${++dbCounter}`
|
|
1199
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1200
|
+
|
|
1201
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
|
|
1202
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
1203
|
+
|
|
1204
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1205
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1206
|
+
{ useOfflinePlugin: true },
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
try {
|
|
1210
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1211
|
+
|
|
1212
|
+
// Simulate the orphaned metadata left by a deleteWithMeta pub/sub event:
|
|
1213
|
+
// the value row is gone but the metadata row remains with deleted_at set.
|
|
1214
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, deleted_at: T1 })
|
|
1215
|
+
|
|
1216
|
+
await model.addSynchroWhere({})
|
|
1217
|
+
await model.synchronizeAll()
|
|
1218
|
+
|
|
1219
|
+
const r1 = await model.db.values.get('r1')
|
|
1220
|
+
assert.ok(r1, 'r1 should be pulled into Dexie via addClient despite orphaned metadata')
|
|
1221
|
+
assert.equal(r1.label, 'from-server')
|
|
1222
|
+
} finally {
|
|
1223
|
+
await cleanup()
|
|
1224
|
+
}
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
test('wherePredicate handles boolean equality correctly', async () => {
|
|
1228
|
+
// typeof(true) === 'boolean', which is matched by neither the string/number branch
|
|
1229
|
+
// nor the null branch nor the object branch — so boolean where-values are silently
|
|
1230
|
+
// ignored and every record passes, sending wrong records into clientMetadataDict.
|
|
1231
|
+
const modelName = `model${++dbCounter}`
|
|
1232
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1233
|
+
|
|
1234
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1235
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1236
|
+
{ useOfflinePlugin: true },
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1241
|
+
|
|
1242
|
+
await model.db.values.add({ uid: 'r1', label: 'active', active: true })
|
|
1243
|
+
await model.db.values.add({ uid: 'r2', label: 'inactive', active: false })
|
|
1244
|
+
|
|
1245
|
+
const results = await model.findWhere({ active: true })
|
|
1246
|
+
const uids = results.map(r => r.uid)
|
|
1247
|
+
|
|
1248
|
+
assert.ok( uids.includes('r1'), 'active=true should match { active: true }')
|
|
1249
|
+
assert.ok(!uids.includes('r2'), 'active=false should NOT match { active: true }')
|
|
1250
|
+
} finally {
|
|
1251
|
+
await cleanup()
|
|
1252
|
+
}
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
test('wherePredicate handles null equality correctly', async () => {
|
|
1256
|
+
// After fixing the TypeError on null, the previous fix used `value !== null`
|
|
1257
|
+
// to guard the object branch — but that leaves null with NO branch at all,
|
|
1258
|
+
// so `where = { user_uid: null }` passes every record instead of only those
|
|
1259
|
+
// with user_uid === null. In synchronize() this causes records with a non-null
|
|
1260
|
+
// user_uid to appear as addDatabase, hit a PK conflict, and get deleted.
|
|
1261
|
+
const modelName = `model${++dbCounter}`
|
|
1262
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1263
|
+
|
|
1264
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1265
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1266
|
+
{ useOfflinePlugin: true },
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
try {
|
|
1270
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1271
|
+
|
|
1272
|
+
await model.db.values.add({ uid: 'r1', label: 'with-user', user_uid: 'u1' })
|
|
1273
|
+
await model.db.values.add({ uid: 'r2', label: 'no-user', user_uid: null })
|
|
1274
|
+
|
|
1275
|
+
const results = await model.findWhere({ user_uid: null })
|
|
1276
|
+
const uids = results.map(r => r.uid)
|
|
1277
|
+
|
|
1278
|
+
assert.ok(!uids.includes('r1'), 'record with user_uid=u1 should NOT match { user_uid: null }')
|
|
1279
|
+
assert.ok( uids.includes('r2'), 'record with user_uid=null should match { user_uid: null }')
|
|
1280
|
+
} finally {
|
|
1281
|
+
await cleanup()
|
|
1282
|
+
}
|
|
1283
|
+
})
|
|
1284
|
+
|
|
1285
|
+
test('wherePredicate handles falsy boundary value (lte: 0) correctly', async () => {
|
|
1286
|
+
// wherePredicate is used by synchronize() to build clientMetadataDict.
|
|
1287
|
+
// A falsy boundary (lte: 0) is checked with `if (value.lte)` which treats 0
|
|
1288
|
+
// as "not set", causing ALL records to pass — wrong records enter clientMetadataDict
|
|
1289
|
+
// and the sync pushes them to the server where they fail with PK conflicts,
|
|
1290
|
+
// then the rollback deletes them from Dexie (data loss).
|
|
1291
|
+
const modelName = `model${++dbCounter}`
|
|
1292
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1293
|
+
|
|
1294
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1295
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1296
|
+
{ useOfflinePlugin: true },
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1301
|
+
|
|
1302
|
+
// Dexie stores arbitrary fields — score is not indexed but is queryable
|
|
1303
|
+
await model.db.values.add({ uid: 'pos', label: 'positive', score: 5 })
|
|
1304
|
+
await model.db.values.add({ uid: 'neg', label: 'negative', score: -3 })
|
|
1305
|
+
await model.db.values.add({ uid: 'zero', label: 'zero', score: 0 })
|
|
1306
|
+
|
|
1307
|
+
const results = await model.findWhere({ score: { lte: 0 } })
|
|
1308
|
+
const uids = results.map(r => r.uid)
|
|
1309
|
+
|
|
1310
|
+
assert.ok(!uids.includes('pos'), 'score=5 should NOT match { lte: 0 }')
|
|
1311
|
+
assert.ok( uids.includes('neg'), 'score=-3 should match { lte: 0 }')
|
|
1312
|
+
assert.ok( uids.includes('zero'), 'score=0 should match { lte: 0 }')
|
|
1313
|
+
} finally {
|
|
1314
|
+
await cleanup()
|
|
1315
|
+
}
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
test('pub/sub createWithMeta event correctly updates a second client\'s Dexie', async () => {
|
|
1319
|
+
const modelName = `model${++dbCounter}`
|
|
1320
|
+
const { db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1321
|
+
|
|
1322
|
+
// Server with pub/sub: every client joins 'all' and createWithMeta broadcasts there
|
|
1323
|
+
const serverApp = expressX({})
|
|
1324
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
1325
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
1326
|
+
serverApp.service(modelName).publish(async () => ['all'])
|
|
1327
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
1328
|
+
const port = serverApp.httpServer.address().port
|
|
1329
|
+
|
|
1330
|
+
function connectClient() {
|
|
1331
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
1332
|
+
const app = createClient(socket, { debug: false })
|
|
1333
|
+
offlinePlugin(app)
|
|
1334
|
+
socket.connect()
|
|
1335
|
+
return new Promise((resolve, reject) => {
|
|
1336
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
1337
|
+
socket.on('connect_error', reject)
|
|
1338
|
+
})
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const { app: appA, socket: socketA } = await connectClient()
|
|
1342
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
1343
|
+
|
|
1344
|
+
const modelA = appA.createOfflineModel(modelName, ['label'])
|
|
1345
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
1346
|
+
|
|
1347
|
+
try {
|
|
1348
|
+
// Client A creates a record while connected — fires createWithMeta directly
|
|
1349
|
+
const record = await modelA.create({ label: 'from-A' })
|
|
1350
|
+
|
|
1351
|
+
// Poll until the pub/sub event reaches client B's Dexie
|
|
1352
|
+
let inB = null
|
|
1353
|
+
for (let i = 0; i < 50; i++) {
|
|
1354
|
+
await new Promise(r => setTimeout(r, 10))
|
|
1355
|
+
inB = await modelB.db.values.get(record.uid)
|
|
1356
|
+
if (inB) break
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
assert.ok(inB, 'Client B should receive the record via pub/sub')
|
|
1360
|
+
assert.equal(inB.label, 'from-A', 'Client B should have the correct label')
|
|
1361
|
+
} finally {
|
|
1362
|
+
socketA.disconnect()
|
|
1363
|
+
socketB.disconnect()
|
|
1364
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
1365
|
+
}
|
|
1366
|
+
})
|
|
1367
|
+
|
|
1368
|
+
})
|