@jcbuisson/express-x-plugins 5.0.7 → 5.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -1
- package/docs/electric-fsm.md +244 -0
- package/docs/sync-fsm.md +117 -0
- package/package.json +32 -2
- package/src/electric-offline-model.mjs +14 -2
- package/src/electric-server-plugin.mjs +21 -5
- package/src/electric-sync-relations.mjs +99 -0
- package/src/sync-algorithm.mjs +146 -0
- package/src/sync-client-plugin.mjs +1023 -0
- package/src/sync-server-plugin.mjs +541 -0
- package/test/electric-relations-postgres.test.mjs +113 -0
- package/test/electric-relations.test.mjs +220 -0
- package/test/electric-server-plugin.test.mjs +3 -2
- package/test/sync-algorithm.test.mjs +229 -0
- package/test/sync-offline.test.mjs +4643 -0
|
@@ -0,0 +1,4643 @@
|
|
|
1
|
+
// Must be imported before Dexie so it patches globalThis.indexedDB first
|
|
2
|
+
import 'fake-indexeddb/auto'
|
|
3
|
+
|
|
4
|
+
import { test, describe, after } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { io as ioc } from 'socket.io-client'
|
|
7
|
+
import { firstValueFrom } from 'rxjs'
|
|
8
|
+
import { effectScope } from 'vue'
|
|
9
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
10
|
+
import { drizzle } from 'drizzle-orm/pglite'
|
|
11
|
+
import { pgTable, text, timestamp, integer } from 'drizzle-orm/pg-core'
|
|
12
|
+
import { eq } from 'drizzle-orm'
|
|
13
|
+
|
|
14
|
+
import { expressX } from '@jcbuisson/express-x/server'
|
|
15
|
+
import { computeSyncResult } from '../src/sync-algorithm.mjs'
|
|
16
|
+
import { createClient } from '@jcbuisson/express-x/client'
|
|
17
|
+
import { offlinePlugin as rawOfflinePlugin } from '../src/sync-client-plugin.mjs'
|
|
18
|
+
const offlinePlugin = (app, options = {}) =>
|
|
19
|
+
rawOfflinePlugin(app, { cacheNamespace: 'express-x-tests', ...options })
|
|
20
|
+
const localOfflinePlugin = offlinePlugin
|
|
21
|
+
|
|
22
|
+
import { drizzleOfflinePlugin as rawDrizzleOfflinePlugin } from '../src/sync-server-plugin.mjs'
|
|
23
|
+
const allowAll = { authorize: async () => true, computeSyncResult }
|
|
24
|
+
const drizzleOfflinePlugin = (app, db, metadata, models, options = {}) =>
|
|
25
|
+
rawDrizzleOfflinePlugin(app, db, metadata, models, { ...allowAll, ...options })
|
|
26
|
+
const localDrizzleOfflinePlugin = drizzleOfflinePlugin
|
|
27
|
+
|
|
28
|
+
const T0 = new Date('2026-01-01T00:00:00Z')
|
|
29
|
+
const T1 = new Date('2026-01-02T00:00:00Z')
|
|
30
|
+
const T2 = new Date('2026-01-03T00:00:00Z')
|
|
31
|
+
|
|
32
|
+
let dbCounter = 0
|
|
33
|
+
|
|
34
|
+
// ─── In-memory DB helper ──────────────────────────────────────────────────────
|
|
35
|
+
// Each test gets a fresh PGlite instance with a unique model table so tests
|
|
36
|
+
// are fully isolated. Model names use underscores (PG-safe identifiers).
|
|
37
|
+
|
|
38
|
+
async function createTestDb(modelName) {
|
|
39
|
+
const pglite = new PGlite()
|
|
40
|
+
await pglite.exec(`
|
|
41
|
+
CREATE TABLE metadata (
|
|
42
|
+
uid TEXT PRIMARY KEY,
|
|
43
|
+
created_at TIMESTAMP,
|
|
44
|
+
updated_at TIMESTAMP,
|
|
45
|
+
deleted_at TIMESTAMP
|
|
46
|
+
);
|
|
47
|
+
CREATE TABLE "${modelName}" (
|
|
48
|
+
uid TEXT PRIMARY KEY,
|
|
49
|
+
label TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
`)
|
|
52
|
+
const db = drizzle(pglite)
|
|
53
|
+
const metaTable = pgTable('metadata', {
|
|
54
|
+
uid: text('uid').primaryKey(),
|
|
55
|
+
created_at: timestamp(),
|
|
56
|
+
updated_at: timestamp(),
|
|
57
|
+
deleted_at: timestamp(),
|
|
58
|
+
})
|
|
59
|
+
const modelTable = pgTable(modelName, {
|
|
60
|
+
uid: text('uid').primaryKey(),
|
|
61
|
+
label: text('label').notNull(),
|
|
62
|
+
})
|
|
63
|
+
return { pglite, db, metaTable, modelTable }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Test context helper ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function createTestContext(registerServices, { useOfflinePlugin = false, offlinePluginImpl = offlinePlugin } = {}) {
|
|
69
|
+
const serverApp = expressX({})
|
|
70
|
+
registerServices(serverApp)
|
|
71
|
+
|
|
72
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
73
|
+
const port = serverApp.httpServer.address().port
|
|
74
|
+
|
|
75
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
76
|
+
transports: ['websocket'],
|
|
77
|
+
autoConnect: false,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const clientApp = createClient(socket, { debug: false })
|
|
81
|
+
if (useOfflinePlugin) offlinePluginImpl(clientApp)
|
|
82
|
+
|
|
83
|
+
socket.connect()
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
socket.on('connect', resolve)
|
|
86
|
+
socket.on('connect_error', reject)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const cleanup = () => new Promise(resolve => {
|
|
90
|
+
socket.disconnect()
|
|
91
|
+
serverApp.io.close(resolve)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return { clientApp, socket, cleanup }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('Full offline-first client ↔ server protocol', () => {
|
|
100
|
+
|
|
101
|
+
test('service call is routed through client-request / client-response', async () => {
|
|
102
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
103
|
+
serverApp.createService('greet', {
|
|
104
|
+
hello: async (name) => `Hello, ${name}!`,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = await clientApp.service('greet').hello('World')
|
|
110
|
+
assert.equal(result, 'Hello, World!')
|
|
111
|
+
} finally {
|
|
112
|
+
await cleanup()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('prototype-like service and method names are routed safely', async () => {
|
|
117
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
118
|
+
serverApp.createService('constructor', {
|
|
119
|
+
toString: async () => 'safe',
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
assert.equal(await clientApp.service('constructor').call('toString'), 'safe')
|
|
125
|
+
} finally {
|
|
126
|
+
await cleanup()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('malformed client request receives a structured error', async () => {
|
|
131
|
+
const { socket, cleanup } = await createTestContext(() => {})
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await socket.timeout(1000).emitWithAck('client-request', null)
|
|
135
|
+
assert.equal(response.error?.code, 'invalid-request')
|
|
136
|
+
} finally {
|
|
137
|
+
await cleanup()
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('duplicate service registration is rejected', () => {
|
|
142
|
+
const serverApp = expressX({})
|
|
143
|
+
serverApp.createService('duplicate', { first: async () => true })
|
|
144
|
+
assert.throws(
|
|
145
|
+
() => serverApp.createService('duplicate', { second: async () => true }),
|
|
146
|
+
/already exists/,
|
|
147
|
+
)
|
|
148
|
+
serverApp.io.close()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('server error is propagated to the client as a rejection', async () => {
|
|
152
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
153
|
+
serverApp.createService('broken', {
|
|
154
|
+
explode: async () => { throw new Error('something went wrong') },
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => clientApp.service('broken').explode(),
|
|
161
|
+
err => {
|
|
162
|
+
assert.match(err.message, /something went wrong/)
|
|
163
|
+
return true
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
} finally {
|
|
167
|
+
await cleanup()
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('client-request without acknowledgment callback does not throw on server error', async () => {
|
|
172
|
+
const { socket, cleanup } = await createTestContext(serverApp => {
|
|
173
|
+
serverApp.createService('broken', {
|
|
174
|
+
explode: async () => { throw new Error('something went wrong') },
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
socket.emit('client-request', {
|
|
180
|
+
name: 'broken',
|
|
181
|
+
action: 'explode',
|
|
182
|
+
args: [],
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
186
|
+
assert.equal(socket.connected, true)
|
|
187
|
+
} finally {
|
|
188
|
+
await cleanup()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('sync.go through socket: server records pulled into real Dexie', async () => {
|
|
193
|
+
const modelName = `model${++dbCounter}`
|
|
194
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
195
|
+
|
|
196
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'Vacances' })
|
|
197
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
198
|
+
|
|
199
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
200
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
201
|
+
{ useOfflinePlugin: true },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
206
|
+
await model.addSynchroWhere({})
|
|
207
|
+
await model.synchronizeAll()
|
|
208
|
+
|
|
209
|
+
const r1 = await model.db.values.get('r1')
|
|
210
|
+
assert.ok(r1, 'Dexie should contain the record pulled from server via socket')
|
|
211
|
+
assert.equal(r1.label, 'Vacances')
|
|
212
|
+
} finally {
|
|
213
|
+
await cleanup()
|
|
214
|
+
pglite.close()
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('sync.go through socket: local Dexie record pushed to server', async () => {
|
|
219
|
+
const modelName = `model${++dbCounter}`
|
|
220
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
221
|
+
|
|
222
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
223
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
224
|
+
{ useOfflinePlugin: true },
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
229
|
+
await model.db.values.add({ uid: 'x1', label: 'Formation' })
|
|
230
|
+
await model.db.metadata.add({ uid: 'x1', created_at: T1 })
|
|
231
|
+
await model.addSynchroWhere({})
|
|
232
|
+
await model.synchronizeAll()
|
|
233
|
+
|
|
234
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'x1'))
|
|
235
|
+
assert.ok(rows.length > 0, 'server should have received the pushed record')
|
|
236
|
+
assert.equal(rows[0].label, 'Formation')
|
|
237
|
+
} finally {
|
|
238
|
+
await cleanup()
|
|
239
|
+
pglite.close()
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('record only on client, deleted → ignored on both sides', async () => {
|
|
244
|
+
const modelName = `model${++dbCounter}`
|
|
245
|
+
const { pglite, 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
|
+
await model.db.values.add({ uid: 'd1', label: 'Gone', __deleted__: true })
|
|
255
|
+
await model.db.metadata.add({ uid: 'd1', created_at: T0, deleted_at: T1 })
|
|
256
|
+
await model.addSynchroWhere({})
|
|
257
|
+
await model.synchronizeAll()
|
|
258
|
+
|
|
259
|
+
const rows = await db.select().from(modelTable)
|
|
260
|
+
assert.ok(!rows.find(r => r.uid === 'd1'), 'server should not have the deleted-only record')
|
|
261
|
+
const d1 = await model.db.values.get('d1')
|
|
262
|
+
assert.ok(!d1, 'Dexie should no longer hold the deleted record')
|
|
263
|
+
} finally {
|
|
264
|
+
await cleanup()
|
|
265
|
+
pglite.close()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('newer local tombstone upgrades older server tombstone on sync', async () => {
|
|
270
|
+
const modelName = `model${++dbCounter}`
|
|
271
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
272
|
+
|
|
273
|
+
await db.insert(metaTable).values({ uid: 'd1', created_at: T0, deleted_at: T1 })
|
|
274
|
+
|
|
275
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
276
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
277
|
+
{ useOfflinePlugin: true },
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
282
|
+
await model.db.metadata.add({ uid: 'd1', created_at: T0, deleted_at: T2, __dirty__: true })
|
|
283
|
+
await model.addSynchroWhere({})
|
|
284
|
+
await model.synchronizeAll()
|
|
285
|
+
|
|
286
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'd1')))[0]
|
|
287
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'server tombstone should be upgraded to newer client tombstone')
|
|
288
|
+
assert.ok(!await model.db.metadata.get('d1'), 'acknowledged local tombstone should be removed')
|
|
289
|
+
} finally {
|
|
290
|
+
await cleanup()
|
|
291
|
+
pglite.close()
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('create() rollback removes metadata when server rejects', async () => {
|
|
296
|
+
const modelName = `model${++dbCounter}`
|
|
297
|
+
|
|
298
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
299
|
+
serverApp.createService(modelName, {
|
|
300
|
+
createWithMeta: async () => { throw new Error('server rejected') },
|
|
301
|
+
findMany: async () => [],
|
|
302
|
+
updateWithMeta: async () => {},
|
|
303
|
+
deleteWithMeta: async () => {},
|
|
304
|
+
})
|
|
305
|
+
serverApp.createService('sync', {
|
|
306
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
307
|
+
})
|
|
308
|
+
}, { useOfflinePlugin: true })
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
312
|
+
|
|
313
|
+
// create() is optimistic: value + metadata are written to Dexie before the
|
|
314
|
+
// server responds, then rolled back if the server rejects.
|
|
315
|
+
const record = await model.create({ label: 'test' })
|
|
316
|
+
const uid = record.uid
|
|
317
|
+
|
|
318
|
+
assert.ok(await model.db.values.get(uid), 'value should exist optimistically')
|
|
319
|
+
assert.ok(await model.db.metadata.get(uid), 'metadata should exist optimistically')
|
|
320
|
+
|
|
321
|
+
// Poll until the rollback removes the value (server rejection processed)
|
|
322
|
+
for (let i = 0; i < 50; i++) {
|
|
323
|
+
await new Promise(r => setTimeout(r, 10))
|
|
324
|
+
if (!await model.db.values.get(uid)) break
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
assert.ok(!await model.db.values.get(uid), 'value should be removed after rollback')
|
|
328
|
+
// Metadata must also be cleaned up — currently it is not
|
|
329
|
+
assert.ok(!await model.db.metadata.get(uid), 'metadata should also be removed after rollback')
|
|
330
|
+
} finally {
|
|
331
|
+
await cleanup()
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('deleted record is fully removed from Dexie metadata after sync', async () => {
|
|
336
|
+
const modelName = `model${++dbCounter}`
|
|
337
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
338
|
+
|
|
339
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
340
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
341
|
+
{ useOfflinePlugin: true },
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
346
|
+
// Record created and deleted locally before ever reaching the server
|
|
347
|
+
await model.db.values.add({ uid: 'd1', label: 'Gone', __deleted__: true })
|
|
348
|
+
await model.db.metadata.add({ uid: 'd1', created_at: T0, deleted_at: T1 })
|
|
349
|
+
await model.addSynchroWhere({})
|
|
350
|
+
await model.synchronizeAll()
|
|
351
|
+
|
|
352
|
+
const d1 = await model.db.values.get('d1')
|
|
353
|
+
assert.ok(!d1, 'Dexie values should not hold deleted record')
|
|
354
|
+
|
|
355
|
+
// Metadata must also be removed — orphaned rows waste space and cause
|
|
356
|
+
// a ConstraintError if a record with the same uid is ever re-created
|
|
357
|
+
const d1Meta = await model.db.metadata.get('d1')
|
|
358
|
+
assert.ok(!d1Meta, 'Dexie metadata should also be removed for deleted record')
|
|
359
|
+
} finally {
|
|
360
|
+
await cleanup()
|
|
361
|
+
pglite.close()
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('record in both, DB newer → client cache is updated with server value', async () => {
|
|
366
|
+
const modelName = `model${++dbCounter}`
|
|
367
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
368
|
+
|
|
369
|
+
// Server has s1 at T2 (newer than client)
|
|
370
|
+
await db.insert(modelTable).values({ uid: 's1', label: 'server-v2' })
|
|
371
|
+
await db.insert(metaTable).values({ uid: 's1', created_at: T0, updated_at: T2 })
|
|
372
|
+
|
|
373
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
374
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
375
|
+
{ useOfflinePlugin: true },
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
380
|
+
// Client has s1 at T1 (stale)
|
|
381
|
+
await model.db.values.add({ uid: 's1', label: 'client-v1' })
|
|
382
|
+
await model.db.metadata.add({ uid: 's1', created_at: T0, updated_at: T1 })
|
|
383
|
+
await model.addSynchroWhere({})
|
|
384
|
+
await model.synchronizeAll()
|
|
385
|
+
|
|
386
|
+
const s1 = await model.db.values.get('s1')
|
|
387
|
+
assert.equal(s1.label, 'server-v2', 'client Dexie should be updated with server\'s newer value')
|
|
388
|
+
|
|
389
|
+
// A second sync must be a no-op — proves the client timestamp was
|
|
390
|
+
// correctly updated to the server's, avoiding an infinite re-sync loop.
|
|
391
|
+
await model.synchronizeAll()
|
|
392
|
+
const s1Again = await model.db.values.get('s1')
|
|
393
|
+
assert.equal(s1Again.label, 'server-v2', 'second sync should not overwrite client value')
|
|
394
|
+
} finally {
|
|
395
|
+
await cleanup()
|
|
396
|
+
pglite.close()
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('record in both, DB created_at newer → client metadata is fully replaced', async () => {
|
|
401
|
+
const modelName = `model${++dbCounter}`
|
|
402
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
403
|
+
|
|
404
|
+
// Server is newer by created_at only; updated_at is null.
|
|
405
|
+
await db.insert(modelTable).values({ uid: 's1', label: 'server-created-later' })
|
|
406
|
+
await db.insert(metaTable).values({ uid: 's1', created_at: T2 })
|
|
407
|
+
|
|
408
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
409
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
410
|
+
{ useOfflinePlugin: true },
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
415
|
+
await model.db.values.add({ uid: 's1', label: 'client-created-earlier' })
|
|
416
|
+
await model.db.metadata.add({ uid: 's1', created_at: T1 })
|
|
417
|
+
await model.addSynchroWhere({})
|
|
418
|
+
|
|
419
|
+
await model.synchronizeAll()
|
|
420
|
+
|
|
421
|
+
const s1 = await model.db.values.get('s1')
|
|
422
|
+
const meta = await model.db.metadata.get('s1')
|
|
423
|
+
assert.equal(s1.label, 'server-created-later')
|
|
424
|
+
assert.equal(new Date(meta.created_at).getTime(), T2.getTime())
|
|
425
|
+
|
|
426
|
+
await model.synchronizeAll()
|
|
427
|
+
const s1Again = await model.db.values.get('s1')
|
|
428
|
+
assert.equal(s1Again.label, 'server-created-later')
|
|
429
|
+
} finally {
|
|
430
|
+
await cleanup()
|
|
431
|
+
pglite.close()
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('one failed updateWithMeta does not abort remaining updateDatabase entries', async () => {
|
|
436
|
+
const modelName = `model${++dbCounter}`
|
|
437
|
+
const serverUpdated = {}
|
|
438
|
+
|
|
439
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
440
|
+
serverApp.createService('sync', {
|
|
441
|
+
// Tell the client both u1 and u2 need to be pushed (client is newer for both)
|
|
442
|
+
go: async (mn, where, clientMetadataDict) => ({
|
|
443
|
+
addClient: [], updateClient: [], deleteClient: [], addDatabase: [],
|
|
444
|
+
updateDatabase: [ clientMetadataDict['u1'], clientMetadataDict['u2'] ],
|
|
445
|
+
}),
|
|
446
|
+
})
|
|
447
|
+
serverApp.createService(modelName, {
|
|
448
|
+
updateWithMeta: async (uid, data) => {
|
|
449
|
+
if (uid === 'u1') throw new Error('server rejected u1')
|
|
450
|
+
serverUpdated[uid] = data // u2 succeeds
|
|
451
|
+
},
|
|
452
|
+
createWithMeta: async () => {},
|
|
453
|
+
deleteWithMeta: async () => {},
|
|
454
|
+
findUnique: async () => null,
|
|
455
|
+
findMany: async () => [],
|
|
456
|
+
})
|
|
457
|
+
}, { useOfflinePlugin: true })
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
461
|
+
await model.db.values.add({ uid: 'u1', label: 'new-u1' })
|
|
462
|
+
await model.db.metadata.add({ uid: 'u1', created_at: T0, updated_at: T2 })
|
|
463
|
+
await model.db.values.add({ uid: 'u2', label: 'new-u2' })
|
|
464
|
+
await model.db.metadata.add({ uid: 'u2', created_at: T0, updated_at: T2 })
|
|
465
|
+
await model.addSynchroWhere({})
|
|
466
|
+
await model.synchronizeAll()
|
|
467
|
+
|
|
468
|
+
// u2's push must succeed even though u1's updateWithMeta threw
|
|
469
|
+
assert.ok(serverUpdated['u2'], 'u2 should be pushed to server despite u1 failure')
|
|
470
|
+
assert.equal(serverUpdated['u2'].label, 'new-u2')
|
|
471
|
+
} finally {
|
|
472
|
+
await cleanup()
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('record in both, client newer → server is updated via socket', async () => {
|
|
477
|
+
const modelName = `model${++dbCounter}`
|
|
478
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
479
|
+
|
|
480
|
+
await db.insert(modelTable).values({ uid: 'u1', label: 'old' })
|
|
481
|
+
await db.insert(metaTable).values({ uid: 'u1', created_at: T0, updated_at: T1 })
|
|
482
|
+
|
|
483
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
484
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
485
|
+
{ useOfflinePlugin: true },
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
490
|
+
await model.db.values.add({ uid: 'u1', label: 'new' })
|
|
491
|
+
await model.db.metadata.add({ uid: 'u1', created_at: T0, updated_at: T2 })
|
|
492
|
+
await model.addSynchroWhere({})
|
|
493
|
+
await model.synchronizeAll()
|
|
494
|
+
|
|
495
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'u1'))
|
|
496
|
+
assert.equal(rows[0].label, 'new', 'server should have the updated label')
|
|
497
|
+
const clientValue = await model.db.values.get('u1')
|
|
498
|
+
assert.equal(clientValue.label, 'new', 'client Dexie should be unchanged')
|
|
499
|
+
} finally {
|
|
500
|
+
await cleanup()
|
|
501
|
+
pglite.close()
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test('server tombstone newer than stale client copy deletes client cache on sync', async () => {
|
|
506
|
+
const modelName = `model${++dbCounter}`
|
|
507
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
508
|
+
|
|
509
|
+
// r1 exists on server initially
|
|
510
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
|
|
511
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
512
|
+
|
|
513
|
+
// Server-side delete while client was offline: hard-delete from model table,
|
|
514
|
+
// but metadata row stays with deleted_at set (this is what deleteWithMeta does)
|
|
515
|
+
await db.delete(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
516
|
+
await db.update(metaTable).set({ deleted_at: T1 }).where(eq(metaTable.uid, 'r1'))
|
|
517
|
+
|
|
518
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
519
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
520
|
+
{ useOfflinePlugin: true },
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
525
|
+
// Client has r1 — it was offline when the server deleted it
|
|
526
|
+
await model.db.values.add({ uid: 'r1', label: 'keep me' })
|
|
527
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
528
|
+
await model.addSynchroWhere({})
|
|
529
|
+
await model.synchronizeAll()
|
|
530
|
+
|
|
531
|
+
// Server tombstone is newer than the stale client copy, so the client
|
|
532
|
+
// must delete its cache instead of resurrecting the server row.
|
|
533
|
+
const rows = await db.select().from(modelTable)
|
|
534
|
+
assert.ok(!rows.find(r => r.uid === 'r1'), 'server tombstone should not be recreated by stale client copy')
|
|
535
|
+
const r1 = await model.db.values.get('r1')
|
|
536
|
+
const meta = await model.db.metadata.get('r1')
|
|
537
|
+
assert.ok(!r1, 'client Dexie value should be deleted when server tombstone is newer')
|
|
538
|
+
assert.ok(!meta, 'client Dexie metadata should be deleted when server tombstone is newer')
|
|
539
|
+
} finally {
|
|
540
|
+
await cleanup()
|
|
541
|
+
pglite.close()
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('server tombstone deletes stale server row and client cache on sync', async () => {
|
|
546
|
+
const modelName = `model${++dbCounter}`
|
|
547
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
548
|
+
|
|
549
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'stale-server-row' })
|
|
550
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T1 })
|
|
551
|
+
|
|
552
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
553
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
554
|
+
{ useOfflinePlugin: true },
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
559
|
+
await model.db.values.add({ uid: 'r1', label: 'stale-client-row' })
|
|
560
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
561
|
+
await model.addSynchroWhere({})
|
|
562
|
+
|
|
563
|
+
await model.synchronizeAll()
|
|
564
|
+
|
|
565
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
566
|
+
assert.equal(rows.length, 0, 'stale server row must be deleted when metadata has a newer tombstone')
|
|
567
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
568
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T1.getTime())
|
|
569
|
+
assert.ok(!await model.db.values.get('r1'), 'client value should be deleted by server tombstone')
|
|
570
|
+
assert.ok(!await model.db.metadata.get('r1'), 'client metadata should be deleted by server tombstone')
|
|
571
|
+
} finally {
|
|
572
|
+
await cleanup()
|
|
573
|
+
pglite.close()
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test('server tombstone deletes stale server-only row without adding it to client', async () => {
|
|
578
|
+
const modelName = `model${++dbCounter}`
|
|
579
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
580
|
+
|
|
581
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'stale-server-row' })
|
|
582
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T1 })
|
|
583
|
+
|
|
584
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
585
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
586
|
+
{ useOfflinePlugin: true },
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
591
|
+
await model.addSynchroWhere({})
|
|
592
|
+
|
|
593
|
+
await model.synchronizeAll()
|
|
594
|
+
|
|
595
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
596
|
+
assert.equal(rows.length, 0, 'stale server-only row must be deleted when metadata is tombstoned')
|
|
597
|
+
assert.ok(!await model.db.values.get('r1'), 'tombstoned server-only row must not be added to client')
|
|
598
|
+
assert.ok(!await model.db.metadata.get('r1'), 'tombstoned server-only metadata must not be added to client')
|
|
599
|
+
} finally {
|
|
600
|
+
await cleanup()
|
|
601
|
+
pglite.close()
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test('server live metadata without row tombstones stale client cache on sync', async () => {
|
|
606
|
+
const modelName = `model${++dbCounter}`
|
|
607
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
608
|
+
|
|
609
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T2 })
|
|
610
|
+
|
|
611
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
612
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
613
|
+
{ useOfflinePlugin: true },
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
618
|
+
await model.db.values.add({ uid: 'r1', label: 'stale-client-row' })
|
|
619
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1 })
|
|
620
|
+
await model.addSynchroWhere({})
|
|
621
|
+
|
|
622
|
+
await model.synchronizeAll()
|
|
623
|
+
|
|
624
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
625
|
+
assert.equal(rows.length, 0, 'missing server row must stay absent')
|
|
626
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
627
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'orphaned live server metadata should become a tombstone')
|
|
628
|
+
assert.ok(!await model.db.values.get('r1'), 'client value should be deleted when newer server metadata has no row')
|
|
629
|
+
assert.ok(!await model.db.metadata.get('r1'), 'client metadata should be deleted when newer server metadata has no row')
|
|
630
|
+
} finally {
|
|
631
|
+
await cleanup()
|
|
632
|
+
pglite.close()
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
test('newer local tombstone tombstones orphaned live server metadata', async () => {
|
|
637
|
+
const modelName = `model${++dbCounter}`
|
|
638
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
639
|
+
|
|
640
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T1 })
|
|
641
|
+
|
|
642
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
643
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
644
|
+
{ useOfflinePlugin: true },
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
649
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, deleted_at: T2, __dirty__: true })
|
|
650
|
+
await model.addSynchroWhere({})
|
|
651
|
+
|
|
652
|
+
await model.synchronizeAll()
|
|
653
|
+
|
|
654
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
655
|
+
assert.equal(rows.length, 0, 'server row must remain absent')
|
|
656
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
657
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'newer local tombstone should tombstone orphaned server metadata')
|
|
658
|
+
assert.ok(!await model.db.values.get('r1'), 'local value should remain absent')
|
|
659
|
+
assert.ok(!await model.db.metadata.get('r1'), 'acknowledged local tombstone should be cleared')
|
|
660
|
+
} finally {
|
|
661
|
+
await cleanup()
|
|
662
|
+
pglite.close()
|
|
663
|
+
}
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
test('direct update newer than server tombstone re-creates server row', async () => {
|
|
667
|
+
const modelName = `model${++dbCounter}`
|
|
668
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
669
|
+
|
|
670
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
|
|
671
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
672
|
+
await db.delete(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
673
|
+
await db.update(metaTable).set({ deleted_at: T1 }).where(eq(metaTable.uid, 'r1'))
|
|
674
|
+
|
|
675
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
676
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
677
|
+
{ useOfflinePlugin: true },
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
682
|
+
await model.db.values.add({ uid: 'r1', label: 'stale-client' })
|
|
683
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
684
|
+
|
|
685
|
+
await model.update('r1', { label: 'client-new' })
|
|
686
|
+
|
|
687
|
+
let rows = []
|
|
688
|
+
for (let i = 0; i < 50; i++) {
|
|
689
|
+
rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
690
|
+
if (rows[0]?.label === 'client-new') break
|
|
691
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
assert.equal(rows[0]?.label, 'client-new', 'newer direct client update should recreate server row')
|
|
695
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
696
|
+
assert.ok(!serverMeta.deleted_at, 'server tombstone should be cleared after newer direct update')
|
|
697
|
+
assert.equal(new Date(serverMeta.updated_at).getTime() > T1.getTime(), true)
|
|
698
|
+
|
|
699
|
+
await model.addSynchroWhere({})
|
|
700
|
+
await model.synchronizeAll()
|
|
701
|
+
|
|
702
|
+
const clientValue = await model.db.values.get('r1')
|
|
703
|
+
const clientMeta = await model.db.metadata.get('r1')
|
|
704
|
+
assert.equal(clientValue.label, 'client-new', 'sync should not delete the recreated client row')
|
|
705
|
+
assert.ok(!clientMeta.deleted_at, 'client metadata should not keep the old tombstone')
|
|
706
|
+
} finally {
|
|
707
|
+
await cleanup()
|
|
708
|
+
pglite.close()
|
|
709
|
+
}
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
test('direct update with no server row or metadata creates server row', async () => {
|
|
713
|
+
const modelName = `model${++dbCounter}`
|
|
714
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
715
|
+
|
|
716
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
717
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
718
|
+
{ useOfflinePlugin: true },
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
723
|
+
await model.db.values.add({ uid: 'r1', label: 'old-local' })
|
|
724
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, __dirty__: false })
|
|
725
|
+
|
|
726
|
+
await model.update('r1', { label: 'client-new' })
|
|
727
|
+
|
|
728
|
+
let rows = []
|
|
729
|
+
let clientMeta = null
|
|
730
|
+
for (let i = 0; i < 50; i++) {
|
|
731
|
+
rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
732
|
+
clientMeta = await model.db.metadata.get('r1')
|
|
733
|
+
if (rows[0]?.label === 'client-new' && clientMeta?.__dirty__ === false) break
|
|
734
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
assert.equal(rows[0]?.label, 'client-new', 'server row must exist after updateWithMeta')
|
|
738
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
739
|
+
assert.ok(serverMeta.updated_at, 'server metadata should record the update timestamp')
|
|
740
|
+
assert.equal(serverMeta.deleted_at, null)
|
|
741
|
+
const clientValue = await model.db.values.get('r1')
|
|
742
|
+
assert.equal(clientValue.label, 'client-new')
|
|
743
|
+
assert.equal(clientMeta.__dirty__, false, 'sync should mark the created server row clean')
|
|
744
|
+
} finally {
|
|
745
|
+
await cleanup()
|
|
746
|
+
pglite.close()
|
|
747
|
+
}
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
test('direct update older than server tombstone deletes stale server row', async () => {
|
|
751
|
+
const modelName = `model${++dbCounter}`
|
|
752
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
753
|
+
|
|
754
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'stale-server-row' })
|
|
755
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T2 })
|
|
756
|
+
|
|
757
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
758
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
759
|
+
{ useOfflinePlugin: true },
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
const result = await clientApp.service(modelName).updateWithMeta('r1', { label: 'client-old' }, T1)
|
|
764
|
+
const [returnedValue, returnedMeta] = result
|
|
765
|
+
|
|
766
|
+
assert.equal(returnedValue == null, true, 'stale direct update should not return a live row')
|
|
767
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime())
|
|
768
|
+
|
|
769
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
770
|
+
assert.equal(rows.length, 0, 'stale server row must be deleted when tombstone wins')
|
|
771
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
772
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'server tombstone must not be cleared')
|
|
773
|
+
} finally {
|
|
774
|
+
await cleanup()
|
|
775
|
+
pglite.close()
|
|
776
|
+
}
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
test('direct update older than server update does not overwrite server row', async () => {
|
|
780
|
+
const modelName = `model${++dbCounter}`
|
|
781
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
782
|
+
|
|
783
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-new' })
|
|
784
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T2 })
|
|
785
|
+
|
|
786
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
787
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
788
|
+
{ useOfflinePlugin: true },
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
try {
|
|
792
|
+
const result = await clientApp.service(modelName).updateWithMeta('r1', { label: 'client-old' }, T1)
|
|
793
|
+
const [returnedValue, returnedMeta] = result
|
|
794
|
+
|
|
795
|
+
assert.equal(returnedValue.label, 'server-new', 'stale direct update should return the newer server row')
|
|
796
|
+
assert.equal(new Date(returnedMeta.updated_at).getTime(), T2.getTime())
|
|
797
|
+
|
|
798
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
799
|
+
assert.equal(rows[0]?.label, 'server-new', 'server row must survive stale direct update')
|
|
800
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
801
|
+
assert.equal(new Date(serverMeta.updated_at).getTime(), T2.getTime(), 'server metadata must not be downgraded')
|
|
802
|
+
} finally {
|
|
803
|
+
await cleanup()
|
|
804
|
+
pglite.close()
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
test('direct update equal to server update does not overwrite server row', async () => {
|
|
809
|
+
const modelName = `model${++dbCounter}`
|
|
810
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
811
|
+
|
|
812
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-same-time' })
|
|
813
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T1 })
|
|
814
|
+
|
|
815
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
816
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
817
|
+
{ useOfflinePlugin: true },
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
const result = await clientApp.service(modelName).updateWithMeta('r1', { label: 'client-same-time' }, T1)
|
|
822
|
+
const [returnedValue, returnedMeta] = result
|
|
823
|
+
|
|
824
|
+
assert.equal(returnedValue.label, 'server-same-time', 'equal direct update should return the existing server row')
|
|
825
|
+
assert.equal(new Date(returnedMeta.updated_at).getTime(), T1.getTime())
|
|
826
|
+
|
|
827
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
828
|
+
assert.equal(rows[0]?.label, 'server-same-time', 'equal direct update must not overwrite server row')
|
|
829
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
830
|
+
assert.equal(new Date(serverMeta.updated_at).getTime(), T1.getTime(), 'server metadata must remain unchanged')
|
|
831
|
+
} finally {
|
|
832
|
+
await cleanup()
|
|
833
|
+
pglite.close()
|
|
834
|
+
}
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test('direct update older than orphaned server metadata tombstones and removes local row', async () => {
|
|
838
|
+
const modelName = `model${++dbCounter}`
|
|
839
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
840
|
+
|
|
841
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T2 })
|
|
842
|
+
|
|
843
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
844
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
845
|
+
{ useOfflinePlugin: true },
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
const result = await clientApp.service(modelName).updateWithMeta('r1', { label: 'client-old' }, T1)
|
|
850
|
+
const [returnedValue, returnedMeta] = result
|
|
851
|
+
|
|
852
|
+
assert.equal(returnedValue == null, true, 'stale direct update should not return a row when server metadata is orphaned')
|
|
853
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime())
|
|
854
|
+
|
|
855
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
856
|
+
assert.equal(rows.length, 0, 'orphaned server row must remain absent')
|
|
857
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
858
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'orphaned live metadata should become a tombstone')
|
|
859
|
+
|
|
860
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
861
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
862
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1, __dirty__: true })
|
|
863
|
+
await model.addSynchroWhere({})
|
|
864
|
+
await model.synchronizeAll()
|
|
865
|
+
|
|
866
|
+
assert.ok(!await model.db.values.get('r1'), 'sync should remove stale local row after orphaned update tombstone')
|
|
867
|
+
assert.ok(!await model.db.metadata.get('r1'), 'sync should remove stale local metadata after orphaned update tombstone')
|
|
868
|
+
} finally {
|
|
869
|
+
await cleanup()
|
|
870
|
+
pglite.close()
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
test('direct delete older than server update does not delete server row', async () => {
|
|
875
|
+
const modelName = `model${++dbCounter}`
|
|
876
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
877
|
+
|
|
878
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-new' })
|
|
879
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T2 })
|
|
880
|
+
|
|
881
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
882
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
883
|
+
{ useOfflinePlugin: true },
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
const result = await clientApp.service(modelName).deleteWithMeta('r1', T1)
|
|
888
|
+
const [returnedValue, returnedMeta] = result
|
|
889
|
+
|
|
890
|
+
assert.equal(returnedValue.label, 'server-new', 'stale direct delete should return the newer server row')
|
|
891
|
+
assert.ok(!returnedMeta.deleted_at, 'stale direct delete should not write a tombstone')
|
|
892
|
+
|
|
893
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
894
|
+
assert.equal(rows[0]?.label, 'server-new', 'server row must survive stale direct delete')
|
|
895
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
896
|
+
assert.ok(!serverMeta.deleted_at, 'server metadata must not be tombstoned by stale direct delete')
|
|
897
|
+
assert.equal(new Date(serverMeta.updated_at).getTime(), T2.getTime())
|
|
898
|
+
} finally {
|
|
899
|
+
await cleanup()
|
|
900
|
+
pglite.close()
|
|
901
|
+
}
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
test('direct delete older than server tombstone does not downgrade tombstone', async () => {
|
|
905
|
+
const modelName = `model${++dbCounter}`
|
|
906
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
907
|
+
|
|
908
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T2 })
|
|
909
|
+
|
|
910
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
911
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
912
|
+
{ useOfflinePlugin: true },
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
const result = await clientApp.service(modelName).deleteWithMeta('r1', T1)
|
|
917
|
+
const [returnedValue, returnedMeta] = result
|
|
918
|
+
|
|
919
|
+
assert.equal(returnedValue == null, true, 'stale direct delete should not return a deleted server row')
|
|
920
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime(), 'stale direct delete should return newer tombstone')
|
|
921
|
+
|
|
922
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
923
|
+
assert.equal(rows.length, 0, 'server row must remain absent')
|
|
924
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
925
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'server tombstone must not be downgraded')
|
|
926
|
+
} finally {
|
|
927
|
+
await cleanup()
|
|
928
|
+
pglite.close()
|
|
929
|
+
}
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
test('direct delete older than server tombstone removes stale server row', async () => {
|
|
933
|
+
const modelName = `model${++dbCounter}`
|
|
934
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
935
|
+
|
|
936
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'stale-server-row' })
|
|
937
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T2 })
|
|
938
|
+
|
|
939
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
940
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
941
|
+
{ useOfflinePlugin: true },
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
const result = await clientApp.service(modelName).deleteWithMeta('r1', T1)
|
|
946
|
+
const [returnedValue, returnedMeta] = result
|
|
947
|
+
|
|
948
|
+
assert.equal(returnedValue == null, true, 'stale direct delete should not return a stale row under tombstone metadata')
|
|
949
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime(), 'stale direct delete should return newer tombstone')
|
|
950
|
+
|
|
951
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
952
|
+
assert.equal(rows.length, 0, 'server tombstone must delete stale model row')
|
|
953
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
954
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'server tombstone must not be downgraded')
|
|
955
|
+
} finally {
|
|
956
|
+
await cleanup()
|
|
957
|
+
pglite.close()
|
|
958
|
+
}
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
test('direct delete older than orphaned server metadata tombstones and removes local row', async () => {
|
|
962
|
+
const modelName = `model${++dbCounter}`
|
|
963
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
964
|
+
const futureUpdate = new Date(Date.now() + 60_000)
|
|
965
|
+
|
|
966
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: futureUpdate })
|
|
967
|
+
|
|
968
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
969
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
970
|
+
{ useOfflinePlugin: true },
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
const result = await clientApp.service(modelName).deleteWithMeta('r1', T1)
|
|
975
|
+
const [returnedValue, returnedMeta] = result
|
|
976
|
+
|
|
977
|
+
assert.equal(returnedValue == null, true, 'stale direct delete should not return a row when server metadata is orphaned')
|
|
978
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), futureUpdate.getTime())
|
|
979
|
+
|
|
980
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
981
|
+
assert.equal(rows.length, 0, 'orphaned server row must remain absent')
|
|
982
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
983
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), futureUpdate.getTime(), 'orphaned live metadata should become a tombstone')
|
|
984
|
+
|
|
985
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
986
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
987
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
988
|
+
await model.remove('r1')
|
|
989
|
+
|
|
990
|
+
for (let i = 0; i < 50; i++) {
|
|
991
|
+
if (!await model.db.values.get('r1')) break
|
|
992
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
assert.ok(!await model.db.values.get('r1'), 'delete acknowledgement tombstone should remove hidden local row')
|
|
996
|
+
assert.ok(!await model.db.metadata.get('r1'), 'delete acknowledgement tombstone should remove local metadata')
|
|
997
|
+
} finally {
|
|
998
|
+
await cleanup()
|
|
999
|
+
pglite.close()
|
|
1000
|
+
}
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
test('direct delete acknowledgement ignores value when server returns tombstone', async () => {
|
|
1004
|
+
const modelName = `model${++dbCounter}`
|
|
1005
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1006
|
+
let resolveDeleteStarted
|
|
1007
|
+
let deleteRelease
|
|
1008
|
+
const deleteStarted = new Promise(resolve => { resolveDeleteStarted = resolve })
|
|
1009
|
+
const deleteCanReturn = new Promise(resolve => { deleteRelease = resolve })
|
|
1010
|
+
|
|
1011
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1012
|
+
serverApp.createService(modelName, {
|
|
1013
|
+
deleteWithMeta: async (uid) => {
|
|
1014
|
+
resolveDeleteStarted()
|
|
1015
|
+
await deleteCanReturn
|
|
1016
|
+
return [{ uid, label: 'stale-server-row' }, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }]
|
|
1017
|
+
},
|
|
1018
|
+
createWithMeta: async () => {},
|
|
1019
|
+
updateWithMeta: async () => {},
|
|
1020
|
+
findMany: async () => [],
|
|
1021
|
+
})
|
|
1022
|
+
serverApp.createService('sync', {
|
|
1023
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
1024
|
+
})
|
|
1025
|
+
}, { useOfflinePlugin: true })
|
|
1026
|
+
|
|
1027
|
+
try {
|
|
1028
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1029
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1030
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1031
|
+
|
|
1032
|
+
await model.remove('r1')
|
|
1033
|
+
await deleteStarted
|
|
1034
|
+
deleteRelease()
|
|
1035
|
+
|
|
1036
|
+
for (let i = 0; i < 50; i++) {
|
|
1037
|
+
const value = await model.db.values.get('r1')
|
|
1038
|
+
const meta = await model.db.metadata.get('r1')
|
|
1039
|
+
if (!value && !meta) break
|
|
1040
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
assert.ok(!await model.db.values.get('r1'), 'tombstone delete acknowledgement should remove value even if a value is returned')
|
|
1044
|
+
assert.ok(!await model.db.metadata.get('r1'), 'tombstone delete acknowledgement should remove metadata even if a value is returned')
|
|
1045
|
+
} finally {
|
|
1046
|
+
await cleanup()
|
|
1047
|
+
}
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
test('direct delete acknowledgement restores client when server row is newer', async () => {
|
|
1051
|
+
const modelName = `model${++dbCounter}`
|
|
1052
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1053
|
+
const futureUpdate = new Date(Date.now() + 60_000)
|
|
1054
|
+
|
|
1055
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-new' })
|
|
1056
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: futureUpdate })
|
|
1057
|
+
|
|
1058
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1059
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1060
|
+
{ useOfflinePlugin: true },
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
try {
|
|
1064
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1065
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1066
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1067
|
+
|
|
1068
|
+
await model.remove('r1')
|
|
1069
|
+
|
|
1070
|
+
let value
|
|
1071
|
+
for (let i = 0; i < 50; i++) {
|
|
1072
|
+
value = await model.db.values.get('r1')
|
|
1073
|
+
if (value?.label === 'server-new' && !value.__deleted__) break
|
|
1074
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const meta = await model.db.metadata.get('r1')
|
|
1078
|
+
assert.equal(value?.label, 'server-new', 'client should restore newer server row after stale delete acknowledgement')
|
|
1079
|
+
assert.ok(!value.__deleted__, 'client restored row must not remain hidden')
|
|
1080
|
+
assert.ok(!meta.deleted_at, 'client metadata must not keep stale delete timestamp')
|
|
1081
|
+
assert.equal(new Date(meta.updated_at).getTime(), futureUpdate.getTime())
|
|
1082
|
+
assert.equal(meta.__dirty__, false)
|
|
1083
|
+
} finally {
|
|
1084
|
+
await cleanup()
|
|
1085
|
+
pglite.close()
|
|
1086
|
+
}
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
test('direct create older than server update does not overwrite server row', async () => {
|
|
1090
|
+
const modelName = `model${++dbCounter}`
|
|
1091
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1092
|
+
|
|
1093
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-new' })
|
|
1094
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T2 })
|
|
1095
|
+
|
|
1096
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1097
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1098
|
+
{ useOfflinePlugin: true },
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
const result = await clientApp.service(modelName).createWithMeta('r1', { label: 'client-old' }, T1)
|
|
1103
|
+
const [returnedValue, returnedMeta] = result
|
|
1104
|
+
|
|
1105
|
+
assert.equal(returnedValue.label, 'server-new', 'stale direct create should return the newer server row')
|
|
1106
|
+
assert.equal(new Date(returnedMeta.updated_at).getTime(), T2.getTime())
|
|
1107
|
+
|
|
1108
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
1109
|
+
assert.equal(rows[0]?.label, 'server-new', 'server row must survive stale direct create')
|
|
1110
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
1111
|
+
assert.equal(new Date(serverMeta.updated_at).getTime(), T2.getTime())
|
|
1112
|
+
} finally {
|
|
1113
|
+
await cleanup()
|
|
1114
|
+
pglite.close()
|
|
1115
|
+
}
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
test('direct create older than orphaned server metadata tombstones and removes optimistic row', async () => {
|
|
1119
|
+
const modelName = `model${++dbCounter}`
|
|
1120
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1121
|
+
|
|
1122
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, updated_at: T2 })
|
|
1123
|
+
|
|
1124
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1125
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1126
|
+
{ useOfflinePlugin: true },
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
try {
|
|
1130
|
+
const result = await clientApp.service(modelName).createWithMeta('r1', { label: 'client-old' }, T1)
|
|
1131
|
+
const [returnedValue, returnedMeta] = result
|
|
1132
|
+
|
|
1133
|
+
assert.equal(returnedValue == null, true, 'stale direct create should not return a row when server metadata is orphaned')
|
|
1134
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime())
|
|
1135
|
+
|
|
1136
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
1137
|
+
assert.equal(rows.length, 0, 'orphaned server row must remain absent')
|
|
1138
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
1139
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime(), 'orphaned live metadata should become a tombstone')
|
|
1140
|
+
|
|
1141
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1142
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1143
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T1, __dirty__: true })
|
|
1144
|
+
await model.addSynchroWhere({})
|
|
1145
|
+
await model.synchronizeAll()
|
|
1146
|
+
|
|
1147
|
+
assert.ok(!await model.db.values.get('r1'), 'sync should remove optimistic row after orphaned metadata tombstone')
|
|
1148
|
+
assert.ok(!await model.db.metadata.get('r1'), 'sync should remove optimistic metadata after orphaned metadata tombstone')
|
|
1149
|
+
} finally {
|
|
1150
|
+
await cleanup()
|
|
1151
|
+
pglite.close()
|
|
1152
|
+
}
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
test('direct create older than server tombstone does not recreate server row', async () => {
|
|
1156
|
+
const modelName = `model${++dbCounter}`
|
|
1157
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1158
|
+
|
|
1159
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T2 })
|
|
1160
|
+
|
|
1161
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1162
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1163
|
+
{ useOfflinePlugin: true },
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
try {
|
|
1167
|
+
const result = await clientApp.service(modelName).createWithMeta('r1', { label: 'client-old' }, T1)
|
|
1168
|
+
const [returnedValue, returnedMeta] = result
|
|
1169
|
+
|
|
1170
|
+
assert.equal(returnedValue == null, true, 'stale direct create should not return a recreated row')
|
|
1171
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime())
|
|
1172
|
+
|
|
1173
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
1174
|
+
assert.equal(rows.length, 0, 'server tombstone must survive stale direct create')
|
|
1175
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
1176
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T2.getTime())
|
|
1177
|
+
} finally {
|
|
1178
|
+
await cleanup()
|
|
1179
|
+
pglite.close()
|
|
1180
|
+
}
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
test('direct create older than server tombstone removes stale server row', async () => {
|
|
1184
|
+
const modelName = `model${++dbCounter}`
|
|
1185
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1186
|
+
|
|
1187
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'stale-server-row' })
|
|
1188
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T2 })
|
|
1189
|
+
|
|
1190
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1191
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1192
|
+
{ useOfflinePlugin: true },
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
try {
|
|
1196
|
+
const result = await clientApp.service(modelName).createWithMeta('r1', { label: 'client-old' }, T1)
|
|
1197
|
+
const [returnedValue, returnedMeta] = result
|
|
1198
|
+
|
|
1199
|
+
assert.equal(returnedValue == null, true, 'stale direct create should not return a stale row under tombstone metadata')
|
|
1200
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T2.getTime())
|
|
1201
|
+
|
|
1202
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
1203
|
+
assert.equal(rows.length, 0, 'server tombstone must delete stale model row')
|
|
1204
|
+
} finally {
|
|
1205
|
+
await cleanup()
|
|
1206
|
+
pglite.close()
|
|
1207
|
+
}
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
test('direct create equal to server tombstone does not recreate server row', async () => {
|
|
1211
|
+
const modelName = `model${++dbCounter}`
|
|
1212
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1213
|
+
|
|
1214
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T1 })
|
|
1215
|
+
|
|
1216
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
1217
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
1218
|
+
{ useOfflinePlugin: true },
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
try {
|
|
1222
|
+
const result = await clientApp.service(modelName).createWithMeta('r1', { label: 'client-equal' }, T1)
|
|
1223
|
+
const [returnedValue, returnedMeta] = result
|
|
1224
|
+
|
|
1225
|
+
assert.equal(returnedValue == null, true, 'equal direct create should not return a recreated row')
|
|
1226
|
+
assert.equal(new Date(returnedMeta.deleted_at).getTime(), T1.getTime())
|
|
1227
|
+
|
|
1228
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
1229
|
+
assert.equal(rows.length, 0, 'server tombstone must survive equal direct create')
|
|
1230
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
1231
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T1.getTime())
|
|
1232
|
+
} finally {
|
|
1233
|
+
await cleanup()
|
|
1234
|
+
pglite.close()
|
|
1235
|
+
}
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
test('direct create acknowledgement removes optimistic row when server returns tombstone', async () => {
|
|
1239
|
+
const modelName = `model${++dbCounter}`
|
|
1240
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1241
|
+
let resolveCreateStarted
|
|
1242
|
+
let createRelease
|
|
1243
|
+
const createStarted = new Promise(resolve => { resolveCreateStarted = resolve })
|
|
1244
|
+
const createCanReturn = new Promise(resolve => { createRelease = resolve })
|
|
1245
|
+
|
|
1246
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1247
|
+
serverApp.createService(modelName, {
|
|
1248
|
+
createWithMeta: async (uid) => {
|
|
1249
|
+
resolveCreateStarted()
|
|
1250
|
+
await createCanReturn
|
|
1251
|
+
return [null, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }]
|
|
1252
|
+
},
|
|
1253
|
+
updateWithMeta: async () => {},
|
|
1254
|
+
deleteWithMeta: async () => {},
|
|
1255
|
+
findMany: async () => [],
|
|
1256
|
+
})
|
|
1257
|
+
serverApp.createService('sync', {
|
|
1258
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
1259
|
+
})
|
|
1260
|
+
}, { useOfflinePlugin: true })
|
|
1261
|
+
|
|
1262
|
+
try {
|
|
1263
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1264
|
+
|
|
1265
|
+
const record = await model.create({ label: 'client-old' })
|
|
1266
|
+
await createStarted
|
|
1267
|
+
createRelease()
|
|
1268
|
+
|
|
1269
|
+
for (let i = 0; i < 50; i++) {
|
|
1270
|
+
const value = await model.db.values.get(record.uid)
|
|
1271
|
+
const meta = await model.db.metadata.get(record.uid)
|
|
1272
|
+
if (!value && !meta) break
|
|
1273
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
assert.ok(!await model.db.values.get(record.uid), 'optimistic create value should be removed when server returns tombstone')
|
|
1277
|
+
assert.ok(!await model.db.metadata.get(record.uid), 'optimistic create metadata should be removed when server returns tombstone')
|
|
1278
|
+
} finally {
|
|
1279
|
+
await cleanup()
|
|
1280
|
+
}
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
test('direct create acknowledgement ignores value when server returns tombstone', async () => {
|
|
1284
|
+
const modelName = `model${++dbCounter}`
|
|
1285
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1286
|
+
let resolveCreateStarted
|
|
1287
|
+
let createRelease
|
|
1288
|
+
const createStarted = new Promise(resolve => { resolveCreateStarted = resolve })
|
|
1289
|
+
const createCanReturn = new Promise(resolve => { createRelease = resolve })
|
|
1290
|
+
|
|
1291
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1292
|
+
serverApp.createService(modelName, {
|
|
1293
|
+
createWithMeta: async (uid, data) => {
|
|
1294
|
+
resolveCreateStarted()
|
|
1295
|
+
await createCanReturn
|
|
1296
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }]
|
|
1297
|
+
},
|
|
1298
|
+
updateWithMeta: async () => {},
|
|
1299
|
+
deleteWithMeta: async () => {},
|
|
1300
|
+
findMany: async () => [],
|
|
1301
|
+
})
|
|
1302
|
+
serverApp.createService('sync', {
|
|
1303
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
1304
|
+
})
|
|
1305
|
+
}, { useOfflinePlugin: true })
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1309
|
+
|
|
1310
|
+
const record = await model.create({ label: 'client-old' })
|
|
1311
|
+
await createStarted
|
|
1312
|
+
createRelease()
|
|
1313
|
+
|
|
1314
|
+
for (let i = 0; i < 50; i++) {
|
|
1315
|
+
const value = await model.db.values.get(record.uid)
|
|
1316
|
+
const meta = await model.db.metadata.get(record.uid)
|
|
1317
|
+
if (!value && !meta) break
|
|
1318
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
assert.ok(!await model.db.values.get(record.uid), 'tombstone acknowledgement should remove value even if a value is returned')
|
|
1322
|
+
assert.ok(!await model.db.metadata.get(record.uid), 'tombstone acknowledgement should remove metadata even if a value is returned')
|
|
1323
|
+
} finally {
|
|
1324
|
+
await cleanup()
|
|
1325
|
+
}
|
|
1326
|
+
})
|
|
1327
|
+
|
|
1328
|
+
test('sync addDatabase tombstone response removes optimistic local row', async () => {
|
|
1329
|
+
const modelName = `model${++dbCounter}`
|
|
1330
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1331
|
+
|
|
1332
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1333
|
+
serverApp.createService(modelName, {
|
|
1334
|
+
createWithMeta: async (uid) => [null, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }],
|
|
1335
|
+
updateWithMeta: async () => {},
|
|
1336
|
+
deleteWithMeta: async () => {},
|
|
1337
|
+
findMany: async () => [],
|
|
1338
|
+
})
|
|
1339
|
+
serverApp.createService('sync', {
|
|
1340
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
1341
|
+
addClient: [],
|
|
1342
|
+
updateClient: [],
|
|
1343
|
+
deleteClient: [],
|
|
1344
|
+
addDatabase: [clientMetadataDict.r1],
|
|
1345
|
+
updateDatabase: [],
|
|
1346
|
+
}),
|
|
1347
|
+
})
|
|
1348
|
+
}, { useOfflinePlugin: true })
|
|
1349
|
+
|
|
1350
|
+
try {
|
|
1351
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1352
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1353
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T1, __dirty__: true })
|
|
1354
|
+
await model.addSynchroWhere({})
|
|
1355
|
+
|
|
1356
|
+
await model.synchronizeAll()
|
|
1357
|
+
|
|
1358
|
+
assert.ok(!await model.db.values.get('r1'), 'sync addDatabase tombstone response should remove local value')
|
|
1359
|
+
assert.ok(!await model.db.metadata.get('r1'), 'sync addDatabase tombstone response should remove local metadata')
|
|
1360
|
+
} finally {
|
|
1361
|
+
await cleanup()
|
|
1362
|
+
}
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
test('sync addDatabase tombstone response ignores returned value', async () => {
|
|
1366
|
+
const modelName = `model${++dbCounter}`
|
|
1367
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1368
|
+
|
|
1369
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1370
|
+
serverApp.createService(modelName, {
|
|
1371
|
+
createWithMeta: async (uid) => [{ uid, label: 'stale-server-row' }, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }],
|
|
1372
|
+
updateWithMeta: async () => {},
|
|
1373
|
+
deleteWithMeta: async () => {},
|
|
1374
|
+
findMany: async () => [],
|
|
1375
|
+
})
|
|
1376
|
+
serverApp.createService('sync', {
|
|
1377
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
1378
|
+
addClient: [],
|
|
1379
|
+
updateClient: [],
|
|
1380
|
+
deleteClient: [],
|
|
1381
|
+
addDatabase: [clientMetadataDict.r1],
|
|
1382
|
+
updateDatabase: [],
|
|
1383
|
+
}),
|
|
1384
|
+
})
|
|
1385
|
+
}, { useOfflinePlugin: true })
|
|
1386
|
+
|
|
1387
|
+
try {
|
|
1388
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1389
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1390
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T1, __dirty__: true })
|
|
1391
|
+
await model.addSynchroWhere({})
|
|
1392
|
+
|
|
1393
|
+
await model.synchronizeAll()
|
|
1394
|
+
|
|
1395
|
+
assert.ok(!await model.db.values.get('r1'), 'sync addDatabase tombstone response should remove local value even if a value is returned')
|
|
1396
|
+
assert.ok(!await model.db.metadata.get('r1'), 'sync addDatabase tombstone response should remove local metadata even if a value is returned')
|
|
1397
|
+
} finally {
|
|
1398
|
+
await cleanup()
|
|
1399
|
+
}
|
|
1400
|
+
})
|
|
1401
|
+
|
|
1402
|
+
test('sync addDatabase live response replaces stale local value', async () => {
|
|
1403
|
+
const modelName = `model${++dbCounter}`
|
|
1404
|
+
|
|
1405
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1406
|
+
serverApp.createService(modelName, {
|
|
1407
|
+
createWithMeta: async (uid) => [
|
|
1408
|
+
{ uid, label: 'server-new' },
|
|
1409
|
+
{ uid, created_at: T0, updated_at: T2, deleted_at: null },
|
|
1410
|
+
],
|
|
1411
|
+
updateWithMeta: async () => {},
|
|
1412
|
+
deleteWithMeta: async () => {},
|
|
1413
|
+
findMany: async () => [],
|
|
1414
|
+
})
|
|
1415
|
+
serverApp.createService('sync', {
|
|
1416
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
1417
|
+
addClient: [],
|
|
1418
|
+
updateClient: [],
|
|
1419
|
+
deleteClient: [],
|
|
1420
|
+
addDatabase: [clientMetadataDict.r1],
|
|
1421
|
+
updateDatabase: [],
|
|
1422
|
+
}),
|
|
1423
|
+
})
|
|
1424
|
+
}, { useOfflinePlugin: true })
|
|
1425
|
+
|
|
1426
|
+
try {
|
|
1427
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1428
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1429
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T1, __dirty__: true })
|
|
1430
|
+
await model.addSynchroWhere({})
|
|
1431
|
+
|
|
1432
|
+
await model.synchronizeAll()
|
|
1433
|
+
|
|
1434
|
+
const value = await model.db.values.get('r1')
|
|
1435
|
+
const meta = await model.db.metadata.get('r1')
|
|
1436
|
+
assert.equal(value.label, 'server-new', 'sync addDatabase live response should replace stale local value')
|
|
1437
|
+
assert.equal(new Date(meta.updated_at).getTime(), T2.getTime())
|
|
1438
|
+
assert.equal(meta.__dirty__, false)
|
|
1439
|
+
} finally {
|
|
1440
|
+
await cleanup()
|
|
1441
|
+
}
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
test('direct update acknowledgement removes local row when server returns tombstone', async () => {
|
|
1445
|
+
const modelName = `model${++dbCounter}`
|
|
1446
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1447
|
+
let resolveUpdateStarted
|
|
1448
|
+
let updateRelease
|
|
1449
|
+
const updateStarted = new Promise(resolve => { resolveUpdateStarted = resolve })
|
|
1450
|
+
const updateCanReturn = new Promise(resolve => { updateRelease = resolve })
|
|
1451
|
+
|
|
1452
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1453
|
+
serverApp.createService(modelName, {
|
|
1454
|
+
createWithMeta: async () => {},
|
|
1455
|
+
updateWithMeta: async (uid) => {
|
|
1456
|
+
resolveUpdateStarted()
|
|
1457
|
+
await updateCanReturn
|
|
1458
|
+
return [null, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }]
|
|
1459
|
+
},
|
|
1460
|
+
deleteWithMeta: async () => {},
|
|
1461
|
+
findMany: async () => [],
|
|
1462
|
+
})
|
|
1463
|
+
serverApp.createService('sync', {
|
|
1464
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
1465
|
+
})
|
|
1466
|
+
}, { useOfflinePlugin: true })
|
|
1467
|
+
|
|
1468
|
+
try {
|
|
1469
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1470
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1471
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1472
|
+
|
|
1473
|
+
await model.update('r1', { label: 'client-new' })
|
|
1474
|
+
await updateStarted
|
|
1475
|
+
updateRelease()
|
|
1476
|
+
|
|
1477
|
+
for (let i = 0; i < 50; i++) {
|
|
1478
|
+
const value = await model.db.values.get('r1')
|
|
1479
|
+
const meta = await model.db.metadata.get('r1')
|
|
1480
|
+
if (!value && !meta) break
|
|
1481
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
assert.ok(!await model.db.values.get('r1'), 'stale update tombstone response should remove local value')
|
|
1485
|
+
assert.ok(!await model.db.metadata.get('r1'), 'stale update tombstone response should remove local metadata')
|
|
1486
|
+
} finally {
|
|
1487
|
+
await cleanup()
|
|
1488
|
+
}
|
|
1489
|
+
})
|
|
1490
|
+
|
|
1491
|
+
test('direct update acknowledgement ignores value when server returns tombstone', async () => {
|
|
1492
|
+
const modelName = `model${++dbCounter}`
|
|
1493
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1494
|
+
let resolveUpdateStarted
|
|
1495
|
+
let updateRelease
|
|
1496
|
+
const updateStarted = new Promise(resolve => { resolveUpdateStarted = resolve })
|
|
1497
|
+
const updateCanReturn = new Promise(resolve => { updateRelease = resolve })
|
|
1498
|
+
|
|
1499
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1500
|
+
serverApp.createService(modelName, {
|
|
1501
|
+
createWithMeta: async () => {},
|
|
1502
|
+
updateWithMeta: async (uid, data) => {
|
|
1503
|
+
resolveUpdateStarted()
|
|
1504
|
+
await updateCanReturn
|
|
1505
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }]
|
|
1506
|
+
},
|
|
1507
|
+
deleteWithMeta: async () => {},
|
|
1508
|
+
findMany: async () => [],
|
|
1509
|
+
})
|
|
1510
|
+
serverApp.createService('sync', {
|
|
1511
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
1512
|
+
})
|
|
1513
|
+
}, { useOfflinePlugin: true })
|
|
1514
|
+
|
|
1515
|
+
try {
|
|
1516
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1517
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1518
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1519
|
+
|
|
1520
|
+
await model.update('r1', { label: 'client-new' })
|
|
1521
|
+
await updateStarted
|
|
1522
|
+
updateRelease()
|
|
1523
|
+
|
|
1524
|
+
for (let i = 0; i < 50; i++) {
|
|
1525
|
+
const value = await model.db.values.get('r1')
|
|
1526
|
+
const meta = await model.db.metadata.get('r1')
|
|
1527
|
+
if (!value && !meta) break
|
|
1528
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
assert.ok(!await model.db.values.get('r1'), 'tombstone update acknowledgement should remove value even if a value is returned')
|
|
1532
|
+
assert.ok(!await model.db.metadata.get('r1'), 'tombstone update acknowledgement should remove metadata even if a value is returned')
|
|
1533
|
+
} finally {
|
|
1534
|
+
await cleanup()
|
|
1535
|
+
}
|
|
1536
|
+
})
|
|
1537
|
+
|
|
1538
|
+
test('sync updateDatabase tombstone response removes local row', async () => {
|
|
1539
|
+
const modelName = `model${++dbCounter}`
|
|
1540
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1541
|
+
|
|
1542
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1543
|
+
serverApp.createService(modelName, {
|
|
1544
|
+
createWithMeta: async () => {},
|
|
1545
|
+
updateWithMeta: async (uid) => [null, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }],
|
|
1546
|
+
deleteWithMeta: async () => {},
|
|
1547
|
+
findMany: async () => [],
|
|
1548
|
+
})
|
|
1549
|
+
serverApp.createService('sync', {
|
|
1550
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
1551
|
+
addClient: [],
|
|
1552
|
+
updateClient: [],
|
|
1553
|
+
deleteClient: [],
|
|
1554
|
+
addDatabase: [],
|
|
1555
|
+
updateDatabase: [clientMetadataDict.r1],
|
|
1556
|
+
}),
|
|
1557
|
+
})
|
|
1558
|
+
}, { useOfflinePlugin: true })
|
|
1559
|
+
|
|
1560
|
+
try {
|
|
1561
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1562
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1563
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1, __dirty__: true })
|
|
1564
|
+
await model.addSynchroWhere({})
|
|
1565
|
+
|
|
1566
|
+
await model.synchronizeAll()
|
|
1567
|
+
|
|
1568
|
+
assert.ok(!await model.db.values.get('r1'), 'sync updateDatabase tombstone response should remove local value')
|
|
1569
|
+
assert.ok(!await model.db.metadata.get('r1'), 'sync updateDatabase tombstone response should remove local metadata')
|
|
1570
|
+
} finally {
|
|
1571
|
+
await cleanup()
|
|
1572
|
+
}
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
test('sync updateDatabase tombstone response ignores returned value', async () => {
|
|
1576
|
+
const modelName = `model${++dbCounter}`
|
|
1577
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
1578
|
+
|
|
1579
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1580
|
+
serverApp.createService(modelName, {
|
|
1581
|
+
createWithMeta: async () => {},
|
|
1582
|
+
updateWithMeta: async (uid) => [{ uid, label: 'stale-server-row' }, { uid, created_at: T0, updated_at: null, deleted_at: futureDelete }],
|
|
1583
|
+
deleteWithMeta: async () => {},
|
|
1584
|
+
findMany: async () => [],
|
|
1585
|
+
})
|
|
1586
|
+
serverApp.createService('sync', {
|
|
1587
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
1588
|
+
addClient: [],
|
|
1589
|
+
updateClient: [],
|
|
1590
|
+
deleteClient: [],
|
|
1591
|
+
addDatabase: [],
|
|
1592
|
+
updateDatabase: [clientMetadataDict.r1],
|
|
1593
|
+
}),
|
|
1594
|
+
})
|
|
1595
|
+
}, { useOfflinePlugin: true })
|
|
1596
|
+
|
|
1597
|
+
try {
|
|
1598
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1599
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1600
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1, __dirty__: true })
|
|
1601
|
+
await model.addSynchroWhere({})
|
|
1602
|
+
|
|
1603
|
+
await model.synchronizeAll()
|
|
1604
|
+
|
|
1605
|
+
assert.ok(!await model.db.values.get('r1'), 'sync updateDatabase tombstone response should remove local value even if a value is returned')
|
|
1606
|
+
assert.ok(!await model.db.metadata.get('r1'), 'sync updateDatabase tombstone response should remove local metadata even if a value is returned')
|
|
1607
|
+
} finally {
|
|
1608
|
+
await cleanup()
|
|
1609
|
+
}
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1612
|
+
test('sync updateDatabase live response replaces stale local value', async () => {
|
|
1613
|
+
const modelName = `model${++dbCounter}`
|
|
1614
|
+
|
|
1615
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
1616
|
+
serverApp.createService(modelName, {
|
|
1617
|
+
createWithMeta: async () => {},
|
|
1618
|
+
updateWithMeta: async (uid) => [
|
|
1619
|
+
{ uid, label: 'server-new' },
|
|
1620
|
+
{ uid, created_at: T0, updated_at: T2, deleted_at: null },
|
|
1621
|
+
],
|
|
1622
|
+
deleteWithMeta: async () => {},
|
|
1623
|
+
findMany: async () => [],
|
|
1624
|
+
})
|
|
1625
|
+
serverApp.createService('sync', {
|
|
1626
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
1627
|
+
addClient: [],
|
|
1628
|
+
updateClient: [],
|
|
1629
|
+
deleteClient: [],
|
|
1630
|
+
addDatabase: [],
|
|
1631
|
+
updateDatabase: [clientMetadataDict.r1],
|
|
1632
|
+
}),
|
|
1633
|
+
})
|
|
1634
|
+
}, { useOfflinePlugin: true })
|
|
1635
|
+
|
|
1636
|
+
try {
|
|
1637
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1638
|
+
await model.db.values.add({ uid: 'r1', label: 'client-old' })
|
|
1639
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1, __dirty__: true })
|
|
1640
|
+
await model.addSynchroWhere({})
|
|
1641
|
+
|
|
1642
|
+
await model.synchronizeAll()
|
|
1643
|
+
|
|
1644
|
+
const value = await model.db.values.get('r1')
|
|
1645
|
+
const meta = await model.db.metadata.get('r1')
|
|
1646
|
+
assert.equal(value.label, 'server-new', 'sync updateDatabase live response should replace stale local value')
|
|
1647
|
+
assert.equal(new Date(meta.updated_at).getTime(), T2.getTime())
|
|
1648
|
+
assert.equal(meta.__dirty__, false)
|
|
1649
|
+
} finally {
|
|
1650
|
+
await cleanup()
|
|
1651
|
+
}
|
|
1652
|
+
})
|
|
1653
|
+
|
|
1654
|
+
test('offline changes are synced after server restart', async () => {
|
|
1655
|
+
const modelName = `model${++dbCounter}`
|
|
1656
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
1657
|
+
|
|
1658
|
+
// ─ Phase 1: start server, connect, register synchro scope ─
|
|
1659
|
+
const serverApp1 = expressX({})
|
|
1660
|
+
serverApp1.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
1661
|
+
await new Promise(resolve => serverApp1.httpServer.listen(0, resolve))
|
|
1662
|
+
const port = serverApp1.httpServer.address().port
|
|
1663
|
+
|
|
1664
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
1665
|
+
transports: ['websocket'],
|
|
1666
|
+
autoConnect: false,
|
|
1667
|
+
reconnectionDelay: 100,
|
|
1668
|
+
reconnectionDelayMax: 500,
|
|
1669
|
+
})
|
|
1670
|
+
const clientApp = createClient(socket, { debug: false })
|
|
1671
|
+
offlinePlugin(clientApp)
|
|
1672
|
+
socket.connect()
|
|
1673
|
+
await new Promise((resolve, reject) => {
|
|
1674
|
+
socket.on('connect', resolve)
|
|
1675
|
+
socket.on('connect_error', reject)
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1679
|
+
await model.addSynchroWhere({})
|
|
1680
|
+
await model.synchronizeAll() // initial sync — server is empty
|
|
1681
|
+
|
|
1682
|
+
// ─ Phase 2: stop server; disconnect all clients ─
|
|
1683
|
+
// io.disconnectSockets() gives 'io server disconnect' which suppresses
|
|
1684
|
+
// socket.io auto-reconnect, so we reconnect manually in phase 5.
|
|
1685
|
+
const disconnected = new Promise(resolve => socket.once('disconnect', resolve))
|
|
1686
|
+
serverApp1.io.disconnectSockets(true)
|
|
1687
|
+
await new Promise(resolve => serverApp1.io.close(resolve))
|
|
1688
|
+
await disconnected
|
|
1689
|
+
|
|
1690
|
+
assert.equal(clientApp.isConnected, false, 'client should be offline')
|
|
1691
|
+
|
|
1692
|
+
// ─ Phase 3: write to Dexie while offline ─
|
|
1693
|
+
await model.db.values.add({ uid: 'y1', label: 'Offline change' })
|
|
1694
|
+
await model.db.metadata.add({ uid: 'y1', created_at: T1 })
|
|
1695
|
+
|
|
1696
|
+
// ─ Phase 4: restart server on the same port ─
|
|
1697
|
+
const serverApp2 = expressX({})
|
|
1698
|
+
serverApp2.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
1699
|
+
await new Promise(resolve => serverApp2.httpServer.listen(port, resolve))
|
|
1700
|
+
|
|
1701
|
+
// ─ Phase 5: reconnect manually (auto-reconnect is suppressed after
|
|
1702
|
+
// server-initiated disconnect) and wait for offlinePlugin to fire ─
|
|
1703
|
+
const reconnected = new Promise((resolve, reject) => {
|
|
1704
|
+
const timer = setTimeout(() => reject(new Error('reconnect timeout')), 5000)
|
|
1705
|
+
socket.once('connect', () => { clearTimeout(timer); resolve() })
|
|
1706
|
+
})
|
|
1707
|
+
socket.connect()
|
|
1708
|
+
await reconnected
|
|
1709
|
+
|
|
1710
|
+
// ─ Phase 6: offlinePlugin's connect listener fires synchronizeAll in the
|
|
1711
|
+
// background; awaiting it here serialises behind the mutex so assertions
|
|
1712
|
+
// run only after the offline change has been pushed to the server. ─
|
|
1713
|
+
await model.synchronizeAll()
|
|
1714
|
+
|
|
1715
|
+
// ─ Phase 7: assert ─
|
|
1716
|
+
assert.equal(clientApp.isConnected, true, 'client should be online again')
|
|
1717
|
+
const rows = await db.select().from(modelTable).where(eq(modelTable.uid, 'y1'))
|
|
1718
|
+
assert.ok(rows.length > 0, 'offline change should reach the server after reconnect')
|
|
1719
|
+
assert.equal(rows[0].label, 'Offline change')
|
|
1720
|
+
|
|
1721
|
+
socket.disconnect()
|
|
1722
|
+
await new Promise(resolve => serverApp2.io.close(resolve))
|
|
1723
|
+
pglite.close()
|
|
1724
|
+
})
|
|
1725
|
+
|
|
1726
|
+
test('reconnect keeps disconnectedDate until automatic sync completes', async () => {
|
|
1727
|
+
const modelName = `model${++dbCounter}`
|
|
1728
|
+
const serverRows = []
|
|
1729
|
+
let resolveSyncStarted
|
|
1730
|
+
let releaseSync
|
|
1731
|
+
let resolveCreate
|
|
1732
|
+
const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
|
|
1733
|
+
const syncRelease = new Promise(resolve => { releaseSync = resolve })
|
|
1734
|
+
const createDone = new Promise(resolve => { resolveCreate = resolve })
|
|
1735
|
+
|
|
1736
|
+
function registerServices(app, { slowSync = false } = {}) {
|
|
1737
|
+
app.createService(modelName, {
|
|
1738
|
+
createWithMeta: async (uid, data, created_at) => {
|
|
1739
|
+
serverRows.push({ uid, ...data })
|
|
1740
|
+
resolveCreate()
|
|
1741
|
+
return [{ uid, ...data }, { uid, created_at, updated_at: null, deleted_at: null }]
|
|
1742
|
+
},
|
|
1743
|
+
updateWithMeta: async () => {},
|
|
1744
|
+
deleteWithMeta: async () => {},
|
|
1745
|
+
findMany: async () => [],
|
|
1746
|
+
})
|
|
1747
|
+
app.createService('sync', {
|
|
1748
|
+
go: async (_modelName, _where, clientMetadataDict) => {
|
|
1749
|
+
if (slowSync) {
|
|
1750
|
+
resolveSyncStarted()
|
|
1751
|
+
await syncRelease
|
|
1752
|
+
}
|
|
1753
|
+
return {
|
|
1754
|
+
addClient: [],
|
|
1755
|
+
updateClient: [],
|
|
1756
|
+
deleteClient: [],
|
|
1757
|
+
addDatabase: Object.values(clientMetadataDict),
|
|
1758
|
+
updateDatabase: [],
|
|
1759
|
+
}
|
|
1760
|
+
},
|
|
1761
|
+
})
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const serverApp1 = expressX({})
|
|
1765
|
+
registerServices(serverApp1)
|
|
1766
|
+
await new Promise(resolve => serverApp1.httpServer.listen(0, resolve))
|
|
1767
|
+
const port = serverApp1.httpServer.address().port
|
|
1768
|
+
|
|
1769
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
1770
|
+
transports: ['websocket'],
|
|
1771
|
+
autoConnect: false,
|
|
1772
|
+
})
|
|
1773
|
+
const clientApp = createClient(socket, { debug: false })
|
|
1774
|
+
offlinePlugin(clientApp)
|
|
1775
|
+
socket.connect()
|
|
1776
|
+
await new Promise((resolve, reject) => {
|
|
1777
|
+
socket.on('connect', resolve)
|
|
1778
|
+
socket.on('connect_error', reject)
|
|
1779
|
+
})
|
|
1780
|
+
|
|
1781
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
1782
|
+
await model.addSynchroWhere({})
|
|
1783
|
+
await model.synchronizeAll()
|
|
1784
|
+
|
|
1785
|
+
const disconnected = new Promise(resolve => socket.once('disconnect', resolve))
|
|
1786
|
+
serverApp1.io.disconnectSockets(true)
|
|
1787
|
+
await new Promise(resolve => serverApp1.io.close(resolve))
|
|
1788
|
+
await disconnected
|
|
1789
|
+
|
|
1790
|
+
await model.db.values.add({ uid: 'reconnect-1', label: 'from reconnect' })
|
|
1791
|
+
await model.db.metadata.add({ uid: 'reconnect-1', created_at: T1 })
|
|
1792
|
+
|
|
1793
|
+
const serverApp2 = expressX({})
|
|
1794
|
+
registerServices(serverApp2, { slowSync: true })
|
|
1795
|
+
await new Promise(resolve => serverApp2.httpServer.listen(port, resolve))
|
|
1796
|
+
|
|
1797
|
+
const reconnected = new Promise((resolve, reject) => {
|
|
1798
|
+
const timer = setTimeout(() => reject(new Error('reconnect timeout')), 5000)
|
|
1799
|
+
socket.once('connect', () => { clearTimeout(timer); resolve() })
|
|
1800
|
+
})
|
|
1801
|
+
socket.connect()
|
|
1802
|
+
await reconnected
|
|
1803
|
+
await syncStarted
|
|
1804
|
+
|
|
1805
|
+
assert.ok(clientApp.disconnectedDate, 'disconnect marker should remain while reconnect sync is running')
|
|
1806
|
+
assert.equal(serverRows.length, 0, 'offline row should not be pushed before sync is released')
|
|
1807
|
+
|
|
1808
|
+
releaseSync()
|
|
1809
|
+
await createDone
|
|
1810
|
+
|
|
1811
|
+
for (let i = 0; i < 50 && clientApp.disconnectedDate; i++) {
|
|
1812
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
assert.equal(clientApp.disconnectedDate, null, 'disconnect marker should clear after reconnect sync completes')
|
|
1816
|
+
assert.deepEqual(serverRows, [{ uid: 'reconnect-1', label: 'from reconnect' }])
|
|
1817
|
+
|
|
1818
|
+
socket.disconnect()
|
|
1819
|
+
await new Promise(resolve => serverApp2.io.close(resolve))
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
test('updateWithMeta pub/sub handler tolerates undefined value (concurrent delete race)', async () => {
|
|
1823
|
+
// updateWithMeta does `const [value] = UPDATE ... RETURNING`. If the model row
|
|
1824
|
+
// was deleted by a concurrent operation between the sync's findMany snapshot and
|
|
1825
|
+
// the actual UPDATE, RETURNING yields 0 rows → value = undefined. The server
|
|
1826
|
+
// broadcasts [undefined, meta]. The handler crashes on `db.values.put(undefined)`
|
|
1827
|
+
// (TypeError: Cannot read properties of undefined) before db.metadata.put(meta)
|
|
1828
|
+
// can run, leaving metadata inconsistent.
|
|
1829
|
+
const modelName = `model${++dbCounter}`
|
|
1830
|
+
|
|
1831
|
+
const serverApp = expressX({})
|
|
1832
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
1833
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
1834
|
+
const port = serverApp.httpServer.address().port
|
|
1835
|
+
|
|
1836
|
+
function connectClient() {
|
|
1837
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
1838
|
+
const app = createClient(socket, { debug: false })
|
|
1839
|
+
offlinePlugin(app)
|
|
1840
|
+
socket.connect()
|
|
1841
|
+
return new Promise((resolve, reject) => {
|
|
1842
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
1843
|
+
socket.on('connect_error', reject)
|
|
1844
|
+
})
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
1848
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
1849
|
+
|
|
1850
|
+
await modelB.db.values.add({ uid: 'r1', label: 'old' })
|
|
1851
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1852
|
+
|
|
1853
|
+
// Simulate the server broadcasting updateWithMeta with undefined value
|
|
1854
|
+
serverApp.io.to('all').emit('service-event', {
|
|
1855
|
+
name: modelName,
|
|
1856
|
+
action: 'updateWithMeta',
|
|
1857
|
+
result: [undefined, { uid: 'r1', created_at: T0, updated_at: T1, deleted_at: null }],
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
await new Promise(r => setTimeout(r, 200))
|
|
1861
|
+
|
|
1862
|
+
// With bug: TypeError on undefined crashes before db.metadata.put(meta) runs
|
|
1863
|
+
// → updated_at stays T0 (unchanged).
|
|
1864
|
+
// With fix: handler guards value, skips the put, still updates metadata
|
|
1865
|
+
// → updated_at becomes T1.
|
|
1866
|
+
const meta = await modelB.db.metadata.get('r1')
|
|
1867
|
+
assert.ok(meta?.updated_at, 'metadata updated_at must be set even when value is undefined')
|
|
1868
|
+
|
|
1869
|
+
socketB.disconnect()
|
|
1870
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1873
|
+
test('createWithMeta pub/sub tombstone response removes local row', async () => {
|
|
1874
|
+
const modelName = `model${++dbCounter}`
|
|
1875
|
+
|
|
1876
|
+
const serverApp = expressX({})
|
|
1877
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
1878
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
1879
|
+
const port = serverApp.httpServer.address().port
|
|
1880
|
+
|
|
1881
|
+
function connectClient() {
|
|
1882
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
1883
|
+
const app = createClient(socket, { debug: false })
|
|
1884
|
+
offlinePlugin(app)
|
|
1885
|
+
socket.connect()
|
|
1886
|
+
return new Promise((resolve, reject) => {
|
|
1887
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
1888
|
+
socket.on('connect_error', reject)
|
|
1889
|
+
})
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
1893
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
1894
|
+
|
|
1895
|
+
await modelB.db.values.add({ uid: 'r1', label: 'old' })
|
|
1896
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1897
|
+
|
|
1898
|
+
serverApp.io.to('all').emit('service-event', {
|
|
1899
|
+
name: modelName,
|
|
1900
|
+
action: 'createWithMeta',
|
|
1901
|
+
result: [undefined, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
await new Promise(r => setTimeout(r, 200))
|
|
1905
|
+
|
|
1906
|
+
assert.ok(!await modelB.db.values.get('r1'), 'pub/sub create tombstone should remove local value')
|
|
1907
|
+
assert.ok(!await modelB.db.metadata.get('r1'), 'pub/sub create tombstone should remove local metadata')
|
|
1908
|
+
|
|
1909
|
+
socketB.disconnect()
|
|
1910
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
1911
|
+
})
|
|
1912
|
+
|
|
1913
|
+
test('updateWithMeta pub/sub tombstone response removes local row', async () => {
|
|
1914
|
+
const modelName = `model${++dbCounter}`
|
|
1915
|
+
|
|
1916
|
+
const serverApp = expressX({})
|
|
1917
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
1918
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
1919
|
+
const port = serverApp.httpServer.address().port
|
|
1920
|
+
|
|
1921
|
+
function connectClient() {
|
|
1922
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
1923
|
+
const app = createClient(socket, { debug: false })
|
|
1924
|
+
offlinePlugin(app)
|
|
1925
|
+
socket.connect()
|
|
1926
|
+
return new Promise((resolve, reject) => {
|
|
1927
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
1928
|
+
socket.on('connect_error', reject)
|
|
1929
|
+
})
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
1933
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
1934
|
+
|
|
1935
|
+
await modelB.db.values.add({ uid: 'r1', label: 'old' })
|
|
1936
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
1937
|
+
|
|
1938
|
+
serverApp.io.to('all').emit('service-event', {
|
|
1939
|
+
name: modelName,
|
|
1940
|
+
action: 'updateWithMeta',
|
|
1941
|
+
result: [undefined, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
1942
|
+
})
|
|
1943
|
+
|
|
1944
|
+
await new Promise(r => setTimeout(r, 200))
|
|
1945
|
+
|
|
1946
|
+
assert.ok(!await modelB.db.values.get('r1'), 'pub/sub update tombstone should remove local value')
|
|
1947
|
+
assert.ok(!await modelB.db.metadata.get('r1'), 'pub/sub update tombstone should remove local metadata')
|
|
1948
|
+
|
|
1949
|
+
socketB.disconnect()
|
|
1950
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1953
|
+
test('stale pub/sub events do not overwrite newer dirty local state', async () => {
|
|
1954
|
+
const modelName = `model${++dbCounter}`
|
|
1955
|
+
|
|
1956
|
+
const serverApp = expressX({})
|
|
1957
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
1958
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
1959
|
+
const port = serverApp.httpServer.address().port
|
|
1960
|
+
|
|
1961
|
+
function connectClient() {
|
|
1962
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
1963
|
+
const app = createClient(socket, { debug: false })
|
|
1964
|
+
offlinePlugin(app)
|
|
1965
|
+
socket.connect()
|
|
1966
|
+
return new Promise((resolve, reject) => {
|
|
1967
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
1968
|
+
socket.on('connect_error', reject)
|
|
1969
|
+
})
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
1973
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
1974
|
+
|
|
1975
|
+
await modelB.db.values.add({ uid: 'r1', label: 'local-new' })
|
|
1976
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T2, __dirty__: true })
|
|
1977
|
+
|
|
1978
|
+
serverApp.io.to('all').emit('service-event', {
|
|
1979
|
+
name: modelName,
|
|
1980
|
+
action: 'updateWithMeta',
|
|
1981
|
+
result: [{ uid: 'r1', label: 'server-old' }, { uid: 'r1', created_at: T0, updated_at: T1, deleted_at: null }],
|
|
1982
|
+
})
|
|
1983
|
+
|
|
1984
|
+
await new Promise(r => setTimeout(r, 100))
|
|
1985
|
+
|
|
1986
|
+
serverApp.io.to('all').emit('service-event', {
|
|
1987
|
+
name: modelName,
|
|
1988
|
+
action: 'deleteWithMeta',
|
|
1989
|
+
result: [{ uid: 'r1', label: 'server-old' }, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
1990
|
+
})
|
|
1991
|
+
|
|
1992
|
+
await new Promise(r => setTimeout(r, 100))
|
|
1993
|
+
|
|
1994
|
+
const value = await modelB.db.values.get('r1')
|
|
1995
|
+
const meta = await modelB.db.metadata.get('r1')
|
|
1996
|
+
assert.equal(value?.label, 'local-new', 'stale pub/sub update/delete must not overwrite newer dirty value')
|
|
1997
|
+
assert.equal(new Date(meta?.updated_at).getTime(), T2.getTime(), 'stale pub/sub event must not downgrade metadata')
|
|
1998
|
+
assert.equal(meta?.__dirty__, true, 'dirty marker must survive stale pub/sub event')
|
|
1999
|
+
|
|
2000
|
+
socketB.disconnect()
|
|
2001
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
2002
|
+
})
|
|
2003
|
+
|
|
2004
|
+
test('deleteWithMeta pub/sub handler uses delete not put to avoid orphan metadata', async () => {
|
|
2005
|
+
// synchronize() step 2 deletes both idbValues and idbMetadata for deleteClient uids.
|
|
2006
|
+
// The deleteWithMeta pub/sub event may arrive on the SAME tab either before or after
|
|
2007
|
+
// that cleanup. The handler uses db.metadata.put(meta) (upsert with deleted_at),
|
|
2008
|
+
// so if it runs AFTER step 2 it RE-CREATES the metadata row as an orphan that can
|
|
2009
|
+
// never be cleaned up. Fix: delete the metadata instead of putting it.
|
|
2010
|
+
const modelName = `model${++dbCounter}`
|
|
2011
|
+
|
|
2012
|
+
const serverApp = expressX({})
|
|
2013
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
2014
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
2015
|
+
const port = serverApp.httpServer.address().port
|
|
2016
|
+
|
|
2017
|
+
function connectClient() {
|
|
2018
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
2019
|
+
const app = createClient(socket, { debug: false })
|
|
2020
|
+
offlinePlugin(app)
|
|
2021
|
+
socket.connect()
|
|
2022
|
+
return new Promise((resolve, reject) => {
|
|
2023
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
2024
|
+
socket.on('connect_error', reject)
|
|
2025
|
+
})
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
2029
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
2030
|
+
|
|
2031
|
+
// r1 exists in client B's Dexie
|
|
2032
|
+
await modelB.db.values.add({ uid: 'r1', label: 'test' })
|
|
2033
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
2034
|
+
|
|
2035
|
+
// Simulate step 2 running first (synchronize already cleaned up r1)
|
|
2036
|
+
await modelB.db.values.delete('r1')
|
|
2037
|
+
await modelB.db.metadata.delete('r1')
|
|
2038
|
+
|
|
2039
|
+
// Pub/sub arrives AFTER step 2 — handler must not re-create metadata as an orphan
|
|
2040
|
+
serverApp.io.to('all').emit('service-event', {
|
|
2041
|
+
name: modelName,
|
|
2042
|
+
action: 'deleteWithMeta',
|
|
2043
|
+
result: [{ uid: 'r1', label: 'test' }, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
2044
|
+
})
|
|
2045
|
+
|
|
2046
|
+
await new Promise(r => setTimeout(r, 200))
|
|
2047
|
+
|
|
2048
|
+
// With bug: put(meta) re-creates metadata with deleted_at → orphan persists
|
|
2049
|
+
// With fix: delete(uid) is a no-op → no orphan
|
|
2050
|
+
const orphan = await modelB.db.metadata.get('r1')
|
|
2051
|
+
assert.ok(!orphan, 'pub/sub handler must not leave an orphan metadata row after step-2 cleanup')
|
|
2052
|
+
|
|
2053
|
+
socketB.disconnect()
|
|
2054
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
2055
|
+
})
|
|
2056
|
+
|
|
2057
|
+
test('deleteWithMeta pub/sub handler tolerates undefined value (double-delete race)', async () => {
|
|
2058
|
+
// deleteWithMeta does `const [value] = DELETE ... RETURNING`. When the record
|
|
2059
|
+
// is already gone (double-delete race now possible with pub/sub enabled) the
|
|
2060
|
+
// RETURNING clause yields 0 rows → value = undefined. The server then broadcasts
|
|
2061
|
+
// [undefined, meta]. The handler crashes on `undefined.uid` (TypeError) before
|
|
2062
|
+
// db.metadata.put(meta) runs, leaving other tabs' caches inconsistent.
|
|
2063
|
+
const modelName = `model${++dbCounter}`
|
|
2064
|
+
|
|
2065
|
+
const serverApp = expressX({})
|
|
2066
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
2067
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
2068
|
+
const port = serverApp.httpServer.address().port
|
|
2069
|
+
|
|
2070
|
+
function connectClient() {
|
|
2071
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
2072
|
+
const app = createClient(socket, { debug: false })
|
|
2073
|
+
offlinePlugin(app)
|
|
2074
|
+
socket.connect()
|
|
2075
|
+
return new Promise((resolve, reject) => {
|
|
2076
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
2077
|
+
socket.on('connect_error', reject)
|
|
2078
|
+
})
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
2082
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
2083
|
+
|
|
2084
|
+
// Pre-populate so we can verify the metadata gets the server's deleted_at
|
|
2085
|
+
await modelB.db.values.add({ uid: 'r1', label: 'existing' })
|
|
2086
|
+
await modelB.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
2087
|
+
|
|
2088
|
+
// Simulate the server broadcasting deleteWithMeta with undefined value
|
|
2089
|
+
// (what happens when the DELETE RETURNING yields 0 rows)
|
|
2090
|
+
serverApp.io.to('all').emit('service-event', {
|
|
2091
|
+
name: modelName,
|
|
2092
|
+
action: 'deleteWithMeta',
|
|
2093
|
+
result: [undefined, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: T1 }],
|
|
2094
|
+
})
|
|
2095
|
+
|
|
2096
|
+
await new Promise(r => setTimeout(r, 200))
|
|
2097
|
+
|
|
2098
|
+
// With original bug: TypeError on undefined.uid aborts before db.metadata.*
|
|
2099
|
+
// runs at all → r1 metadata stays unchanged.
|
|
2100
|
+
// With delete fix: handler guards value, then deletes the metadata row
|
|
2101
|
+
// → r1 metadata is gone (no orphan, clean state).
|
|
2102
|
+
const meta = await modelB.db.metadata.get('r1')
|
|
2103
|
+
assert.ok(!meta, 'metadata must be removed even when value is undefined')
|
|
2104
|
+
|
|
2105
|
+
socketB.disconnect()
|
|
2106
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
2107
|
+
})
|
|
2108
|
+
|
|
2109
|
+
test('update() rollback only restores updated_at, not deleted_at set by a concurrent remove()', async () => {
|
|
2110
|
+
// The optimistic write in update() only touches updated_at. The rollback on
|
|
2111
|
+
// server rejection spreads the full previousMetadata snapshot — including
|
|
2112
|
+
// deleted_at: null — overwriting any deleted_at that remove() set while the
|
|
2113
|
+
// socket round-trip was in flight. Only updated_at should be restored.
|
|
2114
|
+
const modelName = `model${++dbCounter}`
|
|
2115
|
+
|
|
2116
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
2117
|
+
// updateWithMeta delays then rejects so remove() can run in the gap
|
|
2118
|
+
serverApp.createService(modelName, {
|
|
2119
|
+
updateWithMeta: async () => { await new Promise(r => setTimeout(r, 100)); throw new Error('rejected') },
|
|
2120
|
+
deleteWithMeta: async () => {},
|
|
2121
|
+
createWithMeta: async () => {},
|
|
2122
|
+
findMany: async () => [],
|
|
2123
|
+
})
|
|
2124
|
+
serverApp.createService('sync', {
|
|
2125
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
2126
|
+
})
|
|
2127
|
+
}, { useOfflinePlugin: true })
|
|
2128
|
+
|
|
2129
|
+
try {
|
|
2130
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2131
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
2132
|
+
// Explicitly store deleted_at: null (simulates what remove() rollback leaves
|
|
2133
|
+
// behind). Dexie stores the key so previousMetadata includes it, and the
|
|
2134
|
+
// rollback then spreads it back — wiping any concurrent remove()'s value.
|
|
2135
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, deleted_at: null })
|
|
2136
|
+
|
|
2137
|
+
// Fire update() — server will delay 100 ms then reject
|
|
2138
|
+
model.update('r1', { label: 'updated' })
|
|
2139
|
+
|
|
2140
|
+
// While updateWithMeta is in flight, remove() sets deleted_at
|
|
2141
|
+
await model.remove('r1')
|
|
2142
|
+
|
|
2143
|
+
// Wait for the rejection and rollback to complete
|
|
2144
|
+
await new Promise(r => setTimeout(r, 300))
|
|
2145
|
+
|
|
2146
|
+
// deleted_at must survive the update() rollback
|
|
2147
|
+
const meta = await model.db.metadata.get('r1')
|
|
2148
|
+
assert.ok(meta?.deleted_at, 'deleted_at set by remove() must not be cleared by update() rollback')
|
|
2149
|
+
} finally {
|
|
2150
|
+
await cleanup()
|
|
2151
|
+
}
|
|
2152
|
+
})
|
|
2153
|
+
|
|
2154
|
+
test('addClient uses put (upsert) so a concurrent create does not cause ConstraintError', async () => {
|
|
2155
|
+
// create() adds a uid to Dexie between synchronize()'s idbValues.filter snapshot
|
|
2156
|
+
// and the addClient step. Because the uid missed the snapshot it is absent from
|
|
2157
|
+
// clientMetadataDict, so the server puts it in addClient. idbValues.add() then
|
|
2158
|
+
// throws ConstraintError which aborts the entire transaction, silently dropping all
|
|
2159
|
+
// other addClient records in the same batch.
|
|
2160
|
+
// Fix: use idbValues.put() (upsert) so a pre-existing uid is handled gracefully.
|
|
2161
|
+
const modelName = `model${++dbCounter}`
|
|
2162
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2163
|
+
|
|
2164
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'server-r1' })
|
|
2165
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
2166
|
+
await db.insert(modelTable).values({ uid: 'r2', label: 'server-r2' })
|
|
2167
|
+
await db.insert(metaTable).values({ uid: 'r2', created_at: T0 })
|
|
2168
|
+
|
|
2169
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
2170
|
+
// Custom sync that always returns BOTH r1 and r2 in addClient,
|
|
2171
|
+
// regardless of what the client already has — simulating the race where
|
|
2172
|
+
// r1 was added to Dexie after the clientMetadataDict snapshot.
|
|
2173
|
+
serverApp.createService('sync', {
|
|
2174
|
+
go: async () => ({
|
|
2175
|
+
addClient: [
|
|
2176
|
+
[{ uid: 'r1', label: 'server-r1' }, { uid: 'r1', created_at: T0, updated_at: null, deleted_at: null }],
|
|
2177
|
+
[{ uid: 'r2', label: 'server-r2' }, { uid: 'r2', created_at: T0, updated_at: null, deleted_at: null }],
|
|
2178
|
+
],
|
|
2179
|
+
updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [],
|
|
2180
|
+
}),
|
|
2181
|
+
})
|
|
2182
|
+
}, { useOfflinePlugin: true })
|
|
2183
|
+
|
|
2184
|
+
try {
|
|
2185
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2186
|
+
|
|
2187
|
+
// r1 is already in Dexie (simulates what create() does concurrently)
|
|
2188
|
+
await model.db.values.add({ uid: 'r1', label: 'client-r1' })
|
|
2189
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
2190
|
+
|
|
2191
|
+
await model.addSynchroWhere({})
|
|
2192
|
+
await model.synchronizeAll()
|
|
2193
|
+
|
|
2194
|
+
// r2 must still be in Dexie even though r1 caused a uid conflict —
|
|
2195
|
+
// the put() upsert must not abort the whole transaction
|
|
2196
|
+
const r2 = await model.db.values.get('r2')
|
|
2197
|
+
assert.ok(r2, 'r2 must be added despite r1 already existing in Dexie')
|
|
2198
|
+
assert.equal(r2.label, 'server-r2')
|
|
2199
|
+
} finally {
|
|
2200
|
+
await cleanup()
|
|
2201
|
+
pglite.close()
|
|
2202
|
+
}
|
|
2203
|
+
})
|
|
2204
|
+
|
|
2205
|
+
test('missing server metadata does not cause infinite updateClient loop', async () => {
|
|
2206
|
+
// When a record exists in the model table but has no metadata row,
|
|
2207
|
+
// computeSyncResult falls back to { uid, created_at: new Date() }.
|
|
2208
|
+
// new Date() is always "now", so databaseUpdatedAt > clientUpdatedAt forever →
|
|
2209
|
+
// updateClient fires on every sync even though nothing changed.
|
|
2210
|
+
// Fix: use null instead of new Date() so the client's version "wins" once
|
|
2211
|
+
// (updateDatabase), the server gets metadata, and subsequent syncs see diff=0.
|
|
2212
|
+
const modelName = `model${++dbCounter}`
|
|
2213
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2214
|
+
|
|
2215
|
+
// Server has s1 in model table but intentionally NO metadata row
|
|
2216
|
+
await db.insert(modelTable).values({ uid: 's1', label: 'server-label' })
|
|
2217
|
+
|
|
2218
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2219
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2220
|
+
{ useOfflinePlugin: true },
|
|
2221
|
+
)
|
|
2222
|
+
|
|
2223
|
+
try {
|
|
2224
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2225
|
+
// Client has s1 with its own timestamp
|
|
2226
|
+
await model.db.values.add({ uid: 's1', label: 'client-label' })
|
|
2227
|
+
await model.db.metadata.add({ uid: 's1', created_at: T0 })
|
|
2228
|
+
await model.addSynchroWhere({})
|
|
2229
|
+
|
|
2230
|
+
// First sync
|
|
2231
|
+
await model.synchronizeAll()
|
|
2232
|
+
|
|
2233
|
+
// Second sync — with the bug, updateClient fires again (infinite loop);
|
|
2234
|
+
// with the fix the second sync is a no-op (client pushed its data, diff=0)
|
|
2235
|
+
let secondSyncUpdateClientCount = 0
|
|
2236
|
+
const origUpdateWithMeta = db // just a marker — we measure via server state
|
|
2237
|
+
|
|
2238
|
+
await model.synchronizeAll()
|
|
2239
|
+
|
|
2240
|
+
// After two syncs the server metadata must now exist (created by updateWithMeta
|
|
2241
|
+
// on the first sync) so a third sync should be a true no-op
|
|
2242
|
+
const metaRows = await db.select().from(metaTable)
|
|
2243
|
+
const s1Meta = metaRows.find(r => r.uid === 's1')
|
|
2244
|
+
assert.ok(s1Meta, 'server metadata must be created after the first sync (updateDatabase + upsert)')
|
|
2245
|
+
assert.ok(
|
|
2246
|
+
s1Meta.created_at || s1Meta.updated_at || s1Meta.deleted_at,
|
|
2247
|
+
'repaired server metadata must contain a usable timestamp',
|
|
2248
|
+
)
|
|
2249
|
+
assert.equal((await model.db.metadata.get('s1')).__dirty__, false, 'repaired local metadata should be clean')
|
|
2250
|
+
} finally {
|
|
2251
|
+
await cleanup()
|
|
2252
|
+
pglite.close()
|
|
2253
|
+
}
|
|
2254
|
+
})
|
|
2255
|
+
|
|
2256
|
+
test('server-only row with missing metadata is repaired and pulled into client cache', async () => {
|
|
2257
|
+
const modelName = `model${++dbCounter}`
|
|
2258
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2259
|
+
|
|
2260
|
+
await db.insert(modelTable).values({ uid: 'server-only', label: 'server-value' })
|
|
2261
|
+
|
|
2262
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2263
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2264
|
+
{ useOfflinePlugin: true },
|
|
2265
|
+
)
|
|
2266
|
+
|
|
2267
|
+
try {
|
|
2268
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2269
|
+
await model.addSynchroWhere({})
|
|
2270
|
+
await model.synchronizeAll()
|
|
2271
|
+
|
|
2272
|
+
const localValue = await model.db.values.get('server-only')
|
|
2273
|
+
assert.equal(localValue?.label, 'server-value')
|
|
2274
|
+
|
|
2275
|
+
const localMeta = await model.db.metadata.get('server-only')
|
|
2276
|
+
assert.ok(localMeta?.created_at, 'client should receive repaired server metadata with a timestamp')
|
|
2277
|
+
assert.equal(localMeta.__dirty__, false)
|
|
2278
|
+
|
|
2279
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'server-only')))[0]
|
|
2280
|
+
assert.ok(serverMeta?.created_at, 'server should persist repaired metadata')
|
|
2281
|
+
} finally {
|
|
2282
|
+
await cleanup()
|
|
2283
|
+
pglite.close()
|
|
2284
|
+
}
|
|
2285
|
+
})
|
|
2286
|
+
|
|
2287
|
+
test('remove() creates tombstone metadata when local metadata is missing', async () => {
|
|
2288
|
+
const modelName = `model${++dbCounter}`
|
|
2289
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2290
|
+
|
|
2291
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2292
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2293
|
+
{ useOfflinePlugin: true },
|
|
2294
|
+
)
|
|
2295
|
+
|
|
2296
|
+
try {
|
|
2297
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2298
|
+
await model.db.values.add({ uid: 'r1', label: 'orphan-local-value' })
|
|
2299
|
+
|
|
2300
|
+
await model.remove('r1')
|
|
2301
|
+
|
|
2302
|
+
let localMeta = await model.db.metadata.get('r1')
|
|
2303
|
+
assert.ok(localMeta?.deleted_at, 'remove() must create a dirty tombstone when metadata is missing')
|
|
2304
|
+
assert.equal(localMeta.__dirty__, true)
|
|
2305
|
+
|
|
2306
|
+
await model.addSynchroWhere({})
|
|
2307
|
+
await model.synchronizeAll()
|
|
2308
|
+
|
|
2309
|
+
assert.ok(!await model.db.values.get('r1'), 'sync should delete the locally removed orphan value')
|
|
2310
|
+
localMeta = await model.db.metadata.get('r1')
|
|
2311
|
+
assert.ok(!localMeta, 'sync should clear tombstone metadata after acknowledging local-only delete')
|
|
2312
|
+
|
|
2313
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
2314
|
+
assert.equal(serverRows.length, 0, 'local orphan delete must not create a server row')
|
|
2315
|
+
} finally {
|
|
2316
|
+
await cleanup()
|
|
2317
|
+
pglite.close()
|
|
2318
|
+
}
|
|
2319
|
+
})
|
|
2320
|
+
|
|
2321
|
+
test('update() creates dirty metadata when local metadata is missing', async () => {
|
|
2322
|
+
const modelName = `model${++dbCounter}`
|
|
2323
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2324
|
+
|
|
2325
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2326
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2327
|
+
{ useOfflinePlugin: true },
|
|
2328
|
+
)
|
|
2329
|
+
|
|
2330
|
+
try {
|
|
2331
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2332
|
+
await model.db.values.add({ uid: 'r1', label: 'orphan-local-value' })
|
|
2333
|
+
|
|
2334
|
+
await model.update('r1', { label: 'client-new' })
|
|
2335
|
+
|
|
2336
|
+
let localMeta = await model.db.metadata.get('r1')
|
|
2337
|
+
assert.ok(localMeta?.updated_at, 'update() must create dirty metadata when metadata is missing')
|
|
2338
|
+
assert.equal(localMeta.__dirty__, true)
|
|
2339
|
+
|
|
2340
|
+
await model.addSynchroWhere({})
|
|
2341
|
+
await model.synchronizeAll()
|
|
2342
|
+
|
|
2343
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
2344
|
+
assert.equal(serverRows[0]?.label, 'client-new', 'sync should push updated orphan value to server')
|
|
2345
|
+
localMeta = await model.db.metadata.get('r1')
|
|
2346
|
+
assert.equal(localMeta.__dirty__, false, 'sync should mark pushed update clean')
|
|
2347
|
+
} finally {
|
|
2348
|
+
await cleanup()
|
|
2349
|
+
pglite.close()
|
|
2350
|
+
}
|
|
2351
|
+
})
|
|
2352
|
+
|
|
2353
|
+
test('sync repairs visible local value with missing metadata and pushes it to server', async () => {
|
|
2354
|
+
const modelName = `model${++dbCounter}`
|
|
2355
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2356
|
+
|
|
2357
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2358
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2359
|
+
{ useOfflinePlugin: true },
|
|
2360
|
+
)
|
|
2361
|
+
|
|
2362
|
+
try {
|
|
2363
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2364
|
+
await model.db.values.add({ uid: 'r1', label: 'orphan-local-value' })
|
|
2365
|
+
await model.addSynchroWhere({})
|
|
2366
|
+
|
|
2367
|
+
await model.synchronizeAll()
|
|
2368
|
+
|
|
2369
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
2370
|
+
assert.equal(serverRows[0]?.label, 'orphan-local-value', 'sync should push visible local value even when metadata was missing')
|
|
2371
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
2372
|
+
assert.ok(serverMeta?.created_at, 'sync should create server metadata for repaired local value')
|
|
2373
|
+
const localMeta = await model.db.metadata.get('r1')
|
|
2374
|
+
assert.ok(localMeta?.created_at, 'sync should repair local metadata')
|
|
2375
|
+
assert.equal(localMeta.__dirty__, false, 'repaired local metadata should be clean after server acknowledgement')
|
|
2376
|
+
} finally {
|
|
2377
|
+
await cleanup()
|
|
2378
|
+
pglite.close()
|
|
2379
|
+
}
|
|
2380
|
+
})
|
|
2381
|
+
|
|
2382
|
+
test('local value with missing metadata does not resurrect a server tombstone', async () => {
|
|
2383
|
+
const modelName = `model${++dbCounter}`
|
|
2384
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2385
|
+
|
|
2386
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0, deleted_at: T1 })
|
|
2387
|
+
|
|
2388
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2389
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2390
|
+
{ useOfflinePlugin: true },
|
|
2391
|
+
)
|
|
2392
|
+
|
|
2393
|
+
try {
|
|
2394
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2395
|
+
await model.db.values.add({ uid: 'r1', label: 'stale-local-value' })
|
|
2396
|
+
await model.addSynchroWhere({})
|
|
2397
|
+
|
|
2398
|
+
await model.synchronizeAll()
|
|
2399
|
+
|
|
2400
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
2401
|
+
assert.equal(serverRows.length, 0, 'metadata repair must not resurrect a server-deleted row')
|
|
2402
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'r1')))[0]
|
|
2403
|
+
assert.equal(new Date(serverMeta.deleted_at).getTime(), T1.getTime())
|
|
2404
|
+
assert.ok(!await model.db.values.get('r1'), 'stale local value should be removed by server tombstone')
|
|
2405
|
+
assert.ok(!await model.db.metadata.get('r1'), 'repaired local metadata should be removed after tombstone wins')
|
|
2406
|
+
} finally {
|
|
2407
|
+
await cleanup()
|
|
2408
|
+
pglite.close()
|
|
2409
|
+
}
|
|
2410
|
+
})
|
|
2411
|
+
|
|
2412
|
+
test('update() does not create orphan metadata when local value is missing', async () => {
|
|
2413
|
+
const modelName = `model${++dbCounter}`
|
|
2414
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2415
|
+
|
|
2416
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2417
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2418
|
+
{ useOfflinePlugin: true },
|
|
2419
|
+
)
|
|
2420
|
+
|
|
2421
|
+
try {
|
|
2422
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2423
|
+
|
|
2424
|
+
const result = await model.update('missing', { label: 'client-new' })
|
|
2425
|
+
|
|
2426
|
+
assert.equal(result, undefined, 'update() should no-op when the local value is missing')
|
|
2427
|
+
assert.ok(!await model.db.metadata.get('missing'), 'update() must not create dirty metadata without a value')
|
|
2428
|
+
|
|
2429
|
+
await model.addSynchroWhere({})
|
|
2430
|
+
await model.synchronizeAll()
|
|
2431
|
+
|
|
2432
|
+
assert.ok(!await model.db.metadata.get('missing'), 'sync should not leave orphan metadata for missing update')
|
|
2433
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'missing'))
|
|
2434
|
+
assert.equal(serverRows.length, 0, 'missing local update must not create a server row')
|
|
2435
|
+
} finally {
|
|
2436
|
+
await cleanup()
|
|
2437
|
+
pglite.close()
|
|
2438
|
+
}
|
|
2439
|
+
})
|
|
2440
|
+
|
|
2441
|
+
test('remove() does not create tombstone metadata when local value is missing', async () => {
|
|
2442
|
+
const modelName = `model${++dbCounter}`
|
|
2443
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2444
|
+
|
|
2445
|
+
await db.insert(modelTable).values({ uid: 'missing', label: 'server-row' })
|
|
2446
|
+
await db.insert(metaTable).values({ uid: 'missing', created_at: T0 })
|
|
2447
|
+
|
|
2448
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2449
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2450
|
+
{ useOfflinePlugin: true },
|
|
2451
|
+
)
|
|
2452
|
+
|
|
2453
|
+
try {
|
|
2454
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2455
|
+
|
|
2456
|
+
const result = await model.remove('missing')
|
|
2457
|
+
|
|
2458
|
+
assert.equal(result, undefined, 'remove() should no-op when the local value is missing')
|
|
2459
|
+
assert.ok(!await model.db.metadata.get('missing'), 'remove() must not create dirty tombstone metadata without a value')
|
|
2460
|
+
|
|
2461
|
+
await model.addSynchroWhere({})
|
|
2462
|
+
await model.synchronizeAll()
|
|
2463
|
+
|
|
2464
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'missing'))
|
|
2465
|
+
assert.equal(serverRows[0]?.label, 'server-row', 'missing local remove must not delete the server row')
|
|
2466
|
+
|
|
2467
|
+
const localValue = await model.db.values.get('missing')
|
|
2468
|
+
assert.equal(localValue?.label, 'server-row', 'sync should pull the still-live server row')
|
|
2469
|
+
} finally {
|
|
2470
|
+
await cleanup()
|
|
2471
|
+
pglite.close()
|
|
2472
|
+
}
|
|
2473
|
+
})
|
|
2474
|
+
|
|
2475
|
+
test('app-event for unregistered type is silently ignored, not TypeError', async () => {
|
|
2476
|
+
// When an app-event arrives for a type with no registered handler,
|
|
2477
|
+
// type2appHandler[type] is undefined. The guard
|
|
2478
|
+
// if (!type2appHandler[type]) type2appHandler[type] = {}
|
|
2479
|
+
// sets it to an empty object {}. {} is truthy, so the next line
|
|
2480
|
+
// if (handler) handler(value)
|
|
2481
|
+
// tries to call {} as a function → TypeError: {} is not a function.
|
|
2482
|
+
const received = []
|
|
2483
|
+
|
|
2484
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
2485
|
+
serverApp.addConnectListener(socket => {
|
|
2486
|
+
// Delay so the test code below can register handlers before the events fire
|
|
2487
|
+
setTimeout(() => {
|
|
2488
|
+
// First: an event for a type WITH no registered handler (triggers the bug)
|
|
2489
|
+
serverApp.sendAppEvent(socket.id, 'unregistered-type', 'ignored')
|
|
2490
|
+
// Second: an event for a type WITH a registered handler (must still work)
|
|
2491
|
+
serverApp.sendAppEvent(socket.id, 'registered-type', 'hello')
|
|
2492
|
+
}, 100)
|
|
2493
|
+
})
|
|
2494
|
+
})
|
|
2495
|
+
|
|
2496
|
+
try {
|
|
2497
|
+
clientApp.on('registered-type', val => received.push(val))
|
|
2498
|
+
await new Promise(r => setTimeout(r, 400))
|
|
2499
|
+
assert.deepEqual(received, ['hello'],
|
|
2500
|
+
'registered handler must be called even after an unregistered-type event')
|
|
2501
|
+
} finally {
|
|
2502
|
+
await cleanup()
|
|
2503
|
+
}
|
|
2504
|
+
})
|
|
2505
|
+
|
|
2506
|
+
test('removeDisconnectListener is callable and removes the listener', async () => {
|
|
2507
|
+
// removeDisonnectListener (misspelled — missing 'c') was exported on the app object
|
|
2508
|
+
// instead of removeDisconnectListener. app.removeDisconnectListener is therefore
|
|
2509
|
+
// undefined, so any call to it throws TypeError: not a function.
|
|
2510
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
2511
|
+
serverApp.createService('greet', { hello: async (name) => `Hello, ${name}!` })
|
|
2512
|
+
})
|
|
2513
|
+
|
|
2514
|
+
try {
|
|
2515
|
+
let callCount = 0
|
|
2516
|
+
const listener = () => { callCount++ }
|
|
2517
|
+
|
|
2518
|
+
clientApp.addDisconnectListener(listener)
|
|
2519
|
+
|
|
2520
|
+
// Must not throw — the correctly-spelled method must exist
|
|
2521
|
+
assert.ok(
|
|
2522
|
+
typeof clientApp.removeDisconnectListener === 'function',
|
|
2523
|
+
'removeDisconnectListener must be a function on the app object',
|
|
2524
|
+
)
|
|
2525
|
+
|
|
2526
|
+
clientApp.removeDisconnectListener(listener)
|
|
2527
|
+
|
|
2528
|
+
// After removal the listener must no longer be registered
|
|
2529
|
+
// (we can inspect indirectly: add a fresh listener, but the removed one
|
|
2530
|
+
// should not fire — verified by callCount staying at 0)
|
|
2531
|
+
assert.equal(callCount, 0, 'removed listener must not have been called')
|
|
2532
|
+
} finally {
|
|
2533
|
+
await cleanup()
|
|
2534
|
+
}
|
|
2535
|
+
})
|
|
2536
|
+
|
|
2537
|
+
test('addDatabase TypeError on missing fullValue does not abort remaining entries', async () => {
|
|
2538
|
+
// delete fullValue.uid and delete fullValue.__deleted__ are OUTSIDE the inner
|
|
2539
|
+
// try/catch in steps 4 and 5 of synchronize(). If idbValues.get(elt.uid)
|
|
2540
|
+
// returns undefined — because elt.uid is itself undefined (the clientMetadataDict
|
|
2541
|
+
// fallback {} has no uid property when metadata is missing) or because the record
|
|
2542
|
+
// was deleted from Dexie concurrently — the `delete undefined.uid` throws a
|
|
2543
|
+
// TypeError that escapes to the outer try/catch, aborting the entire sync and
|
|
2544
|
+
// silently skipping every remaining addDatabase / updateDatabase entry.
|
|
2545
|
+
const modelName = `model${++dbCounter}`
|
|
2546
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2547
|
+
|
|
2548
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2549
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2550
|
+
{ useOfflinePlugin: true },
|
|
2551
|
+
)
|
|
2552
|
+
|
|
2553
|
+
try {
|
|
2554
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2555
|
+
|
|
2556
|
+
// a1: value exists in Dexie but metadata is MISSING → synchronize builds
|
|
2557
|
+
// clientMetadataDict['a1'] = {} (the "should not happen" fallback)
|
|
2558
|
+
// → computeSyncResult puts {} into addDatabase (elt.uid = undefined)
|
|
2559
|
+
// → delete undefined.uid throws → outer catch → sync aborts
|
|
2560
|
+
await model.db.values.add({ uid: 'a1', label: 'no-meta' })
|
|
2561
|
+
// a2: normal record with both value and metadata
|
|
2562
|
+
await model.db.values.add({ uid: 'a2', label: 'normal' })
|
|
2563
|
+
await model.db.metadata.add({ uid: 'a2', created_at: T0 })
|
|
2564
|
+
|
|
2565
|
+
await model.addSynchroWhere({})
|
|
2566
|
+
await model.synchronizeAll()
|
|
2567
|
+
|
|
2568
|
+
// a2 must be pushed despite the corrupted a1 entry aborting processing for it
|
|
2569
|
+
const rows = await db.select().from(modelTable)
|
|
2570
|
+
assert.ok(rows.find(r => r.uid === 'a2'),
|
|
2571
|
+
'a2 must be pushed to the server even though a1 has missing metadata')
|
|
2572
|
+
} finally {
|
|
2573
|
+
await cleanup()
|
|
2574
|
+
pglite.close()
|
|
2575
|
+
}
|
|
2576
|
+
})
|
|
2577
|
+
|
|
2578
|
+
test('wherePredicate range check excludes records whose field is null', async () => {
|
|
2579
|
+
// The previous fix excluded undefined (NaN comparisons) but missed null.
|
|
2580
|
+
// JS coerces null → 0 for numeric comparisons, so for { score: { lte: 10 } }:
|
|
2581
|
+
// null > 10 → 0 > 10 → false → the lte guard doesn't fire
|
|
2582
|
+
// → null-score record passes the filter (wrong; SQL NULL never matches ranges).
|
|
2583
|
+
// That record enters clientMetadataDict, appears as addDatabase to the server,
|
|
2584
|
+
// and if score is NOT NULL the insert fails, rolling back the Dexie record.
|
|
2585
|
+
const modelName = `model${++dbCounter}`
|
|
2586
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2587
|
+
|
|
2588
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2589
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2590
|
+
{ useOfflinePlugin: true },
|
|
2591
|
+
)
|
|
2592
|
+
|
|
2593
|
+
try {
|
|
2594
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2595
|
+
|
|
2596
|
+
// Add directly to Dexie to simulate records with various score values
|
|
2597
|
+
await model.db.values.add({ uid: 'null-score', label: 'null', score: null })
|
|
2598
|
+
await model.db.values.add({ uid: 'five-score', label: 'five', score: 5 })
|
|
2599
|
+
|
|
2600
|
+
// score=null should NOT match { lte: 10 } even though (null coerced to 0) <= 10
|
|
2601
|
+
const results = await model.findWhere({ score: { lte: 10 } })
|
|
2602
|
+
const uids = results.map(r => r.uid)
|
|
2603
|
+
|
|
2604
|
+
assert.ok(!uids.includes('null-score'), 'null score must NOT match { lte: 10 } (SQL NULL behaviour)')
|
|
2605
|
+
assert.ok( uids.includes('five-score'), 'score=5 must match { lte: 10 }')
|
|
2606
|
+
} finally {
|
|
2607
|
+
await cleanup()
|
|
2608
|
+
pglite.close()
|
|
2609
|
+
}
|
|
2610
|
+
})
|
|
2611
|
+
|
|
2612
|
+
test('wherePredicate range check excludes records whose field is absent (undefined)', async () => {
|
|
2613
|
+
// Comparing undefined with a number via >, <, >=, <= always returns false in JS
|
|
2614
|
+
// (undefined coerces to NaN, and NaN comparisons are always false). So none of
|
|
2615
|
+
// the range return-false guards fire and a record WITHOUT the filtered field
|
|
2616
|
+
// incorrectly passes { score: { gte: 1 } }. In PostgreSQL, NULL values are
|
|
2617
|
+
// correctly excluded by range operators, creating a client/server mismatch:
|
|
2618
|
+
// the client includes the record in clientMetadataDict → addDatabase → if the
|
|
2619
|
+
// column is NOT NULL the insert fails → rollback deletes the record from Dexie.
|
|
2620
|
+
const modelName = `model${++dbCounter}`
|
|
2621
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2622
|
+
|
|
2623
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2624
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2625
|
+
{ useOfflinePlugin: true },
|
|
2626
|
+
)
|
|
2627
|
+
|
|
2628
|
+
try {
|
|
2629
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2630
|
+
|
|
2631
|
+
// r1 has no 'score' field; r2 has score=5
|
|
2632
|
+
await model.db.values.add({ uid: 'r1', label: 'no-score' })
|
|
2633
|
+
await model.db.values.add({ uid: 'r2', label: 'has-score', score: 5 })
|
|
2634
|
+
|
|
2635
|
+
const results = await model.findWhere({ score: { gte: 1 } })
|
|
2636
|
+
const uids = results.map(r => r.uid)
|
|
2637
|
+
|
|
2638
|
+
assert.ok(!uids.includes('r1'), 'record without score field must NOT match { gte: 1 }')
|
|
2639
|
+
assert.ok( uids.includes('r2'), 'record with score=5 must match { gte: 1 }')
|
|
2640
|
+
} finally {
|
|
2641
|
+
await cleanup()
|
|
2642
|
+
pglite.close()
|
|
2643
|
+
}
|
|
2644
|
+
})
|
|
2645
|
+
|
|
2646
|
+
test('addSynchroWhere with range-operator where does not log ConstraintError when already covered', async () => {
|
|
2647
|
+
// isSubset uses !== (reference equality) for all values. For primitive where
|
|
2648
|
+
// values (strings, numbers) this is fine. For object values like { gte: 1 },
|
|
2649
|
+
// two structurally identical objects from different call sites have different
|
|
2650
|
+
// references, so isSubset returns false even though the where is already in the
|
|
2651
|
+
// list. addSynchroDBWhere then calls whereDb.add on an already-existing key →
|
|
2652
|
+
// ConstraintError → caught and logged. modified=false for the wrong reason.
|
|
2653
|
+
// In a real browser with persistent IndexedDB the error fires on every page load.
|
|
2654
|
+
const modelName = `model${++dbCounter}`
|
|
2655
|
+
const pglite = new PGlite()
|
|
2656
|
+
await pglite.exec(`
|
|
2657
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
2658
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, score INTEGER NOT NULL);
|
|
2659
|
+
`)
|
|
2660
|
+
const db = drizzle(pglite)
|
|
2661
|
+
const metaTable = pgTable('metadata', { uid: text('uid').primaryKey(), created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp() })
|
|
2662
|
+
const modelTable = pgTable(modelName, { uid: text('uid').primaryKey(), label: text('label').notNull(), score: integer('score').notNull() })
|
|
2663
|
+
|
|
2664
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2665
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2666
|
+
{ useOfflinePlugin: true },
|
|
2667
|
+
)
|
|
2668
|
+
|
|
2669
|
+
try {
|
|
2670
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2671
|
+
|
|
2672
|
+
// Intercept console.log to detect spurious ConstraintError logs
|
|
2673
|
+
const errorLogs = []
|
|
2674
|
+
const origLog = console.log
|
|
2675
|
+
console.log = (...args) => {
|
|
2676
|
+
if (typeof args[0] === 'string' && args[0].startsWith('err addSynchroDBWhere')) errorLogs.push(args)
|
|
2677
|
+
origLog(...args)
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
try {
|
|
2681
|
+
await model.addSynchroWhere({ score: { gte: 1 } }) // first call: adds to list
|
|
2682
|
+
await model.addSynchroWhere({ score: { gte: 1 } }) // second call: should be detected as already covered
|
|
2683
|
+
assert.equal(errorLogs.length, 0, 'adding a where already in the list must not throw a ConstraintError')
|
|
2684
|
+
} finally {
|
|
2685
|
+
console.log = origLog
|
|
2686
|
+
}
|
|
2687
|
+
} finally {
|
|
2688
|
+
await cleanup()
|
|
2689
|
+
pglite.close()
|
|
2690
|
+
}
|
|
2691
|
+
})
|
|
2692
|
+
|
|
2693
|
+
test('compound range { gte, lte } filters consistently on both client and server', async () => {
|
|
2694
|
+
// wherePredicate applies `lte` first (else-if chain); whereToDrizzleFilters applies
|
|
2695
|
+
// `gte` first (return on first match). For { gte:1, lte:10 } this means:
|
|
2696
|
+
// client includes score=0 (only lte:10 checked → 0≤10 passes)
|
|
2697
|
+
// server includes score=15 (only gte:1 checked → 15≥1 passes)
|
|
2698
|
+
// score=0 → addDatabase → pushed to server (wrong, outside range)
|
|
2699
|
+
// score=15 → addClient → added to client (wrong, outside range)
|
|
2700
|
+
const modelName = `model${++dbCounter}`
|
|
2701
|
+
const pglite = new PGlite()
|
|
2702
|
+
await pglite.exec(`
|
|
2703
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
2704
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, score INTEGER NOT NULL);
|
|
2705
|
+
`)
|
|
2706
|
+
const db = drizzle(pglite)
|
|
2707
|
+
const metaTable = pgTable('metadata', {
|
|
2708
|
+
uid: text('uid').primaryKey(),
|
|
2709
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
2710
|
+
})
|
|
2711
|
+
const modelTable = pgTable(modelName, {
|
|
2712
|
+
uid: text('uid').primaryKey(),
|
|
2713
|
+
label: text('label').notNull(),
|
|
2714
|
+
score: integer('score').notNull(),
|
|
2715
|
+
})
|
|
2716
|
+
|
|
2717
|
+
// Server has records below, inside, and above the range 1–10
|
|
2718
|
+
await db.insert(modelTable).values([
|
|
2719
|
+
{ uid: 'lo', label: 'low', score: 0 },
|
|
2720
|
+
{ uid: 'mid', label: 'middle', score: 5 },
|
|
2721
|
+
{ uid: 'hi', label: 'high', score: 15 },
|
|
2722
|
+
])
|
|
2723
|
+
await db.insert(metaTable).values([
|
|
2724
|
+
{ uid: 'lo', created_at: T0 },
|
|
2725
|
+
{ uid: 'mid', created_at: T0 },
|
|
2726
|
+
{ uid: 'hi', created_at: T0 },
|
|
2727
|
+
])
|
|
2728
|
+
|
|
2729
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2730
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2731
|
+
{ useOfflinePlugin: true },
|
|
2732
|
+
)
|
|
2733
|
+
|
|
2734
|
+
try {
|
|
2735
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2736
|
+
await model.addSynchroWhere({ score: { gte: 1, lte: 10 } })
|
|
2737
|
+
await model.synchronizeAll()
|
|
2738
|
+
|
|
2739
|
+
const vals = await model.db.values.toArray()
|
|
2740
|
+
const uids = vals.map(v => v.uid)
|
|
2741
|
+
|
|
2742
|
+
assert.ok(!uids.includes('lo'), 'score=0 is below range and must NOT be in Dexie')
|
|
2743
|
+
assert.ok( uids.includes('mid'), 'score=5 is in range and must be in Dexie')
|
|
2744
|
+
assert.ok(!uids.includes('hi'), 'score=15 is above range and must NOT be in Dexie')
|
|
2745
|
+
} finally {
|
|
2746
|
+
await cleanup()
|
|
2747
|
+
pglite.close()
|
|
2748
|
+
}
|
|
2749
|
+
})
|
|
2750
|
+
|
|
2751
|
+
test('whereToDrizzleFilters supports range operators used in addSynchroWhere', async () => {
|
|
2752
|
+
// wherePredicate (client) handles { score: { gte: 5 } } correctly after earlier fixes,
|
|
2753
|
+
// but whereToDrizzleFilters (server) only calls eq() for every value.
|
|
2754
|
+
// Passing an object like { gte: 5 } to eq() makes Drizzle try to bind a plain
|
|
2755
|
+
// object as a PostgreSQL parameter → query throws → sync.go catch swallows the error
|
|
2756
|
+
// and returns undefined → client gets a TypeError → sync silently fails.
|
|
2757
|
+
const modelName = `model${++dbCounter}`
|
|
2758
|
+
const pglite = new PGlite()
|
|
2759
|
+
await pglite.exec(`
|
|
2760
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
2761
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, score INTEGER NOT NULL);
|
|
2762
|
+
`)
|
|
2763
|
+
const db = drizzle(pglite)
|
|
2764
|
+
const metaTable = pgTable('metadata', {
|
|
2765
|
+
uid: text('uid').primaryKey(),
|
|
2766
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
2767
|
+
})
|
|
2768
|
+
const modelTable = pgTable(modelName, {
|
|
2769
|
+
uid: text('uid').primaryKey(),
|
|
2770
|
+
label: text('label').notNull(),
|
|
2771
|
+
score: integer('score').notNull(),
|
|
2772
|
+
})
|
|
2773
|
+
|
|
2774
|
+
await db.insert(modelTable).values([
|
|
2775
|
+
{ uid: 'lo', label: 'low', score: 3 },
|
|
2776
|
+
{ uid: 'hi', label: 'high', score: 7 },
|
|
2777
|
+
])
|
|
2778
|
+
await db.insert(metaTable).values([
|
|
2779
|
+
{ uid: 'lo', created_at: T0 },
|
|
2780
|
+
{ uid: 'hi', created_at: T0 },
|
|
2781
|
+
])
|
|
2782
|
+
|
|
2783
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2784
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2785
|
+
{ useOfflinePlugin: true },
|
|
2786
|
+
)
|
|
2787
|
+
|
|
2788
|
+
try {
|
|
2789
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2790
|
+
await model.addSynchroWhere({ score: { gte: 5 } })
|
|
2791
|
+
await model.synchronizeAll()
|
|
2792
|
+
|
|
2793
|
+
const vals = await model.db.values.toArray()
|
|
2794
|
+
const uids = vals.map(v => v.uid)
|
|
2795
|
+
assert.ok(!uids.includes('lo'), 'score=3 should NOT be pulled (< 5)')
|
|
2796
|
+
assert.ok( uids.includes('hi'), 'score=7 should be pulled (≥ 5)')
|
|
2797
|
+
} finally {
|
|
2798
|
+
await cleanup()
|
|
2799
|
+
pglite.close()
|
|
2800
|
+
}
|
|
2801
|
+
})
|
|
2802
|
+
|
|
2803
|
+
test('sync.go serializes overlapping where scopes on the server', async () => {
|
|
2804
|
+
const modelName = `model${++dbCounter}`
|
|
2805
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2806
|
+
|
|
2807
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'x' })
|
|
2808
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
2809
|
+
|
|
2810
|
+
const serverApp = expressX({})
|
|
2811
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
2812
|
+
|
|
2813
|
+
const modelService = serverApp.service(modelName)
|
|
2814
|
+
const originalFindMany = modelService.__findMany
|
|
2815
|
+
let releaseBroadFindMany
|
|
2816
|
+
let broadFindManyEntered
|
|
2817
|
+
let narrowFindManyEntered = false
|
|
2818
|
+
const broadFindManyStarted = new Promise(resolve => { broadFindManyEntered = resolve })
|
|
2819
|
+
const broadFindManyRelease = new Promise(resolve => { releaseBroadFindMany = resolve })
|
|
2820
|
+
|
|
2821
|
+
modelService.__findMany = async (context, where) => {
|
|
2822
|
+
if (Object.keys(where).length === 0) {
|
|
2823
|
+
broadFindManyEntered()
|
|
2824
|
+
await broadFindManyRelease
|
|
2825
|
+
} else {
|
|
2826
|
+
narrowFindManyEntered = true
|
|
2827
|
+
}
|
|
2828
|
+
return originalFindMany(context, where)
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
try {
|
|
2832
|
+
const broadSync = serverApp.service('sync').go(modelName, {}, {})
|
|
2833
|
+
await broadFindManyStarted
|
|
2834
|
+
|
|
2835
|
+
const narrowSync = serverApp.service('sync').go(modelName, { label: 'x' }, {})
|
|
2836
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
2837
|
+
assert.equal(narrowFindManyEntered, false, 'narrow overlapping sync must wait for broad sync')
|
|
2838
|
+
|
|
2839
|
+
releaseBroadFindMany()
|
|
2840
|
+
await Promise.all([broadSync, narrowSync])
|
|
2841
|
+
assert.equal(narrowFindManyEntered, true, 'narrow sync should run after broad sync releases')
|
|
2842
|
+
} finally {
|
|
2843
|
+
pglite.close()
|
|
2844
|
+
}
|
|
2845
|
+
})
|
|
2846
|
+
|
|
2847
|
+
test('getObservable syncs persisted where scopes on a new client session', async () => {
|
|
2848
|
+
// If whereList was left in IndexedDB by a previous run, addSynchroDBWhere()
|
|
2849
|
+
// returns false. getObservable() must still sync once for this app session;
|
|
2850
|
+
// otherwise the first emission can be stale forever until a manual sync/reconnect.
|
|
2851
|
+
const modelName = `model${++dbCounter}`
|
|
2852
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2853
|
+
|
|
2854
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
|
|
2855
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
2856
|
+
|
|
2857
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2858
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2859
|
+
{ useOfflinePlugin: true },
|
|
2860
|
+
)
|
|
2861
|
+
|
|
2862
|
+
try {
|
|
2863
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2864
|
+
await model.db.whereList.add({ sortedjson: '{}' })
|
|
2865
|
+
|
|
2866
|
+
const rows = await firstValueFrom(model.getObservable({}))
|
|
2867
|
+
|
|
2868
|
+
assert.deepEqual(rows.map(row => row.uid), ['r1'])
|
|
2869
|
+
} finally {
|
|
2870
|
+
await cleanup()
|
|
2871
|
+
pglite.close()
|
|
2872
|
+
}
|
|
2873
|
+
})
|
|
2874
|
+
|
|
2875
|
+
test('covered active scope remains persisted after broader scope is removed', async () => {
|
|
2876
|
+
const modelName = `model${++dbCounter}`
|
|
2877
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2878
|
+
|
|
2879
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'open' })
|
|
2880
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
2881
|
+
|
|
2882
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2883
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2884
|
+
{ useOfflinePlugin: true },
|
|
2885
|
+
)
|
|
2886
|
+
|
|
2887
|
+
try {
|
|
2888
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2889
|
+
|
|
2890
|
+
await model.addSynchroWhere({})
|
|
2891
|
+
await model.addSynchroWhere({ label: 'open' })
|
|
2892
|
+
|
|
2893
|
+
await model.db.whereList.where('sortedjson').equals('{}').delete()
|
|
2894
|
+
await model.db.values.clear()
|
|
2895
|
+
await model.db.metadata.clear()
|
|
2896
|
+
|
|
2897
|
+
await model.synchronizeAll()
|
|
2898
|
+
|
|
2899
|
+
const rows = await model.findWhere({ label: 'open' })
|
|
2900
|
+
assert.deepEqual(rows.map(row => row.uid), ['r1'])
|
|
2901
|
+
} finally {
|
|
2902
|
+
await cleanup()
|
|
2903
|
+
pglite.close()
|
|
2904
|
+
}
|
|
2905
|
+
})
|
|
2906
|
+
|
|
2907
|
+
test('scoped sync does not push dirty local rows outside the where scope', async () => {
|
|
2908
|
+
const modelName = `model${++dbCounter}`
|
|
2909
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2910
|
+
|
|
2911
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2912
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2913
|
+
{ useOfflinePlugin: true },
|
|
2914
|
+
)
|
|
2915
|
+
|
|
2916
|
+
try {
|
|
2917
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2918
|
+
await model.db.values.add({ uid: 'out', label: 'closed' })
|
|
2919
|
+
await model.db.metadata.add({ uid: 'out', created_at: T0, __dirty__: true })
|
|
2920
|
+
await model.addSynchroWhere({ label: 'open' })
|
|
2921
|
+
|
|
2922
|
+
await model.synchronizeAll()
|
|
2923
|
+
|
|
2924
|
+
const serverRows = await db.select().from(modelTable)
|
|
2925
|
+
assert.equal(serverRows.length, 0, 'scoped sync must not push dirty rows outside its where predicate')
|
|
2926
|
+
|
|
2927
|
+
const localValue = await model.db.values.get('out')
|
|
2928
|
+
const localMeta = await model.db.metadata.get('out')
|
|
2929
|
+
assert.equal(localValue?.label, 'closed', 'out-of-scope local value should remain local')
|
|
2930
|
+
assert.equal(localMeta?.__dirty__, true, 'out-of-scope metadata should stay dirty for a matching or broad sync')
|
|
2931
|
+
} finally {
|
|
2932
|
+
await cleanup()
|
|
2933
|
+
pglite.close()
|
|
2934
|
+
}
|
|
2935
|
+
})
|
|
2936
|
+
|
|
2937
|
+
test('scoped sync does not delete server rows outside the where scope', async () => {
|
|
2938
|
+
const modelName = `model${++dbCounter}`
|
|
2939
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2940
|
+
|
|
2941
|
+
await db.insert(modelTable).values({ uid: 'out', label: 'closed' })
|
|
2942
|
+
await db.insert(metaTable).values({ uid: 'out', created_at: T0 })
|
|
2943
|
+
|
|
2944
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2945
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2946
|
+
{ useOfflinePlugin: true },
|
|
2947
|
+
)
|
|
2948
|
+
|
|
2949
|
+
try {
|
|
2950
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
2951
|
+
await model.db.values.add({ uid: 'out', label: 'closed', __deleted__: true })
|
|
2952
|
+
await model.db.metadata.add({ uid: 'out', created_at: T0, deleted_at: T2, __dirty__: true })
|
|
2953
|
+
await model.addSynchroWhere({ label: 'open' })
|
|
2954
|
+
|
|
2955
|
+
await model.synchronizeAll()
|
|
2956
|
+
|
|
2957
|
+
const serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'out'))
|
|
2958
|
+
assert.equal(serverRows[0]?.label, 'closed', 'scoped sync must not delete server rows outside its where predicate')
|
|
2959
|
+
|
|
2960
|
+
const serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, 'out')))[0]
|
|
2961
|
+
assert.ok(!serverMeta.deleted_at, 'out-of-scope server metadata must not be tombstoned')
|
|
2962
|
+
} finally {
|
|
2963
|
+
await cleanup()
|
|
2964
|
+
pglite.close()
|
|
2965
|
+
}
|
|
2966
|
+
})
|
|
2967
|
+
|
|
2968
|
+
test('disposing one offline model scope does not remove another active sync scope', async () => {
|
|
2969
|
+
const modelName = `model${++dbCounter}`
|
|
2970
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
2971
|
+
|
|
2972
|
+
await db.insert(modelTable).values([
|
|
2973
|
+
{ uid: 'r1', label: 'open' },
|
|
2974
|
+
{ uid: 'r2', label: 'closed' },
|
|
2975
|
+
])
|
|
2976
|
+
await db.insert(metaTable).values([
|
|
2977
|
+
{ uid: 'r1', created_at: T0 },
|
|
2978
|
+
{ uid: 'r2', created_at: T0 },
|
|
2979
|
+
])
|
|
2980
|
+
|
|
2981
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
2982
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
2983
|
+
{ useOfflinePlugin: true },
|
|
2984
|
+
)
|
|
2985
|
+
|
|
2986
|
+
const scopeA = effectScope()
|
|
2987
|
+
const scopeB = effectScope()
|
|
2988
|
+
|
|
2989
|
+
try {
|
|
2990
|
+
const modelA = scopeA.run(() => clientApp.createOfflineModel(modelName, ['label']))
|
|
2991
|
+
const modelB = scopeB.run(() => clientApp.createOfflineModel(modelName, ['label']))
|
|
2992
|
+
|
|
2993
|
+
await modelA.addSynchroWhere({ label: 'open' })
|
|
2994
|
+
await modelB.addSynchroWhere({ label: 'closed' })
|
|
2995
|
+
|
|
2996
|
+
scopeA.stop()
|
|
2997
|
+
|
|
2998
|
+
let storedScopes = []
|
|
2999
|
+
for (let i = 0; i < 50; i++) {
|
|
3000
|
+
storedScopes = (await modelB.db.whereList.toArray()).map(row => row.sortedjson).sort()
|
|
3001
|
+
if (!storedScopes.includes('{"label":"open"}')) break
|
|
3002
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
assert.deepEqual(storedScopes, ['{"label":"closed"}'])
|
|
3006
|
+
|
|
3007
|
+
await modelB.synchronizeAll()
|
|
3008
|
+
const rows = await modelB.findWhere({ label: 'closed' })
|
|
3009
|
+
assert.deepEqual(rows.map(row => row.uid), ['r2'])
|
|
3010
|
+
} finally {
|
|
3011
|
+
scopeA.stop()
|
|
3012
|
+
scopeB.stop()
|
|
3013
|
+
await cleanup()
|
|
3014
|
+
pglite.close()
|
|
3015
|
+
}
|
|
3016
|
+
})
|
|
3017
|
+
|
|
3018
|
+
test('initial connect syncs scopes registered while the client started offline', async () => {
|
|
3019
|
+
const modelName = `model${++dbCounter}`
|
|
3020
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
3021
|
+
|
|
3022
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
|
|
3023
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
3024
|
+
|
|
3025
|
+
const serverApp = expressX({})
|
|
3026
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
3027
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
3028
|
+
const port = serverApp.httpServer.address().port
|
|
3029
|
+
|
|
3030
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
3031
|
+
transports: ['websocket'],
|
|
3032
|
+
autoConnect: false,
|
|
3033
|
+
})
|
|
3034
|
+
const clientApp = createClient(socket, { debug: false })
|
|
3035
|
+
offlinePlugin(clientApp)
|
|
3036
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3037
|
+
let subscription
|
|
3038
|
+
|
|
3039
|
+
try {
|
|
3040
|
+
await model.db.whereList.add({ sortedjson: '{}' })
|
|
3041
|
+
|
|
3042
|
+
const observedServerRow = new Promise((resolve, reject) => {
|
|
3043
|
+
const timeout = setTimeout(() => reject(new Error('timed out waiting for synced row')), 1000)
|
|
3044
|
+
subscription = model.getObservable({}).subscribe({
|
|
3045
|
+
next: rows => {
|
|
3046
|
+
if (rows.some(row => row.uid === 'r1')) {
|
|
3047
|
+
clearTimeout(timeout)
|
|
3048
|
+
subscription.unsubscribe()
|
|
3049
|
+
resolve(rows)
|
|
3050
|
+
}
|
|
3051
|
+
},
|
|
3052
|
+
error: reject,
|
|
3053
|
+
})
|
|
3054
|
+
})
|
|
3055
|
+
|
|
3056
|
+
socket.connect()
|
|
3057
|
+
await new Promise((resolve, reject) => {
|
|
3058
|
+
socket.on('connect', resolve)
|
|
3059
|
+
socket.on('connect_error', reject)
|
|
3060
|
+
})
|
|
3061
|
+
|
|
3062
|
+
const rows = await observedServerRow
|
|
3063
|
+
assert.deepEqual(rows.map(row => row.uid), ['r1'])
|
|
3064
|
+
} finally {
|
|
3065
|
+
if (subscription) subscription.unsubscribe()
|
|
3066
|
+
socket.disconnect()
|
|
3067
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
3068
|
+
pglite.close()
|
|
3069
|
+
}
|
|
3070
|
+
})
|
|
3071
|
+
|
|
3072
|
+
test('reconnect flushes offline updates that moved outside the registered sync scope', async () => {
|
|
3073
|
+
const modelName = `model${++dbCounter}`
|
|
3074
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
3075
|
+
|
|
3076
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'open' })
|
|
3077
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
3078
|
+
|
|
3079
|
+
const serverApp = expressX({})
|
|
3080
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
3081
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
3082
|
+
const port = serverApp.httpServer.address().port
|
|
3083
|
+
|
|
3084
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
3085
|
+
transports: ['websocket'],
|
|
3086
|
+
autoConnect: false,
|
|
3087
|
+
})
|
|
3088
|
+
const clientApp = createClient(socket, { debug: false })
|
|
3089
|
+
offlinePlugin(clientApp)
|
|
3090
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3091
|
+
|
|
3092
|
+
try {
|
|
3093
|
+
socket.connect()
|
|
3094
|
+
await new Promise((resolve, reject) => {
|
|
3095
|
+
socket.on('connect', resolve)
|
|
3096
|
+
socket.on('connect_error', reject)
|
|
3097
|
+
})
|
|
3098
|
+
|
|
3099
|
+
await model.addSynchroWhere({ label: 'open' })
|
|
3100
|
+
await model.synchronizeAll()
|
|
3101
|
+
assert.equal((await model.db.values.get('r1')).label, 'open')
|
|
3102
|
+
|
|
3103
|
+
socket.disconnect()
|
|
3104
|
+
for (let i = 0; i < 50 && clientApp.isConnected; i++) {
|
|
3105
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
3106
|
+
}
|
|
3107
|
+
assert.equal(clientApp.isConnected, false)
|
|
3108
|
+
|
|
3109
|
+
await model.update('r1', { label: 'closed' })
|
|
3110
|
+
assert.equal((await model.db.values.get('r1')).label, 'closed')
|
|
3111
|
+
|
|
3112
|
+
socket.connect()
|
|
3113
|
+
await new Promise((resolve, reject) => {
|
|
3114
|
+
socket.on('connect', resolve)
|
|
3115
|
+
socket.on('connect_error', reject)
|
|
3116
|
+
})
|
|
3117
|
+
|
|
3118
|
+
let serverRows = []
|
|
3119
|
+
for (let i = 0; i < 50; i++) {
|
|
3120
|
+
serverRows = await db.select().from(modelTable).where(eq(modelTable.uid, 'r1'))
|
|
3121
|
+
if (serverRows[0]?.label === 'closed') break
|
|
3122
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
assert.equal(serverRows[0]?.label, 'closed', 'durable offline mutation must be flushed even after it moved out of the query scope')
|
|
3126
|
+
assert.equal((await model.db.values.get('r1')).label, 'closed', 'client value must not be overwritten by stale scoped server data')
|
|
3127
|
+
assert.equal((await model.db.metadata.get('r1')).__dirty__, false, 'flushed out-of-scope update should be marked clean')
|
|
3128
|
+
} finally {
|
|
3129
|
+
socket.disconnect()
|
|
3130
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
3131
|
+
pglite.close()
|
|
3132
|
+
}
|
|
3133
|
+
})
|
|
3134
|
+
|
|
3135
|
+
test('reconnect preserves updated_at for an offline create followed by offline update', async () => {
|
|
3136
|
+
const modelName = `model${++dbCounter}`
|
|
3137
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
3138
|
+
|
|
3139
|
+
const serverApp = expressX({})
|
|
3140
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
3141
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
3142
|
+
const port = serverApp.httpServer.address().port
|
|
3143
|
+
|
|
3144
|
+
const socket = ioc(`http://localhost:${port}`, {
|
|
3145
|
+
transports: ['websocket'],
|
|
3146
|
+
autoConnect: false,
|
|
3147
|
+
})
|
|
3148
|
+
const clientApp = createClient(socket, { debug: false })
|
|
3149
|
+
offlinePlugin(clientApp)
|
|
3150
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3151
|
+
|
|
3152
|
+
try {
|
|
3153
|
+
const record = await model.create({ label: 'first' })
|
|
3154
|
+
await model.update(record.uid, { label: 'second' })
|
|
3155
|
+
const localMetaBeforeConnect = await model.db.metadata.get(record.uid)
|
|
3156
|
+
assert.ok(localMetaBeforeConnect.created_at)
|
|
3157
|
+
assert.ok(localMetaBeforeConnect.updated_at)
|
|
3158
|
+
assert.equal(localMetaBeforeConnect.__operation__, 'create')
|
|
3159
|
+
|
|
3160
|
+
socket.connect()
|
|
3161
|
+
await new Promise((resolve, reject) => {
|
|
3162
|
+
socket.on('connect', resolve)
|
|
3163
|
+
socket.on('connect_error', reject)
|
|
3164
|
+
})
|
|
3165
|
+
|
|
3166
|
+
let serverRow
|
|
3167
|
+
let serverMeta
|
|
3168
|
+
for (let i = 0; i < 50; i++) {
|
|
3169
|
+
serverRow = (await db.select().from(modelTable).where(eq(modelTable.uid, record.uid)))[0]
|
|
3170
|
+
serverMeta = (await db.select().from(metaTable).where(eq(metaTable.uid, record.uid)))[0]
|
|
3171
|
+
if (serverRow?.label === 'second' && serverMeta?.updated_at) break
|
|
3172
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
assert.equal(serverRow?.label, 'second')
|
|
3176
|
+
assert.equal(new Date(serverMeta.updated_at).getTime(), new Date(localMetaBeforeConnect.updated_at).getTime())
|
|
3177
|
+
|
|
3178
|
+
const localMetaAfterConnect = await model.db.metadata.get(record.uid)
|
|
3179
|
+
assert.equal(localMetaAfterConnect.__dirty__, false)
|
|
3180
|
+
assert.equal(new Date(localMetaAfterConnect.updated_at).getTime(), new Date(localMetaBeforeConnect.updated_at).getTime())
|
|
3181
|
+
} finally {
|
|
3182
|
+
socket.disconnect()
|
|
3183
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
3184
|
+
pglite.close()
|
|
3185
|
+
}
|
|
3186
|
+
})
|
|
3187
|
+
|
|
3188
|
+
test('direct update sends the full current value to prevent partial server upserts', async () => {
|
|
3189
|
+
const modelName = `model${++dbCounter}`
|
|
3190
|
+
let receivedData
|
|
3191
|
+
|
|
3192
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3193
|
+
serverApp.createService(modelName, {
|
|
3194
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3195
|
+
receivedData = data
|
|
3196
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3197
|
+
},
|
|
3198
|
+
createWithMeta: async () => {},
|
|
3199
|
+
deleteWithMeta: async () => {},
|
|
3200
|
+
findMany: async () => [],
|
|
3201
|
+
})
|
|
3202
|
+
serverApp.createService('sync', {
|
|
3203
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3204
|
+
})
|
|
3205
|
+
}, { useOfflinePlugin: true })
|
|
3206
|
+
|
|
3207
|
+
try {
|
|
3208
|
+
const model = clientApp.createOfflineModel(modelName, ['label', 'description'])
|
|
3209
|
+
await model.db.values.add({ uid: 'r1', label: 'original', description: 'must survive' })
|
|
3210
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3211
|
+
|
|
3212
|
+
await model.update('r1', { label: 'patched' })
|
|
3213
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3214
|
+
|
|
3215
|
+
assert.deepEqual(receivedData, {
|
|
3216
|
+
label: 'patched',
|
|
3217
|
+
description: 'must survive',
|
|
3218
|
+
})
|
|
3219
|
+
} finally {
|
|
3220
|
+
await cleanup()
|
|
3221
|
+
}
|
|
3222
|
+
})
|
|
3223
|
+
|
|
3224
|
+
test('stale direct update acknowledgement does not overwrite newer local update', async () => {
|
|
3225
|
+
const modelName = `model${++dbCounter}`
|
|
3226
|
+
let firstUpdateRelease
|
|
3227
|
+
let updateCount = 0
|
|
3228
|
+
|
|
3229
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3230
|
+
serverApp.createService(modelName, {
|
|
3231
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3232
|
+
updateCount += 1
|
|
3233
|
+
if (updateCount === 1) {
|
|
3234
|
+
await new Promise(resolve => { firstUpdateRelease = resolve })
|
|
3235
|
+
}
|
|
3236
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3237
|
+
},
|
|
3238
|
+
createWithMeta: async () => {},
|
|
3239
|
+
deleteWithMeta: async () => {},
|
|
3240
|
+
findMany: async () => [],
|
|
3241
|
+
})
|
|
3242
|
+
serverApp.createService('sync', {
|
|
3243
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3244
|
+
})
|
|
3245
|
+
}, { useOfflinePlugin: true })
|
|
3246
|
+
|
|
3247
|
+
try {
|
|
3248
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3249
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3250
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3251
|
+
|
|
3252
|
+
model.update('r1', { label: 'first' })
|
|
3253
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
3254
|
+
await model.update('r1', { label: 'second' })
|
|
3255
|
+
firstUpdateRelease()
|
|
3256
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3257
|
+
|
|
3258
|
+
const value = await model.db.values.get('r1')
|
|
3259
|
+
const meta = await model.db.metadata.get('r1')
|
|
3260
|
+
|
|
3261
|
+
assert.equal(value.label, 'second', 'older acknowledgement must not restore first update')
|
|
3262
|
+
assert.equal(meta.__dirty__, false, 'latest acknowledged update should clear dirty marker')
|
|
3263
|
+
assert.equal(new Date(meta.updated_at).getTime() > T0.getTime(), true)
|
|
3264
|
+
} finally {
|
|
3265
|
+
await cleanup()
|
|
3266
|
+
}
|
|
3267
|
+
})
|
|
3268
|
+
|
|
3269
|
+
test('stale direct create acknowledgement does not overwrite newer local update', async () => {
|
|
3270
|
+
const modelName = `model${++dbCounter}`
|
|
3271
|
+
let createRelease
|
|
3272
|
+
|
|
3273
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3274
|
+
serverApp.createService(modelName, {
|
|
3275
|
+
createWithMeta: async (uid, data, created_at) => {
|
|
3276
|
+
await new Promise(resolve => { createRelease = resolve })
|
|
3277
|
+
return [{ uid, ...data }, { uid, created_at, updated_at: null, deleted_at: null }]
|
|
3278
|
+
},
|
|
3279
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3280
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3281
|
+
},
|
|
3282
|
+
deleteWithMeta: async () => {},
|
|
3283
|
+
findMany: async () => [],
|
|
3284
|
+
})
|
|
3285
|
+
serverApp.createService('sync', {
|
|
3286
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3287
|
+
})
|
|
3288
|
+
}, { useOfflinePlugin: true })
|
|
3289
|
+
|
|
3290
|
+
try {
|
|
3291
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3292
|
+
|
|
3293
|
+
const record = await model.create({ label: 'first' })
|
|
3294
|
+
await model.update(record.uid, { label: 'second' })
|
|
3295
|
+
createRelease()
|
|
3296
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3297
|
+
|
|
3298
|
+
const value = await model.db.values.get(record.uid)
|
|
3299
|
+
const meta = await model.db.metadata.get(record.uid)
|
|
3300
|
+
|
|
3301
|
+
assert.equal(value.label, 'second', 'older create acknowledgement must not restore initial value')
|
|
3302
|
+
assert.equal(meta.__dirty__, false, 'newer acknowledged update should remain clean')
|
|
3303
|
+
assert.ok(meta.updated_at, 'newer update timestamp should remain')
|
|
3304
|
+
} finally {
|
|
3305
|
+
await cleanup()
|
|
3306
|
+
}
|
|
3307
|
+
})
|
|
3308
|
+
|
|
3309
|
+
test('stale direct create rejection does not remove newer local update', async () => {
|
|
3310
|
+
const modelName = `model${++dbCounter}`
|
|
3311
|
+
let createRelease
|
|
3312
|
+
|
|
3313
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3314
|
+
serverApp.createService(modelName, {
|
|
3315
|
+
createWithMeta: async () => {
|
|
3316
|
+
await new Promise(resolve => { createRelease = resolve })
|
|
3317
|
+
throw new Error('create rejected late')
|
|
3318
|
+
},
|
|
3319
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3320
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3321
|
+
},
|
|
3322
|
+
deleteWithMeta: async () => {},
|
|
3323
|
+
findMany: async () => [],
|
|
3324
|
+
})
|
|
3325
|
+
serverApp.createService('sync', {
|
|
3326
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3327
|
+
})
|
|
3328
|
+
}, { useOfflinePlugin: true })
|
|
3329
|
+
|
|
3330
|
+
try {
|
|
3331
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3332
|
+
|
|
3333
|
+
const record = await model.create({ label: 'first' })
|
|
3334
|
+
await model.update(record.uid, { label: 'second' })
|
|
3335
|
+
createRelease()
|
|
3336
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3337
|
+
|
|
3338
|
+
const value = await model.db.values.get(record.uid)
|
|
3339
|
+
const meta = await model.db.metadata.get(record.uid)
|
|
3340
|
+
|
|
3341
|
+
assert.equal(value.label, 'second', 'older create rejection must not remove newer local update')
|
|
3342
|
+
assert.ok(meta, 'metadata must remain after stale create rejection')
|
|
3343
|
+
assert.equal(meta.__dirty__, false, 'newer acknowledged update should remain clean')
|
|
3344
|
+
} finally {
|
|
3345
|
+
await cleanup()
|
|
3346
|
+
}
|
|
3347
|
+
})
|
|
3348
|
+
|
|
3349
|
+
test('stale direct update rejection does not roll back newer local update', async () => {
|
|
3350
|
+
const modelName = `model${++dbCounter}`
|
|
3351
|
+
let firstUpdateRelease
|
|
3352
|
+
let updateCount = 0
|
|
3353
|
+
|
|
3354
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3355
|
+
serverApp.createService(modelName, {
|
|
3356
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3357
|
+
updateCount += 1
|
|
3358
|
+
if (updateCount === 1) {
|
|
3359
|
+
await new Promise(resolve => { firstUpdateRelease = resolve })
|
|
3360
|
+
throw new Error('first update rejected late')
|
|
3361
|
+
}
|
|
3362
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3363
|
+
},
|
|
3364
|
+
createWithMeta: async () => {},
|
|
3365
|
+
deleteWithMeta: async () => {},
|
|
3366
|
+
findMany: async () => [],
|
|
3367
|
+
})
|
|
3368
|
+
serverApp.createService('sync', {
|
|
3369
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3370
|
+
})
|
|
3371
|
+
}, { useOfflinePlugin: true })
|
|
3372
|
+
|
|
3373
|
+
try {
|
|
3374
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3375
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3376
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3377
|
+
|
|
3378
|
+
model.update('r1', { label: 'first' })
|
|
3379
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
3380
|
+
await model.update('r1', { label: 'second' })
|
|
3381
|
+
firstUpdateRelease()
|
|
3382
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3383
|
+
|
|
3384
|
+
const value = await model.db.values.get('r1')
|
|
3385
|
+
const meta = await model.db.metadata.get('r1')
|
|
3386
|
+
|
|
3387
|
+
assert.equal(value.label, 'second', 'older rejection must not roll back the newer update')
|
|
3388
|
+
assert.equal(meta.__dirty__, false, 'newer acknowledged update should remain clean')
|
|
3389
|
+
assert.equal(new Date(meta.updated_at).getTime() > T0.getTime(), true)
|
|
3390
|
+
} finally {
|
|
3391
|
+
await cleanup()
|
|
3392
|
+
}
|
|
3393
|
+
})
|
|
3394
|
+
|
|
3395
|
+
test('stale direct delete rejection does not roll back newer delete', async () => {
|
|
3396
|
+
const modelName = `model${++dbCounter}`
|
|
3397
|
+
let firstDeleteRelease
|
|
3398
|
+
let deleteCount = 0
|
|
3399
|
+
|
|
3400
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3401
|
+
serverApp.createService(modelName, {
|
|
3402
|
+
deleteWithMeta: async (uid, deleted_at) => {
|
|
3403
|
+
deleteCount += 1
|
|
3404
|
+
if (deleteCount === 1) {
|
|
3405
|
+
await new Promise(resolve => { firstDeleteRelease = resolve })
|
|
3406
|
+
throw new Error('first delete rejected late')
|
|
3407
|
+
}
|
|
3408
|
+
return [{ uid, label: 'original' }, { uid, created_at: T0, deleted_at, updated_at: null }]
|
|
3409
|
+
},
|
|
3410
|
+
createWithMeta: async () => {},
|
|
3411
|
+
updateWithMeta: async () => {},
|
|
3412
|
+
findMany: async () => [],
|
|
3413
|
+
})
|
|
3414
|
+
serverApp.createService('sync', {
|
|
3415
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3416
|
+
})
|
|
3417
|
+
}, { useOfflinePlugin: true })
|
|
3418
|
+
|
|
3419
|
+
try {
|
|
3420
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3421
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3422
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3423
|
+
|
|
3424
|
+
model.remove('r1')
|
|
3425
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
3426
|
+
await model.remove('r1')
|
|
3427
|
+
firstDeleteRelease()
|
|
3428
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3429
|
+
|
|
3430
|
+
const value = await model.db.values.get('r1')
|
|
3431
|
+
const meta = await model.db.metadata.get('r1')
|
|
3432
|
+
|
|
3433
|
+
assert.equal(value.__deleted__, true, 'older rejection must not undelete the newer delete')
|
|
3434
|
+
assert.ok(meta.deleted_at, 'newer delete timestamp should remain')
|
|
3435
|
+
assert.equal(meta.__dirty__, false, 'newer acknowledged delete should remain clean')
|
|
3436
|
+
} finally {
|
|
3437
|
+
await cleanup()
|
|
3438
|
+
}
|
|
3439
|
+
})
|
|
3440
|
+
|
|
3441
|
+
test('stale direct delete acknowledgement does not overwrite newer local update', async () => {
|
|
3442
|
+
const modelName = `model${++dbCounter}`
|
|
3443
|
+
let deleteRelease
|
|
3444
|
+
|
|
3445
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3446
|
+
serverApp.createService(modelName, {
|
|
3447
|
+
deleteWithMeta: async (uid) => {
|
|
3448
|
+
await new Promise(resolve => { deleteRelease = resolve })
|
|
3449
|
+
return [{ uid, label: 'server-old' }, { uid, created_at: T0, updated_at: T1, deleted_at: null }]
|
|
3450
|
+
},
|
|
3451
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3452
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3453
|
+
},
|
|
3454
|
+
createWithMeta: async () => {},
|
|
3455
|
+
findMany: async () => [],
|
|
3456
|
+
})
|
|
3457
|
+
serverApp.createService('sync', {
|
|
3458
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3459
|
+
})
|
|
3460
|
+
}, { useOfflinePlugin: true })
|
|
3461
|
+
|
|
3462
|
+
try {
|
|
3463
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3464
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3465
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3466
|
+
|
|
3467
|
+
model.remove('r1')
|
|
3468
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
3469
|
+
await model.update('r1', { label: 'local-new' })
|
|
3470
|
+
deleteRelease()
|
|
3471
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
3472
|
+
|
|
3473
|
+
const value = await model.db.values.get('r1')
|
|
3474
|
+
const meta = await model.db.metadata.get('r1')
|
|
3475
|
+
|
|
3476
|
+
assert.equal(value.label, 'local-new', 'stale delete acknowledgement must not restore older server value')
|
|
3477
|
+
assert.equal(new Date(meta.updated_at).getTime() > T1.getTime(), true, 'newer local update timestamp must remain')
|
|
3478
|
+
assert.equal(meta.__dirty__, false)
|
|
3479
|
+
} finally {
|
|
3480
|
+
await cleanup()
|
|
3481
|
+
}
|
|
3482
|
+
})
|
|
3483
|
+
|
|
3484
|
+
test('stale sync updateClient does not overwrite newer local update', async () => {
|
|
3485
|
+
const modelName = `model${++dbCounter}`
|
|
3486
|
+
let resolveSyncStarted
|
|
3487
|
+
let releaseSync
|
|
3488
|
+
const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
|
|
3489
|
+
const syncRelease = new Promise(resolve => { releaseSync = resolve })
|
|
3490
|
+
|
|
3491
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3492
|
+
serverApp.createService(modelName, {
|
|
3493
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3494
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3495
|
+
},
|
|
3496
|
+
createWithMeta: async () => {},
|
|
3497
|
+
deleteWithMeta: async () => {},
|
|
3498
|
+
findMany: async () => [],
|
|
3499
|
+
})
|
|
3500
|
+
serverApp.createService('sync', {
|
|
3501
|
+
go: async () => {
|
|
3502
|
+
resolveSyncStarted()
|
|
3503
|
+
await syncRelease
|
|
3504
|
+
return {
|
|
3505
|
+
addClient: [],
|
|
3506
|
+
updateClient: [[{ uid: 'r1', label: 'server-old' }, { uid: 'r1', created_at: T0, updated_at: T1, deleted_at: null }]],
|
|
3507
|
+
deleteClient: [],
|
|
3508
|
+
addDatabase: [],
|
|
3509
|
+
updateDatabase: [],
|
|
3510
|
+
}
|
|
3511
|
+
},
|
|
3512
|
+
})
|
|
3513
|
+
}, { useOfflinePlugin: true })
|
|
3514
|
+
|
|
3515
|
+
try {
|
|
3516
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3517
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3518
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3519
|
+
await model.addSynchroWhere({})
|
|
3520
|
+
|
|
3521
|
+
const syncPromise = model.synchronizeAll()
|
|
3522
|
+
await syncStarted
|
|
3523
|
+
await model.update('r1', { label: 'local-new' })
|
|
3524
|
+
releaseSync()
|
|
3525
|
+
await syncPromise
|
|
3526
|
+
|
|
3527
|
+
const value = await model.db.values.get('r1')
|
|
3528
|
+
const meta = await model.db.metadata.get('r1')
|
|
3529
|
+
|
|
3530
|
+
assert.equal(value.label, 'local-new', 'stale sync updateClient must not overwrite newer local update')
|
|
3531
|
+
assert.equal(meta.__dirty__, false, 'newer acknowledged update should remain clean')
|
|
3532
|
+
assert.equal(new Date(meta.updated_at).getTime() > T1.getTime(), true)
|
|
3533
|
+
} finally {
|
|
3534
|
+
await cleanup()
|
|
3535
|
+
}
|
|
3536
|
+
})
|
|
3537
|
+
|
|
3538
|
+
test('newer sync updateClient overwrites local update made during sync', async () => {
|
|
3539
|
+
const modelName = `model${++dbCounter}`
|
|
3540
|
+
const futureUpdate = new Date(Date.now() + 60_000)
|
|
3541
|
+
let resolveSyncStarted
|
|
3542
|
+
let releaseSync
|
|
3543
|
+
const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
|
|
3544
|
+
const syncRelease = new Promise(resolve => { releaseSync = resolve })
|
|
3545
|
+
|
|
3546
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3547
|
+
serverApp.createService(modelName, {
|
|
3548
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3549
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3550
|
+
},
|
|
3551
|
+
createWithMeta: async () => {},
|
|
3552
|
+
deleteWithMeta: async () => {},
|
|
3553
|
+
findMany: async () => [],
|
|
3554
|
+
})
|
|
3555
|
+
serverApp.createService('sync', {
|
|
3556
|
+
go: async () => {
|
|
3557
|
+
resolveSyncStarted()
|
|
3558
|
+
await syncRelease
|
|
3559
|
+
return {
|
|
3560
|
+
addClient: [],
|
|
3561
|
+
updateClient: [[
|
|
3562
|
+
{ uid: 'r1', label: 'server-new' },
|
|
3563
|
+
{ uid: 'r1', created_at: T0, updated_at: futureUpdate, deleted_at: null },
|
|
3564
|
+
]],
|
|
3565
|
+
deleteClient: [],
|
|
3566
|
+
addDatabase: [],
|
|
3567
|
+
updateDatabase: [],
|
|
3568
|
+
}
|
|
3569
|
+
},
|
|
3570
|
+
})
|
|
3571
|
+
}, { useOfflinePlugin: true })
|
|
3572
|
+
|
|
3573
|
+
try {
|
|
3574
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3575
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3576
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3577
|
+
await model.addSynchroWhere({})
|
|
3578
|
+
|
|
3579
|
+
const syncPromise = model.synchronizeAll()
|
|
3580
|
+
await syncStarted
|
|
3581
|
+
await model.update('r1', { label: 'local-new' })
|
|
3582
|
+
releaseSync()
|
|
3583
|
+
await syncPromise
|
|
3584
|
+
|
|
3585
|
+
const value = await model.db.values.get('r1')
|
|
3586
|
+
const meta = await model.db.metadata.get('r1')
|
|
3587
|
+
|
|
3588
|
+
assert.equal(value.label, 'server-new', 'newer sync updateClient must overwrite local update made during sync')
|
|
3589
|
+
assert.equal(new Date(meta.updated_at).getTime(), futureUpdate.getTime())
|
|
3590
|
+
assert.equal(meta.__dirty__, false)
|
|
3591
|
+
} finally {
|
|
3592
|
+
await cleanup()
|
|
3593
|
+
}
|
|
3594
|
+
})
|
|
3595
|
+
|
|
3596
|
+
test('stale sync deleteClient does not delete newer local update', async () => {
|
|
3597
|
+
const modelName = `model${++dbCounter}`
|
|
3598
|
+
let resolveSyncStarted
|
|
3599
|
+
let releaseSync
|
|
3600
|
+
const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
|
|
3601
|
+
const syncRelease = new Promise(resolve => { releaseSync = resolve })
|
|
3602
|
+
|
|
3603
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3604
|
+
serverApp.createService(modelName, {
|
|
3605
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3606
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3607
|
+
},
|
|
3608
|
+
createWithMeta: async () => {},
|
|
3609
|
+
deleteWithMeta: async () => {},
|
|
3610
|
+
findMany: async () => [],
|
|
3611
|
+
})
|
|
3612
|
+
serverApp.createService('sync', {
|
|
3613
|
+
go: async () => {
|
|
3614
|
+
resolveSyncStarted()
|
|
3615
|
+
await syncRelease
|
|
3616
|
+
return {
|
|
3617
|
+
addClient: [],
|
|
3618
|
+
updateClient: [],
|
|
3619
|
+
deleteClient: [['r1', T1]],
|
|
3620
|
+
addDatabase: [],
|
|
3621
|
+
updateDatabase: [],
|
|
3622
|
+
}
|
|
3623
|
+
},
|
|
3624
|
+
})
|
|
3625
|
+
}, { useOfflinePlugin: true })
|
|
3626
|
+
|
|
3627
|
+
try {
|
|
3628
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3629
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3630
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3631
|
+
await model.addSynchroWhere({})
|
|
3632
|
+
|
|
3633
|
+
const syncPromise = model.synchronizeAll()
|
|
3634
|
+
await syncStarted
|
|
3635
|
+
await model.update('r1', { label: 'local-new' })
|
|
3636
|
+
releaseSync()
|
|
3637
|
+
await syncPromise
|
|
3638
|
+
|
|
3639
|
+
const value = await model.db.values.get('r1')
|
|
3640
|
+
const meta = await model.db.metadata.get('r1')
|
|
3641
|
+
|
|
3642
|
+
assert.equal(value.label, 'local-new', 'stale sync deleteClient must not delete newer local update')
|
|
3643
|
+
assert.ok(meta, 'metadata must remain after stale sync deleteClient')
|
|
3644
|
+
assert.equal(meta.__dirty__, false, 'newer acknowledged update should remain clean')
|
|
3645
|
+
} finally {
|
|
3646
|
+
await cleanup()
|
|
3647
|
+
}
|
|
3648
|
+
})
|
|
3649
|
+
|
|
3650
|
+
test('newer sync deleteClient deletes local update made during sync', async () => {
|
|
3651
|
+
const modelName = `model${++dbCounter}`
|
|
3652
|
+
const futureDelete = new Date(Date.now() + 60_000)
|
|
3653
|
+
let resolveSyncStarted
|
|
3654
|
+
let releaseSync
|
|
3655
|
+
const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
|
|
3656
|
+
const syncRelease = new Promise(resolve => { releaseSync = resolve })
|
|
3657
|
+
|
|
3658
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3659
|
+
serverApp.createService(modelName, {
|
|
3660
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3661
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3662
|
+
},
|
|
3663
|
+
createWithMeta: async () => {},
|
|
3664
|
+
deleteWithMeta: async () => {},
|
|
3665
|
+
findMany: async () => [],
|
|
3666
|
+
})
|
|
3667
|
+
serverApp.createService('sync', {
|
|
3668
|
+
go: async () => {
|
|
3669
|
+
resolveSyncStarted()
|
|
3670
|
+
await syncRelease
|
|
3671
|
+
return {
|
|
3672
|
+
addClient: [],
|
|
3673
|
+
updateClient: [],
|
|
3674
|
+
deleteClient: [['r1', futureDelete]],
|
|
3675
|
+
addDatabase: [],
|
|
3676
|
+
updateDatabase: [],
|
|
3677
|
+
}
|
|
3678
|
+
},
|
|
3679
|
+
})
|
|
3680
|
+
}, { useOfflinePlugin: true })
|
|
3681
|
+
|
|
3682
|
+
try {
|
|
3683
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3684
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3685
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3686
|
+
await model.addSynchroWhere({})
|
|
3687
|
+
|
|
3688
|
+
const syncPromise = model.synchronizeAll()
|
|
3689
|
+
await syncStarted
|
|
3690
|
+
await model.update('r1', { label: 'local-new' })
|
|
3691
|
+
releaseSync()
|
|
3692
|
+
await syncPromise
|
|
3693
|
+
|
|
3694
|
+
assert.ok(!await model.db.values.get('r1'), 'newer sync deleteClient must delete local value changed during sync')
|
|
3695
|
+
const meta = await model.db.metadata.get('r1')
|
|
3696
|
+
assert.equal(new Date(meta.deleted_at).getTime(), futureDelete.getTime(), 'newer sync deleteClient should leave a clean tombstone guard')
|
|
3697
|
+
assert.equal(meta.__dirty__, false)
|
|
3698
|
+
} finally {
|
|
3699
|
+
await cleanup()
|
|
3700
|
+
}
|
|
3701
|
+
})
|
|
3702
|
+
|
|
3703
|
+
test('stale sync addClient does not overwrite newer local cache row', async () => {
|
|
3704
|
+
const modelName = `model${++dbCounter}`
|
|
3705
|
+
let resolveSyncStarted
|
|
3706
|
+
let releaseSync
|
|
3707
|
+
const syncStarted = new Promise(resolve => { resolveSyncStarted = resolve })
|
|
3708
|
+
const syncRelease = new Promise(resolve => { releaseSync = resolve })
|
|
3709
|
+
|
|
3710
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3711
|
+
serverApp.createService('sync', {
|
|
3712
|
+
go: async () => {
|
|
3713
|
+
resolveSyncStarted()
|
|
3714
|
+
await syncRelease
|
|
3715
|
+
return {
|
|
3716
|
+
addClient: [[{ uid: 'r1', label: 'server-old' }, { uid: 'r1', created_at: T1, updated_at: null, deleted_at: null }]],
|
|
3717
|
+
updateClient: [],
|
|
3718
|
+
deleteClient: [],
|
|
3719
|
+
addDatabase: [],
|
|
3720
|
+
updateDatabase: [],
|
|
3721
|
+
}
|
|
3722
|
+
},
|
|
3723
|
+
})
|
|
3724
|
+
}, { useOfflinePlugin: true })
|
|
3725
|
+
|
|
3726
|
+
try {
|
|
3727
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3728
|
+
await model.addSynchroWhere({})
|
|
3729
|
+
|
|
3730
|
+
const syncPromise = model.synchronizeAll()
|
|
3731
|
+
await syncStarted
|
|
3732
|
+
await model.db.values.put({ uid: 'r1', label: 'local-new' })
|
|
3733
|
+
await model.db.metadata.put({ uid: 'r1', created_at: T2, updated_at: null, deleted_at: null, __dirty__: false })
|
|
3734
|
+
releaseSync()
|
|
3735
|
+
await syncPromise
|
|
3736
|
+
|
|
3737
|
+
const value = await model.db.values.get('r1')
|
|
3738
|
+
const meta = await model.db.metadata.get('r1')
|
|
3739
|
+
|
|
3740
|
+
assert.equal(value.label, 'local-new', 'stale sync addClient must not overwrite newer local cache row')
|
|
3741
|
+
assert.equal(new Date(meta.created_at).getTime(), T2.getTime(), 'newer local metadata should remain')
|
|
3742
|
+
} finally {
|
|
3743
|
+
await cleanup()
|
|
3744
|
+
}
|
|
3745
|
+
})
|
|
3746
|
+
|
|
3747
|
+
test('sync addClient restores live row over equal-time local tombstone guard', async () => {
|
|
3748
|
+
const modelName = `model${++dbCounter}`
|
|
3749
|
+
|
|
3750
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3751
|
+
serverApp.createService('sync', {
|
|
3752
|
+
go: async () => ({
|
|
3753
|
+
addClient: [[
|
|
3754
|
+
{ uid: 'r1', label: 'server-live' },
|
|
3755
|
+
{ uid: 'r1', created_at: T1, updated_at: null, deleted_at: null },
|
|
3756
|
+
]],
|
|
3757
|
+
updateClient: [],
|
|
3758
|
+
deleteClient: [],
|
|
3759
|
+
addDatabase: [],
|
|
3760
|
+
updateDatabase: [],
|
|
3761
|
+
}),
|
|
3762
|
+
})
|
|
3763
|
+
}, { useOfflinePlugin: true })
|
|
3764
|
+
|
|
3765
|
+
try {
|
|
3766
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3767
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, deleted_at: T1, __dirty__: false })
|
|
3768
|
+
await model.addSynchroWhere({})
|
|
3769
|
+
|
|
3770
|
+
await model.synchronizeAll()
|
|
3771
|
+
|
|
3772
|
+
const value = await model.db.values.get('r1')
|
|
3773
|
+
const meta = await model.db.metadata.get('r1')
|
|
3774
|
+
|
|
3775
|
+
assert.equal(value.label, 'server-live', 'live addClient row should replace equal-time local tombstone guard')
|
|
3776
|
+
assert.ok(!meta.deleted_at, 'restored live row must clear local tombstone guard')
|
|
3777
|
+
assert.equal(new Date(meta.created_at).getTime(), T1.getTime())
|
|
3778
|
+
} finally {
|
|
3779
|
+
await cleanup()
|
|
3780
|
+
}
|
|
3781
|
+
})
|
|
3782
|
+
|
|
3783
|
+
test('stale sync updateDatabase acknowledgement does not clear newer dirty update', async () => {
|
|
3784
|
+
const modelName = `model${++dbCounter}`
|
|
3785
|
+
let firstUpdateRelease
|
|
3786
|
+
let updateCount = 0
|
|
3787
|
+
|
|
3788
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3789
|
+
serverApp.createService(modelName, {
|
|
3790
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3791
|
+
updateCount += 1
|
|
3792
|
+
if (updateCount === 1) {
|
|
3793
|
+
await new Promise(resolve => { firstUpdateRelease = resolve })
|
|
3794
|
+
}
|
|
3795
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3796
|
+
},
|
|
3797
|
+
createWithMeta: async () => {},
|
|
3798
|
+
deleteWithMeta: async () => {},
|
|
3799
|
+
findMany: async () => [],
|
|
3800
|
+
})
|
|
3801
|
+
serverApp.createService('sync', {
|
|
3802
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
3803
|
+
addClient: [],
|
|
3804
|
+
updateClient: [],
|
|
3805
|
+
deleteClient: [],
|
|
3806
|
+
addDatabase: [],
|
|
3807
|
+
updateDatabase: [clientMetadataDict.r1],
|
|
3808
|
+
}),
|
|
3809
|
+
})
|
|
3810
|
+
}, { useOfflinePlugin: true })
|
|
3811
|
+
|
|
3812
|
+
try {
|
|
3813
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3814
|
+
await model.db.values.add({ uid: 'r1', label: 'first' })
|
|
3815
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1, __dirty__: true })
|
|
3816
|
+
await model.addSynchroWhere({})
|
|
3817
|
+
|
|
3818
|
+
const syncPromise = model.synchronizeAll()
|
|
3819
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
3820
|
+
await model.update('r1', { label: 'second' })
|
|
3821
|
+
firstUpdateRelease()
|
|
3822
|
+
await syncPromise
|
|
3823
|
+
|
|
3824
|
+
for (let i = 0; i < 50; i++) {
|
|
3825
|
+
const meta = await model.db.metadata.get('r1')
|
|
3826
|
+
if (meta?.__dirty__ === false) break
|
|
3827
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
const value = await model.db.values.get('r1')
|
|
3831
|
+
const meta = await model.db.metadata.get('r1')
|
|
3832
|
+
|
|
3833
|
+
assert.equal(value.label, 'second', 'newer local value must remain after stale updateDatabase acknowledgement')
|
|
3834
|
+
assert.equal(new Date(meta.updated_at).getTime() > T1.getTime(), true, 'newer metadata timestamp must remain')
|
|
3835
|
+
assert.equal(meta.__dirty__, false, 'newer direct update acknowledgement should remain clean')
|
|
3836
|
+
} finally {
|
|
3837
|
+
await cleanup()
|
|
3838
|
+
}
|
|
3839
|
+
})
|
|
3840
|
+
|
|
3841
|
+
test('stale sync addDatabase rejection does not remove newer local update', async () => {
|
|
3842
|
+
const modelName = `model${++dbCounter}`
|
|
3843
|
+
let createRelease
|
|
3844
|
+
|
|
3845
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3846
|
+
serverApp.createService(modelName, {
|
|
3847
|
+
createWithMeta: async () => {
|
|
3848
|
+
await new Promise(resolve => { createRelease = resolve })
|
|
3849
|
+
throw new Error('stale create rejected')
|
|
3850
|
+
},
|
|
3851
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
3852
|
+
return [{ uid, ...data }, { uid, created_at: T0, updated_at, deleted_at: null }]
|
|
3853
|
+
},
|
|
3854
|
+
deleteWithMeta: async () => {},
|
|
3855
|
+
findMany: async () => [],
|
|
3856
|
+
})
|
|
3857
|
+
serverApp.createService('sync', {
|
|
3858
|
+
go: async (_modelName, _where, clientMetadataDict) => ({
|
|
3859
|
+
addClient: [],
|
|
3860
|
+
updateClient: [],
|
|
3861
|
+
deleteClient: [],
|
|
3862
|
+
addDatabase: [clientMetadataDict.r1],
|
|
3863
|
+
updateDatabase: [],
|
|
3864
|
+
}),
|
|
3865
|
+
})
|
|
3866
|
+
}, { useOfflinePlugin: true })
|
|
3867
|
+
|
|
3868
|
+
try {
|
|
3869
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3870
|
+
await model.db.values.add({ uid: 'r1', label: 'first' })
|
|
3871
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T1, __dirty__: true })
|
|
3872
|
+
await model.addSynchroWhere({})
|
|
3873
|
+
|
|
3874
|
+
const syncPromise = model.synchronizeAll()
|
|
3875
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
3876
|
+
await model.update('r1', { label: 'second' })
|
|
3877
|
+
createRelease()
|
|
3878
|
+
await syncPromise
|
|
3879
|
+
|
|
3880
|
+
for (let i = 0; i < 50; i++) {
|
|
3881
|
+
const meta = await model.db.metadata.get('r1')
|
|
3882
|
+
if (meta?.__dirty__ === false) break
|
|
3883
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
const value = await model.db.values.get('r1')
|
|
3887
|
+
const meta = await model.db.metadata.get('r1')
|
|
3888
|
+
|
|
3889
|
+
assert.equal(value.label, 'second', 'stale addDatabase rejection must not remove newer local update')
|
|
3890
|
+
assert.ok(meta, 'metadata must remain after stale addDatabase rejection')
|
|
3891
|
+
assert.equal(meta.__dirty__, false, 'newer direct update acknowledgement should remain clean')
|
|
3892
|
+
} finally {
|
|
3893
|
+
await cleanup()
|
|
3894
|
+
}
|
|
3895
|
+
})
|
|
3896
|
+
|
|
3897
|
+
test('update() rollback clears stale updated_at from metadata', async () => {
|
|
3898
|
+
// Optimistic update sets updated_at = now in metadata before the server responds.
|
|
3899
|
+
// On rejection the rollback does db.metadata.update(uid, previousMetadata), but
|
|
3900
|
+
// previousMetadata captured before the update has no updated_at key, so Dexie's
|
|
3901
|
+
// partial update leaves updated_at = now in place.
|
|
3902
|
+
// On the next sync: new Date(stale_now) - new Date(T0) > 0 → spurious updateDatabase.
|
|
3903
|
+
const modelName = `model${++dbCounter}`
|
|
3904
|
+
|
|
3905
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
3906
|
+
serverApp.createService(modelName, {
|
|
3907
|
+
updateWithMeta: async () => { throw new Error('server rejected update') },
|
|
3908
|
+
createWithMeta: async () => {},
|
|
3909
|
+
deleteWithMeta: async () => {},
|
|
3910
|
+
findMany: async () => [],
|
|
3911
|
+
findUnique: async () => null,
|
|
3912
|
+
})
|
|
3913
|
+
serverApp.createService('sync', {
|
|
3914
|
+
go: async () => ({ addClient: [], updateClient: [], deleteClient: [], addDatabase: [], updateDatabase: [] }),
|
|
3915
|
+
})
|
|
3916
|
+
}, { useOfflinePlugin: true })
|
|
3917
|
+
|
|
3918
|
+
try {
|
|
3919
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3920
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3921
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 }) // no updated_at
|
|
3922
|
+
|
|
3923
|
+
// update() optimistically sets updated_at = now, then server rejects
|
|
3924
|
+
await model.update('r1', { label: 'new' })
|
|
3925
|
+
|
|
3926
|
+
// Poll until the rollback restores the value
|
|
3927
|
+
for (let i = 0; i < 50; i++) {
|
|
3928
|
+
await new Promise(r => setTimeout(r, 10))
|
|
3929
|
+
const v = await model.db.values.get('r1')
|
|
3930
|
+
if (v?.label === 'original') break
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
assert.equal((await model.db.values.get('r1'))?.label, 'original', 'value should be rolled back')
|
|
3934
|
+
|
|
3935
|
+
// updated_at must be null/undefined after rollback — a stale timestamp would
|
|
3936
|
+
// cause every subsequent sync to fire a spurious updateDatabase
|
|
3937
|
+
const meta = await model.db.metadata.get('r1')
|
|
3938
|
+
assert.ok(!meta.updated_at, 'updated_at should be null after rollback, not a stale timestamp')
|
|
3939
|
+
} finally {
|
|
3940
|
+
await cleanup()
|
|
3941
|
+
}
|
|
3942
|
+
})
|
|
3943
|
+
|
|
3944
|
+
test('createWithMeta is idempotent: duplicate call does not erase the Dexie record', async () => {
|
|
3945
|
+
// Race condition: create() fires createWithMeta_A and returns immediately.
|
|
3946
|
+
// A concurrent synchronize() snapshots findMany() before _A lands on the server,
|
|
3947
|
+
// so the record appears in addDatabase. The sync fires createWithMeta_B.
|
|
3948
|
+
// The server processes _A first (success), then _B hits a PK conflict on the
|
|
3949
|
+
// model table → throws → addDatabase catch deletes the record from Dexie.
|
|
3950
|
+
// Fix: model INSERT should use ON CONFLICT DO UPDATE just like the metadata INSERT.
|
|
3951
|
+
const modelName = `model${++dbCounter}`
|
|
3952
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
3953
|
+
|
|
3954
|
+
// Server already has r1 (createWithMeta_A already landed)
|
|
3955
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'original' })
|
|
3956
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T0 })
|
|
3957
|
+
|
|
3958
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
3959
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
3960
|
+
{ useOfflinePlugin: true },
|
|
3961
|
+
)
|
|
3962
|
+
|
|
3963
|
+
try {
|
|
3964
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
3965
|
+
// Client has r1 in Dexie from the optimistic create
|
|
3966
|
+
await model.db.values.add({ uid: 'r1', label: 'original' })
|
|
3967
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
3968
|
+
|
|
3969
|
+
// Simulate createWithMeta_B: same call the addDatabase step would make
|
|
3970
|
+
let threw = false
|
|
3971
|
+
try {
|
|
3972
|
+
await clientApp.service(modelName).createWithMeta('r1', { label: 'original' }, T0)
|
|
3973
|
+
} catch (err) {
|
|
3974
|
+
threw = true
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
assert.ok(!threw, 'createWithMeta should not throw when uid already exists on server')
|
|
3978
|
+
|
|
3979
|
+
// The rollback must NOT have run — r1 must still be in Dexie
|
|
3980
|
+
const r1 = await model.db.values.get('r1')
|
|
3981
|
+
assert.ok(r1, 'client Dexie should retain r1 after idempotent createWithMeta')
|
|
3982
|
+
} finally {
|
|
3983
|
+
await cleanup()
|
|
3984
|
+
pglite.close()
|
|
3985
|
+
}
|
|
3986
|
+
})
|
|
3987
|
+
|
|
3988
|
+
test('addClient succeeds even when orphaned metadata already exists in Dexie', async () => {
|
|
3989
|
+
// The deleteWithMeta pub/sub handler does db.metadata.put(meta) which leaves
|
|
3990
|
+
// an orphaned metadata row after the value is deleted. If the same record is
|
|
3991
|
+
// later re-created on the server, addClient tries idbMetadata.add() which
|
|
3992
|
+
// throws a ConstraintError (PK already taken by the orphan), aborting the
|
|
3993
|
+
// entire addClient transaction — the record never arrives in the client cache.
|
|
3994
|
+
const modelName = `model${++dbCounter}`
|
|
3995
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
3996
|
+
|
|
3997
|
+
await db.insert(modelTable).values({ uid: 'r1', label: 'from-server' })
|
|
3998
|
+
await db.insert(metaTable).values({ uid: 'r1', created_at: T2 })
|
|
3999
|
+
|
|
4000
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4001
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
4002
|
+
{ useOfflinePlugin: true },
|
|
4003
|
+
)
|
|
4004
|
+
|
|
4005
|
+
try {
|
|
4006
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4007
|
+
|
|
4008
|
+
// Simulate the orphaned metadata left by a deleteWithMeta pub/sub event:
|
|
4009
|
+
// the value row is gone but the metadata row remains with deleted_at set.
|
|
4010
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, deleted_at: T1 })
|
|
4011
|
+
|
|
4012
|
+
await model.addSynchroWhere({})
|
|
4013
|
+
await model.synchronizeAll()
|
|
4014
|
+
|
|
4015
|
+
const r1 = await model.db.values.get('r1')
|
|
4016
|
+
assert.ok(r1, 'r1 should be pulled into Dexie via addClient despite orphaned metadata')
|
|
4017
|
+
assert.equal(r1.label, 'from-server')
|
|
4018
|
+
} finally {
|
|
4019
|
+
await cleanup()
|
|
4020
|
+
pglite.close()
|
|
4021
|
+
}
|
|
4022
|
+
})
|
|
4023
|
+
|
|
4024
|
+
test('wherePredicate handles boolean equality correctly', async () => {
|
|
4025
|
+
// typeof(true) === 'boolean', which is matched by neither the string/number branch
|
|
4026
|
+
// nor the null branch nor the object branch — so boolean where-values are silently
|
|
4027
|
+
// ignored and every record passes, sending wrong records into clientMetadataDict.
|
|
4028
|
+
const modelName = `model${++dbCounter}`
|
|
4029
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4030
|
+
|
|
4031
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4032
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
4033
|
+
{ useOfflinePlugin: true },
|
|
4034
|
+
)
|
|
4035
|
+
|
|
4036
|
+
try {
|
|
4037
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4038
|
+
|
|
4039
|
+
await model.db.values.add({ uid: 'r1', label: 'active', active: true })
|
|
4040
|
+
await model.db.values.add({ uid: 'r2', label: 'inactive', active: false })
|
|
4041
|
+
|
|
4042
|
+
const results = await model.findWhere({ active: true })
|
|
4043
|
+
const uids = results.map(r => r.uid)
|
|
4044
|
+
|
|
4045
|
+
assert.ok( uids.includes('r1'), 'active=true should match { active: true }')
|
|
4046
|
+
assert.ok(!uids.includes('r2'), 'active=false should NOT match { active: true }')
|
|
4047
|
+
} finally {
|
|
4048
|
+
await cleanup()
|
|
4049
|
+
pglite.close()
|
|
4050
|
+
}
|
|
4051
|
+
})
|
|
4052
|
+
|
|
4053
|
+
test('wherePredicate handles null equality correctly', async () => {
|
|
4054
|
+
// After fixing the TypeError on null, the previous fix used `value !== null`
|
|
4055
|
+
// to guard the object branch — but that leaves null with NO branch at all,
|
|
4056
|
+
// so `where = { user_uid: null }` passes every record instead of only those
|
|
4057
|
+
// with user_uid === null. In synchronize() this causes records with a non-null
|
|
4058
|
+
// user_uid to appear as addDatabase, hit a PK conflict, and get deleted.
|
|
4059
|
+
const modelName = `model${++dbCounter}`
|
|
4060
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4061
|
+
|
|
4062
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4063
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
4064
|
+
{ useOfflinePlugin: true },
|
|
4065
|
+
)
|
|
4066
|
+
|
|
4067
|
+
try {
|
|
4068
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4069
|
+
|
|
4070
|
+
await model.db.values.add({ uid: 'r1', label: 'with-user', user_uid: 'u1' })
|
|
4071
|
+
await model.db.values.add({ uid: 'r2', label: 'no-user', user_uid: null })
|
|
4072
|
+
|
|
4073
|
+
const results = await model.findWhere({ user_uid: null })
|
|
4074
|
+
const uids = results.map(r => r.uid)
|
|
4075
|
+
|
|
4076
|
+
assert.ok(!uids.includes('r1'), 'record with user_uid=u1 should NOT match { user_uid: null }')
|
|
4077
|
+
assert.ok( uids.includes('r2'), 'record with user_uid=null should match { user_uid: null }')
|
|
4078
|
+
} finally {
|
|
4079
|
+
await cleanup()
|
|
4080
|
+
pglite.close()
|
|
4081
|
+
}
|
|
4082
|
+
})
|
|
4083
|
+
|
|
4084
|
+
test('wherePredicate handles Date equality consistently with server sync scope', async () => {
|
|
4085
|
+
const modelName = `model${++dbCounter}`
|
|
4086
|
+
const pglite = new PGlite()
|
|
4087
|
+
await pglite.exec(`
|
|
4088
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
4089
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, start TIMESTAMP NOT NULL);
|
|
4090
|
+
`)
|
|
4091
|
+
const db = drizzle(pglite)
|
|
4092
|
+
const metaTable = pgTable('metadata', {
|
|
4093
|
+
uid: text('uid').primaryKey(),
|
|
4094
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
4095
|
+
})
|
|
4096
|
+
const modelTable = pgTable(modelName, {
|
|
4097
|
+
uid: text('uid').primaryKey(),
|
|
4098
|
+
label: text('label').notNull(),
|
|
4099
|
+
start: timestamp('start').notNull(),
|
|
4100
|
+
})
|
|
4101
|
+
|
|
4102
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4103
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
4104
|
+
{ useOfflinePlugin: true },
|
|
4105
|
+
)
|
|
4106
|
+
|
|
4107
|
+
try {
|
|
4108
|
+
const model = clientApp.createOfflineModel(modelName, ['label', 'start'])
|
|
4109
|
+
await model.db.values.add({ uid: 'out', label: 'outside-scope', start: T2 })
|
|
4110
|
+
await model.db.metadata.add({ uid: 'out', created_at: T0, __dirty__: true })
|
|
4111
|
+
|
|
4112
|
+
assert.deepEqual(await model.findWhere({ start: T1 }), [], 'Date equality should not match a different Date')
|
|
4113
|
+
|
|
4114
|
+
await model.addSynchroWhere({ start: T1 })
|
|
4115
|
+
await model.synchronizeAll()
|
|
4116
|
+
|
|
4117
|
+
const serverRows = await db.select().from(modelTable)
|
|
4118
|
+
assert.equal(serverRows.length, 0, 'Date-scoped sync must not push local rows with a different Date')
|
|
4119
|
+
} finally {
|
|
4120
|
+
await cleanup()
|
|
4121
|
+
pglite.close()
|
|
4122
|
+
}
|
|
4123
|
+
})
|
|
4124
|
+
|
|
4125
|
+
test('wherePredicate handles persisted Date range bounds consistently with server sync scope', async () => {
|
|
4126
|
+
const modelName = `model${++dbCounter}`
|
|
4127
|
+
const pglite = new PGlite()
|
|
4128
|
+
await pglite.exec(`
|
|
4129
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
4130
|
+
CREATE TABLE "${modelName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL, start TIMESTAMP NOT NULL);
|
|
4131
|
+
`)
|
|
4132
|
+
const db = drizzle(pglite)
|
|
4133
|
+
const metaTable = pgTable('metadata', {
|
|
4134
|
+
uid: text('uid').primaryKey(),
|
|
4135
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
4136
|
+
})
|
|
4137
|
+
const modelTable = pgTable(modelName, {
|
|
4138
|
+
uid: text('uid').primaryKey(),
|
|
4139
|
+
label: text('label').notNull(),
|
|
4140
|
+
start: timestamp('start').notNull(),
|
|
4141
|
+
})
|
|
4142
|
+
|
|
4143
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4144
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
4145
|
+
{ useOfflinePlugin: true },
|
|
4146
|
+
)
|
|
4147
|
+
|
|
4148
|
+
try {
|
|
4149
|
+
const model = clientApp.createOfflineModel(modelName, ['label', 'start'])
|
|
4150
|
+
await model.db.values.add({ uid: 'before', label: 'before-range', start: T0 })
|
|
4151
|
+
await model.db.metadata.add({ uid: 'before', created_at: T0, __dirty__: true })
|
|
4152
|
+
|
|
4153
|
+
const persistedWhere = { start: { gte: T1.toISOString() } }
|
|
4154
|
+
assert.deepEqual(await model.findWhere(persistedWhere), [], 'persisted Date range should not match a Date before the lower bound')
|
|
4155
|
+
|
|
4156
|
+
await model.addSynchroWhere(persistedWhere)
|
|
4157
|
+
await model.synchronizeAll()
|
|
4158
|
+
|
|
4159
|
+
const serverRows = await db.select().from(modelTable)
|
|
4160
|
+
assert.equal(serverRows.length, 0, 'Date-range sync must not push local rows outside a serialized Date lower bound')
|
|
4161
|
+
} finally {
|
|
4162
|
+
await cleanup()
|
|
4163
|
+
pglite.close()
|
|
4164
|
+
}
|
|
4165
|
+
})
|
|
4166
|
+
|
|
4167
|
+
test('wherePredicate handles falsy boundary value (lte: 0) correctly', async () => {
|
|
4168
|
+
// wherePredicate is used by synchronize() to build clientMetadataDict.
|
|
4169
|
+
// A falsy boundary (lte: 0) is checked with `if (value.lte)` which treats 0
|
|
4170
|
+
// as "not set", causing ALL records to pass — wrong records enter clientMetadataDict
|
|
4171
|
+
// and the sync pushes them to the server where they fail with PK conflicts,
|
|
4172
|
+
// then the rollback deletes them from Dexie (data loss).
|
|
4173
|
+
const modelName = `model${++dbCounter}`
|
|
4174
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4175
|
+
|
|
4176
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4177
|
+
serverApp => serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable]),
|
|
4178
|
+
{ useOfflinePlugin: true },
|
|
4179
|
+
)
|
|
4180
|
+
|
|
4181
|
+
try {
|
|
4182
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4183
|
+
|
|
4184
|
+
// Dexie stores arbitrary fields — score is not indexed but is queryable
|
|
4185
|
+
await model.db.values.add({ uid: 'pos', label: 'positive', score: 5 })
|
|
4186
|
+
await model.db.values.add({ uid: 'neg', label: 'negative', score: -3 })
|
|
4187
|
+
await model.db.values.add({ uid: 'zero', label: 'zero', score: 0 })
|
|
4188
|
+
|
|
4189
|
+
const results = await model.findWhere({ score: { lte: 0 } })
|
|
4190
|
+
const uids = results.map(r => r.uid)
|
|
4191
|
+
|
|
4192
|
+
assert.ok(!uids.includes('pos'), 'score=5 should NOT match { lte: 0 }')
|
|
4193
|
+
assert.ok( uids.includes('neg'), 'score=-3 should match { lte: 0 }')
|
|
4194
|
+
assert.ok( uids.includes('zero'), 'score=0 should match { lte: 0 }')
|
|
4195
|
+
} finally {
|
|
4196
|
+
await cleanup()
|
|
4197
|
+
pglite.close()
|
|
4198
|
+
}
|
|
4199
|
+
})
|
|
4200
|
+
|
|
4201
|
+
test('failed addDatabase push remains dirty for retry', async () => {
|
|
4202
|
+
const modelName = `model${++dbCounter}`
|
|
4203
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
4204
|
+
serverApp.createService(modelName, {
|
|
4205
|
+
createWithMeta: async () => { throw new Error('temporary failure') },
|
|
4206
|
+
})
|
|
4207
|
+
serverApp.createService('sync', {
|
|
4208
|
+
go: async (_modelName, _where, metadata) => ({
|
|
4209
|
+
addClient: [], updateClient: [], deleteClient: [],
|
|
4210
|
+
addDatabase: [metadata.r1], updateDatabase: [],
|
|
4211
|
+
}),
|
|
4212
|
+
})
|
|
4213
|
+
}, { useOfflinePlugin: true, offlinePluginImpl: localOfflinePlugin })
|
|
4214
|
+
|
|
4215
|
+
try {
|
|
4216
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4217
|
+
await model.db.values.add({ uid: 'r1', label: 'offline' })
|
|
4218
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T1, __dirty__: true })
|
|
4219
|
+
await model.addSynchroWhere({})
|
|
4220
|
+
await model.synchronizeAll()
|
|
4221
|
+
|
|
4222
|
+
assert.equal((await model.db.values.get('r1'))?.label, 'offline')
|
|
4223
|
+
assert.equal((await model.db.metadata.get('r1'))?.__dirty__, true)
|
|
4224
|
+
} finally {
|
|
4225
|
+
await cleanup()
|
|
4226
|
+
}
|
|
4227
|
+
})
|
|
4228
|
+
|
|
4229
|
+
test('failed remove restores the preceding dirty mutation', async () => {
|
|
4230
|
+
const modelName = `model${++dbCounter}`
|
|
4231
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
4232
|
+
serverApp.createService(modelName, {
|
|
4233
|
+
deleteWithMeta: async () => { throw new Error('temporary failure') },
|
|
4234
|
+
})
|
|
4235
|
+
}, { useOfflinePlugin: true, offlinePluginImpl: localOfflinePlugin })
|
|
4236
|
+
|
|
4237
|
+
try {
|
|
4238
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4239
|
+
await model.db.values.add({ uid: 'r1', label: 'pending update' })
|
|
4240
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: T1, __dirty__: true })
|
|
4241
|
+
await model.remove('r1')
|
|
4242
|
+
|
|
4243
|
+
for (let i = 0; i < 50 && (await model.db.values.get('r1'))?.__deleted__; i++) {
|
|
4244
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
assert.equal((await model.db.values.get('r1'))?.__deleted__ ?? null, null)
|
|
4248
|
+
const meta = await model.db.metadata.get('r1')
|
|
4249
|
+
assert.equal(new Date(meta.updated_at).getTime(), T1.getTime())
|
|
4250
|
+
assert.equal(meta.__dirty__, true)
|
|
4251
|
+
} finally {
|
|
4252
|
+
await cleanup()
|
|
4253
|
+
}
|
|
4254
|
+
})
|
|
4255
|
+
|
|
4256
|
+
test('successive local mutations always receive increasing timestamps', async () => {
|
|
4257
|
+
const modelName = `model${++dbCounter}`
|
|
4258
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4259
|
+
() => {},
|
|
4260
|
+
{ useOfflinePlugin: true, offlinePluginImpl: localOfflinePlugin },
|
|
4261
|
+
)
|
|
4262
|
+
|
|
4263
|
+
try {
|
|
4264
|
+
clientApp.isConnected = false
|
|
4265
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4266
|
+
const future = new Date(Date.now() + 60_000)
|
|
4267
|
+
await model.db.values.add({ uid: 'r1', label: 'initial' })
|
|
4268
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0, updated_at: future, __dirty__: true })
|
|
4269
|
+
|
|
4270
|
+
await model.update('r1', { label: 'first' })
|
|
4271
|
+
const first = await model.db.metadata.get('r1')
|
|
4272
|
+
await model.update('r1', { label: 'second' })
|
|
4273
|
+
const second = await model.db.metadata.get('r1')
|
|
4274
|
+
|
|
4275
|
+
assert.ok(new Date(first.updated_at) > future)
|
|
4276
|
+
assert.ok(new Date(second.updated_at) > new Date(first.updated_at))
|
|
4277
|
+
} finally {
|
|
4278
|
+
await cleanup()
|
|
4279
|
+
}
|
|
4280
|
+
})
|
|
4281
|
+
|
|
4282
|
+
test('observable unsubscribe releases its persisted synchronization scope', async () => {
|
|
4283
|
+
const modelName = `model${++dbCounter}`
|
|
4284
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4285
|
+
() => {},
|
|
4286
|
+
{ useOfflinePlugin: true, offlinePluginImpl: localOfflinePlugin },
|
|
4287
|
+
)
|
|
4288
|
+
|
|
4289
|
+
try {
|
|
4290
|
+
clientApp.isConnected = false
|
|
4291
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4292
|
+
const subscription = model.getObservable({ label: 'open' }).subscribe(() => {})
|
|
4293
|
+
|
|
4294
|
+
for (let i = 0; i < 50 && (await model.db.whereList.count()) === 0; i++) {
|
|
4295
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
4296
|
+
}
|
|
4297
|
+
assert.equal(await model.db.whereList.count(), 1)
|
|
4298
|
+
|
|
4299
|
+
subscription.unsubscribe()
|
|
4300
|
+
for (let i = 0; i < 50 && (await model.db.whereList.count()) !== 0; i++) {
|
|
4301
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
4302
|
+
}
|
|
4303
|
+
assert.equal(await model.db.whereList.count(), 0)
|
|
4304
|
+
} finally {
|
|
4305
|
+
await cleanup()
|
|
4306
|
+
}
|
|
4307
|
+
})
|
|
4308
|
+
|
|
4309
|
+
test('reset clears cached records but preserves active synchronization scopes', async () => {
|
|
4310
|
+
const modelName = `model${++dbCounter}`
|
|
4311
|
+
const { clientApp, cleanup } = await createTestContext(
|
|
4312
|
+
() => {},
|
|
4313
|
+
{ useOfflinePlugin: true, offlinePluginImpl: localOfflinePlugin },
|
|
4314
|
+
)
|
|
4315
|
+
|
|
4316
|
+
try {
|
|
4317
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4318
|
+
await model.addSynchroWhere({ label: 'open' })
|
|
4319
|
+
await model.db.values.add({ uid: 'r1', label: 'open' })
|
|
4320
|
+
await model.db.metadata.add({ uid: 'r1', created_at: T0 })
|
|
4321
|
+
|
|
4322
|
+
await model.reset()
|
|
4323
|
+
|
|
4324
|
+
assert.equal(await model.db.values.count(), 0)
|
|
4325
|
+
assert.equal(await model.db.metadata.count(), 0)
|
|
4326
|
+
assert.deepEqual(
|
|
4327
|
+
(await model.db.whereList.toArray()).map(row => row.sortedjson),
|
|
4328
|
+
['{"label":"open"}'],
|
|
4329
|
+
)
|
|
4330
|
+
} finally {
|
|
4331
|
+
await cleanup()
|
|
4332
|
+
}
|
|
4333
|
+
})
|
|
4334
|
+
|
|
4335
|
+
test('local Drizzle plugin rejects invalid mutation timestamps', async () => {
|
|
4336
|
+
const modelName = `model${++dbCounter}`
|
|
4337
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4338
|
+
const serverApp = expressX({})
|
|
4339
|
+
serverApp.configure(localDrizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
4340
|
+
|
|
4341
|
+
try {
|
|
4342
|
+
await assert.rejects(
|
|
4343
|
+
serverApp.service(modelName).createWithMeta('r1', { label: 'invalid' }, 'not-a-date'),
|
|
4344
|
+
/valid timestamp/,
|
|
4345
|
+
)
|
|
4346
|
+
assert.equal((await db.select().from(modelTable)).length, 0)
|
|
4347
|
+
} finally {
|
|
4348
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
4349
|
+
pglite.close()
|
|
4350
|
+
}
|
|
4351
|
+
})
|
|
4352
|
+
|
|
4353
|
+
test('Drizzle plugin requires authorization and restricts sync model names', async () => {
|
|
4354
|
+
const modelName = `model${++dbCounter}`
|
|
4355
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4356
|
+
const unauthorizedApp = expressX({})
|
|
4357
|
+
assert.throws(
|
|
4358
|
+
() => rawDrizzleOfflinePlugin(unauthorizedApp, db, metaTable, [modelTable]),
|
|
4359
|
+
/requires an authorize/,
|
|
4360
|
+
)
|
|
4361
|
+
unauthorizedApp.io.close()
|
|
4362
|
+
|
|
4363
|
+
const serverApp = expressX({})
|
|
4364
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
4365
|
+
try {
|
|
4366
|
+
await assert.rejects(
|
|
4367
|
+
serverApp.service('sync').go('not-configured', {}, {}),
|
|
4368
|
+
/not configured/,
|
|
4369
|
+
)
|
|
4370
|
+
} finally {
|
|
4371
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
4372
|
+
pglite.close()
|
|
4373
|
+
}
|
|
4374
|
+
})
|
|
4375
|
+
|
|
4376
|
+
test('same uid cannot be created in two configured models', async () => {
|
|
4377
|
+
const firstName = `model${++dbCounter}`
|
|
4378
|
+
const secondName = `model${++dbCounter}`
|
|
4379
|
+
const pglite = new PGlite()
|
|
4380
|
+
await pglite.exec(`
|
|
4381
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
4382
|
+
CREATE TABLE "${firstName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL);
|
|
4383
|
+
CREATE TABLE "${secondName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL);
|
|
4384
|
+
`)
|
|
4385
|
+
const db = drizzle(pglite)
|
|
4386
|
+
const metaTable = pgTable('metadata', {
|
|
4387
|
+
uid: text('uid').primaryKey(),
|
|
4388
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
4389
|
+
})
|
|
4390
|
+
const firstTable = pgTable(firstName, {
|
|
4391
|
+
uid: text('uid').primaryKey(), label: text('label').notNull(),
|
|
4392
|
+
})
|
|
4393
|
+
const secondTable = pgTable(secondName, {
|
|
4394
|
+
uid: text('uid').primaryKey(), label: text('label').notNull(),
|
|
4395
|
+
})
|
|
4396
|
+
const serverApp = expressX({})
|
|
4397
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [firstTable, secondTable])
|
|
4398
|
+
|
|
4399
|
+
try {
|
|
4400
|
+
await serverApp.service(firstName).createWithMeta('shared', { label: 'first' }, T0)
|
|
4401
|
+
await assert.rejects(
|
|
4402
|
+
serverApp.service(secondName).createWithMeta('shared', { label: 'second' }, T1),
|
|
4403
|
+
/already belongs/,
|
|
4404
|
+
)
|
|
4405
|
+
} finally {
|
|
4406
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
4407
|
+
pglite.close()
|
|
4408
|
+
}
|
|
4409
|
+
})
|
|
4410
|
+
|
|
4411
|
+
test('concurrent cross-model creates cannot claim the same uid', async () => {
|
|
4412
|
+
const firstName = `model${++dbCounter}`
|
|
4413
|
+
const secondName = `model${++dbCounter}`
|
|
4414
|
+
const pglite = new PGlite()
|
|
4415
|
+
await pglite.exec(`
|
|
4416
|
+
CREATE TABLE metadata (uid TEXT PRIMARY KEY, created_at TIMESTAMP, updated_at TIMESTAMP, deleted_at TIMESTAMP);
|
|
4417
|
+
CREATE TABLE "${firstName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL);
|
|
4418
|
+
CREATE TABLE "${secondName}" (uid TEXT PRIMARY KEY, label TEXT NOT NULL);
|
|
4419
|
+
`)
|
|
4420
|
+
const db = drizzle(pglite)
|
|
4421
|
+
const metaTable = pgTable('metadata', {
|
|
4422
|
+
uid: text('uid').primaryKey(),
|
|
4423
|
+
created_at: timestamp(), updated_at: timestamp(), deleted_at: timestamp(),
|
|
4424
|
+
})
|
|
4425
|
+
const firstTable = pgTable(firstName, { uid: text('uid').primaryKey(), label: text('label').notNull() })
|
|
4426
|
+
const secondTable = pgTable(secondName, { uid: text('uid').primaryKey(), label: text('label').notNull() })
|
|
4427
|
+
const serverApp = expressX({})
|
|
4428
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [firstTable, secondTable])
|
|
4429
|
+
|
|
4430
|
+
try {
|
|
4431
|
+
const results = await Promise.allSettled([
|
|
4432
|
+
serverApp.service(firstName).createWithMeta('shared', { label: 'first' }, T0),
|
|
4433
|
+
serverApp.service(secondName).createWithMeta('shared', { label: 'second' }, T0),
|
|
4434
|
+
])
|
|
4435
|
+
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1)
|
|
4436
|
+
assert.equal(results.filter(result => result.status === 'rejected').length, 1)
|
|
4437
|
+
} finally {
|
|
4438
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
4439
|
+
pglite.close()
|
|
4440
|
+
}
|
|
4441
|
+
})
|
|
4442
|
+
|
|
4443
|
+
test('scoped sync ignores client metadata for a real out-of-scope row', async () => {
|
|
4444
|
+
const modelName = `model${++dbCounter}`
|
|
4445
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4446
|
+
await db.insert(modelTable).values({ uid: 'closed-row', label: 'closed' })
|
|
4447
|
+
await db.insert(metaTable).values({ uid: 'closed-row', created_at: T1 })
|
|
4448
|
+
const serverApp = expressX({})
|
|
4449
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
4450
|
+
|
|
4451
|
+
try {
|
|
4452
|
+
await serverApp.service('sync').go(modelName, { label: 'open' }, {
|
|
4453
|
+
'closed-row': { uid: 'closed-row', created_at: T0 },
|
|
4454
|
+
})
|
|
4455
|
+
assert.equal((await db.select().from(modelTable))[0]?.label, 'closed')
|
|
4456
|
+
} finally {
|
|
4457
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
4458
|
+
pglite.close()
|
|
4459
|
+
}
|
|
4460
|
+
})
|
|
4461
|
+
|
|
4462
|
+
test('offline plugin requires an explicit cache namespace', () => {
|
|
4463
|
+
const serverApp = expressX({})
|
|
4464
|
+
assert.throws(() => rawOfflinePlugin(serverApp), /cacheNamespace/)
|
|
4465
|
+
serverApp.io.close()
|
|
4466
|
+
})
|
|
4467
|
+
|
|
4468
|
+
test('create ignores caller-controlled uid and internal deletion marker', async () => {
|
|
4469
|
+
const modelName = `model${++dbCounter}`
|
|
4470
|
+
const { clientApp, cleanup } = await createTestContext(() => {}, { useOfflinePlugin: true })
|
|
4471
|
+
|
|
4472
|
+
try {
|
|
4473
|
+
clientApp.isConnected = false
|
|
4474
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4475
|
+
const value = await model.create({ uid: 'attacker', label: 'safe', __deleted__: true })
|
|
4476
|
+
|
|
4477
|
+
assert.notEqual(value.uid, 'attacker')
|
|
4478
|
+
assert.equal(value.__deleted__, undefined)
|
|
4479
|
+
assert.equal((await model.db.metadata.get(value.uid))?.uid, value.uid)
|
|
4480
|
+
} finally {
|
|
4481
|
+
await cleanup()
|
|
4482
|
+
}
|
|
4483
|
+
})
|
|
4484
|
+
|
|
4485
|
+
test('malformed create acknowledgement leaves the local mutation dirty', async () => {
|
|
4486
|
+
const modelName = `model${++dbCounter}`
|
|
4487
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
4488
|
+
serverApp.createService(modelName, { createWithMeta: async () => undefined })
|
|
4489
|
+
}, { useOfflinePlugin: true })
|
|
4490
|
+
|
|
4491
|
+
try {
|
|
4492
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4493
|
+
const value = await model.create({ label: 'keep' })
|
|
4494
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
4495
|
+
|
|
4496
|
+
assert.equal((await model.db.values.get(value.uid))?.label, 'keep')
|
|
4497
|
+
assert.equal((await model.db.metadata.get(value.uid))?.__dirty__, true)
|
|
4498
|
+
} finally {
|
|
4499
|
+
await cleanup()
|
|
4500
|
+
}
|
|
4501
|
+
})
|
|
4502
|
+
|
|
4503
|
+
test('mismatched sync tuple is rejected before changing the cache', async () => {
|
|
4504
|
+
const modelName = `model${++dbCounter}`
|
|
4505
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
4506
|
+
serverApp.createService(modelName, {})
|
|
4507
|
+
serverApp.createService('sync', {
|
|
4508
|
+
go: async () => ({
|
|
4509
|
+
addClient: [[
|
|
4510
|
+
{ uid: 'value-id', label: 'bad' },
|
|
4511
|
+
{ uid: 'metadata-id', created_at: T0 },
|
|
4512
|
+
]],
|
|
4513
|
+
updateClient: [],
|
|
4514
|
+
deleteClient: [],
|
|
4515
|
+
addDatabase: [],
|
|
4516
|
+
updateDatabase: [],
|
|
4517
|
+
}),
|
|
4518
|
+
})
|
|
4519
|
+
}, { useOfflinePlugin: true })
|
|
4520
|
+
|
|
4521
|
+
try {
|
|
4522
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4523
|
+
await model.addSynchroWhere({})
|
|
4524
|
+
await assert.rejects(() => model.synchronizeAll(), /uid/)
|
|
4525
|
+
assert.equal(await model.db.values.count(), 0)
|
|
4526
|
+
assert.equal(await model.db.metadata.count(), 0)
|
|
4527
|
+
} finally {
|
|
4528
|
+
await cleanup()
|
|
4529
|
+
}
|
|
4530
|
+
})
|
|
4531
|
+
|
|
4532
|
+
test('non-JSON-safe synchronization filters are rejected', async () => {
|
|
4533
|
+
const modelName = `model${++dbCounter}`
|
|
4534
|
+
const { clientApp, cleanup } = await createTestContext(() => {}, { useOfflinePlugin: true })
|
|
4535
|
+
|
|
4536
|
+
try {
|
|
4537
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4538
|
+
assert.throws(() => model.addSynchroWhere({ tenant: undefined }), /non-serializable/)
|
|
4539
|
+
assert.throws(() => model.addSynchroWhere({ score: Number.NaN }), /non-finite/)
|
|
4540
|
+
assert.equal(await model.db.whereList.count(), 0)
|
|
4541
|
+
} finally {
|
|
4542
|
+
await cleanup()
|
|
4543
|
+
}
|
|
4544
|
+
})
|
|
4545
|
+
|
|
4546
|
+
test('timestamp-free acknowledgement leaves mutation dirty', async () => {
|
|
4547
|
+
const modelName = `model${++dbCounter}`
|
|
4548
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
4549
|
+
serverApp.createService(modelName, {
|
|
4550
|
+
createWithMeta: async (uid, data) => [{ uid, ...data }, { uid }],
|
|
4551
|
+
})
|
|
4552
|
+
}, { useOfflinePlugin: true })
|
|
4553
|
+
|
|
4554
|
+
try {
|
|
4555
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4556
|
+
const value = await model.create({ label: 'keep' })
|
|
4557
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
4558
|
+
assert.equal((await model.db.metadata.get(value.uid))?.__dirty__, true)
|
|
4559
|
+
} finally {
|
|
4560
|
+
await cleanup()
|
|
4561
|
+
}
|
|
4562
|
+
})
|
|
4563
|
+
|
|
4564
|
+
test('server values cannot inject the internal deletion marker', async () => {
|
|
4565
|
+
const modelName = `model${++dbCounter}`
|
|
4566
|
+
const { clientApp, cleanup } = await createTestContext(serverApp => {
|
|
4567
|
+
serverApp.createService(modelName, {})
|
|
4568
|
+
serverApp.createService('sync', {
|
|
4569
|
+
go: async () => ({
|
|
4570
|
+
addClient: [[
|
|
4571
|
+
{ uid: 'r1', label: 'visible', __deleted__: true },
|
|
4572
|
+
{ uid: 'r1', created_at: T0 },
|
|
4573
|
+
]],
|
|
4574
|
+
updateClient: [],
|
|
4575
|
+
deleteClient: [],
|
|
4576
|
+
addDatabase: [],
|
|
4577
|
+
updateDatabase: [],
|
|
4578
|
+
}),
|
|
4579
|
+
})
|
|
4580
|
+
}, { useOfflinePlugin: true })
|
|
4581
|
+
|
|
4582
|
+
try {
|
|
4583
|
+
const model = clientApp.createOfflineModel(modelName, ['label'])
|
|
4584
|
+
await model.addSynchroWhere({})
|
|
4585
|
+
await model.synchronizeAll()
|
|
4586
|
+
assert.equal((await model.findWhere({}))[0]?.label, 'visible')
|
|
4587
|
+
} finally {
|
|
4588
|
+
await cleanup()
|
|
4589
|
+
}
|
|
4590
|
+
})
|
|
4591
|
+
|
|
4592
|
+
test('pub/sub createWithMeta event correctly updates a second client\'s Dexie', async () => {
|
|
4593
|
+
const modelName = `model${++dbCounter}`
|
|
4594
|
+
const { pglite, db, metaTable, modelTable } = await createTestDb(modelName)
|
|
4595
|
+
|
|
4596
|
+
// Server with pub/sub: every client joins 'all' and createWithMeta broadcasts there
|
|
4597
|
+
const serverApp = expressX({})
|
|
4598
|
+
serverApp.configure(drizzleOfflinePlugin, db, metaTable, [modelTable])
|
|
4599
|
+
serverApp.addConnectListener(socket => serverApp.joinChannel('all', socket))
|
|
4600
|
+
serverApp.service(modelName).publish(async () => ['all'])
|
|
4601
|
+
await new Promise(resolve => serverApp.httpServer.listen(0, resolve))
|
|
4602
|
+
const port = serverApp.httpServer.address().port
|
|
4603
|
+
|
|
4604
|
+
function connectClient() {
|
|
4605
|
+
const socket = ioc(`http://localhost:${port}`, { transports: ['websocket'], autoConnect: false })
|
|
4606
|
+
const app = createClient(socket, { debug: false })
|
|
4607
|
+
offlinePlugin(app)
|
|
4608
|
+
socket.connect()
|
|
4609
|
+
return new Promise((resolve, reject) => {
|
|
4610
|
+
socket.on('connect', () => resolve({ app, socket }))
|
|
4611
|
+
socket.on('connect_error', reject)
|
|
4612
|
+
})
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
const { app: appA, socket: socketA } = await connectClient()
|
|
4616
|
+
const { app: appB, socket: socketB } = await connectClient()
|
|
4617
|
+
|
|
4618
|
+
const modelA = appA.createOfflineModel(modelName, ['label'])
|
|
4619
|
+
const modelB = appB.createOfflineModel(modelName, ['label'])
|
|
4620
|
+
|
|
4621
|
+
try {
|
|
4622
|
+
// Client A creates a record while connected — fires createWithMeta directly
|
|
4623
|
+
const record = await modelA.create({ label: 'from-A' })
|
|
4624
|
+
|
|
4625
|
+
// Poll until the pub/sub event reaches client B's Dexie
|
|
4626
|
+
let inB = null
|
|
4627
|
+
for (let i = 0; i < 50; i++) {
|
|
4628
|
+
await new Promise(r => setTimeout(r, 10))
|
|
4629
|
+
inB = await modelB.db.values.get(record.uid)
|
|
4630
|
+
if (inB) break
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
assert.ok(inB, 'Client B should receive the record via pub/sub')
|
|
4634
|
+
assert.equal(inB.label, 'from-A', 'Client B should have the correct label')
|
|
4635
|
+
} finally {
|
|
4636
|
+
socketA.disconnect()
|
|
4637
|
+
socketB.disconnect()
|
|
4638
|
+
await new Promise(resolve => serverApp.io.close(resolve))
|
|
4639
|
+
pglite.close()
|
|
4640
|
+
}
|
|
4641
|
+
})
|
|
4642
|
+
|
|
4643
|
+
})
|