@jcbuisson/express-x-plugins 5.0.4 → 5.0.6
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
CHANGED
|
@@ -1,24 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
# express-x-plugins
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
IMPORTANT FOR ELECTRIC
|
|
6
|
-
|
|
7
|
-
# set lc_messages=C for the PostgreSQL role so Electric receives recognizable English errors
|
|
8
|
-
ALTER ROLE chris SET lc_messages = 'C';
|
|
9
|
-
|
|
10
|
-
# ALLOW POSTGRES LOGICAL REPLICATION
|
|
11
|
-
ALTER SYSTEM SET wal_level = 'logical';
|
|
12
|
-
ALTER SYSTEM SET max_replication_slots = 10;
|
|
13
|
-
ALTER SYSTEM SET max_wal_senders = 10;
|
|
14
|
-
ALTER ROLE chris WITH REPLICATION;
|
|
15
|
-
|
|
16
|
-
(restart postgres)
|
|
17
|
-
|
|
18
|
-
## Use HTTP2
|
|
19
|
-
nginx: `listen 443 ssl http2;`
|
|
20
|
-
|
|
21
|
-
|
|
22
4
|
Currently includes:
|
|
23
5
|
|
|
24
6
|
- a plugin which preserves room membership and socket data across page reloads
|
|
@@ -33,19 +15,39 @@ npm install @jcbuisson/express-x-plugins
|
|
|
33
15
|
```
|
|
34
16
|
|
|
35
17
|
|
|
36
|
-
##
|
|
18
|
+
## Electric plugin: local-first Postgres
|
|
37
19
|
|
|
38
|
-
|
|
20
|
+
Express-X handles authorized PostgreSQL mutations; Electric streams committed rows to clients.
|
|
39
21
|
|
|
40
|
-
|
|
41
|
-
|
|
22
|
+
The electric plugin has two separate elections:
|
|
23
|
+
|
|
24
|
+
- PGlite leader: owns the local database engine. IndexedDB shares the stored data, but it doesn’t share the running PostgreSQL engine.
|
|
25
|
+
Each PGlite instance has its own in-memory state: caches, transaction state, and buffers.
|
|
26
|
+
- Sync owner: chosen by a separate Web Lock per model; runs the Electric stream and sends queued mutations to the server.
|
|
27
|
+
|
|
28
|
+
They prevent duplicate database engines and duplicate synchronization across tabs. The database leader and sync owner can be different tabs.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Settings
|
|
32
|
+
```bash
|
|
33
|
+
# set lc_messages=C for the PostgreSQL role so Electric receives recognizable English errors
|
|
34
|
+
ALTER ROLE chris SET lc_messages = 'C';
|
|
35
|
+
|
|
36
|
+
# ALLOW POSTGRES LOGICAL REPLICATION
|
|
37
|
+
ALTER SYSTEM SET wal_level = 'logical';
|
|
38
|
+
ALTER SYSTEM SET max_replication_slots = 10;
|
|
39
|
+
ALTER SYSTEM SET max_wal_senders = 10;
|
|
40
|
+
ALTER ROLE chris WITH REPLICATION;
|
|
41
|
+
|
|
42
|
+
(restart postgres)
|
|
42
43
|
```
|
|
43
44
|
|
|
45
|
+
(?
|
|
46
|
+
## Use HTTP2
|
|
47
|
+
nginx: `listen 443 ssl http2;`
|
|
48
|
+
)
|
|
44
49
|
|
|
45
|
-
## Local-first Postgres plugin
|
|
46
50
|
|
|
47
|
-
Express-X handles authorized PostgreSQL mutations; Electric streams committed rows to clients.
|
|
48
|
-
For durable offline writes, use sync mode with a shared PGlite database.
|
|
49
51
|
|
|
50
52
|
### Server
|
|
51
53
|
|
|
@@ -343,3 +345,23 @@ volumes:
|
|
|
343
345
|
docker compose pull electric
|
|
344
346
|
docker compose up electric
|
|
345
347
|
```
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
## Reload plugin
|
|
352
|
+
|
|
353
|
+
### Server
|
|
354
|
+
|
|
355
|
+
```js
|
|
356
|
+
import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
Managed sync automatically pauses network ownership when `navigator.onLine` is
|
|
361
|
+
false and resumes active models on the browser's `online` event. Local reads,
|
|
362
|
+
writes, queued edits, and cross-tab notifications remain available while offline.
|
|
363
|
+
A model explicitly stopped with `model.stop()` stays stopped after reconnection.
|
|
364
|
+
Disposing a model removes its network listeners. Browser connectivity is a hint;
|
|
365
|
+
requests can still fail while reported online and retain the usual retry behavior.
|
|
366
|
+
For custom runtimes/tests, plugin options `network` and `networkEvents` can supply
|
|
367
|
+
the connectivity state and an EventTarget respectively.
|
package/package.json
CHANGED
|
@@ -58,13 +58,15 @@ export function createManagedSync(options, createModel) {
|
|
|
58
58
|
let channel
|
|
59
59
|
let offline
|
|
60
60
|
let error = null
|
|
61
|
+
const network = modelOptions.network ?? options.network ?? globalThis.navigator
|
|
62
|
+
const networkEvents = options.networkEvents ?? globalThis
|
|
61
63
|
function report(cause) {
|
|
62
64
|
error = cause
|
|
63
65
|
try { (modelOptions.onError ?? options.onError)?.(cause, { modelName: name }) } catch (callbackError) { console.error(callbackError) }
|
|
64
66
|
}
|
|
65
67
|
const ready = getDatabase().then(async db => {
|
|
66
68
|
channel = new Channel(`express-x:${databaseName}:${name}`)
|
|
67
|
-
offline = createModel(name, { ...modelOptions, localDb: db, channel, ownsSync: false, onError: report })
|
|
69
|
+
offline = createModel(name, { ...modelOptions, network, localDb: db, channel, ownsSync: false, onError: report })
|
|
68
70
|
const prepared = preparation.then(() => offline.prepare())
|
|
69
71
|
preparation = prepared.catch(() => {})
|
|
70
72
|
await prepared
|
|
@@ -78,10 +80,11 @@ export function createManagedSync(options, createModel) {
|
|
|
78
80
|
function startOwnership() {
|
|
79
81
|
if (controller || disposed || !active) return
|
|
80
82
|
offline.start()
|
|
83
|
+
if (network?.onLine === false) return
|
|
81
84
|
controller = new AbortController()
|
|
82
85
|
const ownerGeneration = ++generation
|
|
83
86
|
void locks.request(`express-x:${databaseName}:${name}`, { signal: controller.signal }, async () => {
|
|
84
|
-
if (!active || disposed || generation !== ownerGeneration) return
|
|
87
|
+
if (!active || disposed || network?.onLine === false || generation !== ownerGeneration) return
|
|
85
88
|
try {
|
|
86
89
|
offline.setSyncOwner(true)
|
|
87
90
|
await new Promise(resolve => { release = resolve })
|
|
@@ -91,16 +94,26 @@ export function createManagedSync(options, createModel) {
|
|
|
91
94
|
}
|
|
92
95
|
}).catch(cause => { if (cause.name !== 'AbortError') report(cause) })
|
|
93
96
|
}
|
|
94
|
-
function
|
|
95
|
-
active = false
|
|
97
|
+
function releaseOwnership() {
|
|
96
98
|
++generation
|
|
97
99
|
controller?.abort()
|
|
98
100
|
controller = undefined
|
|
99
101
|
release?.()
|
|
100
102
|
release = undefined
|
|
101
103
|
offline?.setSyncOwner(false)
|
|
104
|
+
}
|
|
105
|
+
function stop() {
|
|
106
|
+
active = false
|
|
107
|
+
releaseOwnership()
|
|
102
108
|
offline?.stop()
|
|
103
109
|
}
|
|
110
|
+
function updateNetwork() {
|
|
111
|
+
if (disposed) return
|
|
112
|
+
if (network?.onLine === false) releaseOwnership()
|
|
113
|
+
else if (active) void ready.then(startOwnership).catch(report)
|
|
114
|
+
}
|
|
115
|
+
networkEvents.addEventListener?.('offline', updateNetwork)
|
|
116
|
+
networkEvents.addEventListener?.('online', updateNetwork)
|
|
104
117
|
function start() {
|
|
105
118
|
if (disposed) throw new Error('Electric model has been disposed')
|
|
106
119
|
active = true
|
|
@@ -115,6 +128,8 @@ export function createManagedSync(options, createModel) {
|
|
|
115
128
|
async dispose() {
|
|
116
129
|
if (disposed) return
|
|
117
130
|
disposed = true
|
|
131
|
+
networkEvents.removeEventListener?.('offline', updateNetwork)
|
|
132
|
+
networkEvents.removeEventListener?.('online', updateNetwork)
|
|
118
133
|
stop()
|
|
119
134
|
try { await ready; await offline.waitForIdle() } finally { channel?.close(); models.delete(name) }
|
|
120
135
|
},
|
|
@@ -284,6 +284,9 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
|
|
|
284
284
|
if (!started) return
|
|
285
285
|
started = false
|
|
286
286
|
++streamGeneration
|
|
287
|
+
// Drain previously queued snapshots, then invalidate this stream's view
|
|
288
|
+
// before a later flush can reconcile against another owner's shared rows.
|
|
289
|
+
background(resetSnapshot())
|
|
287
290
|
clearInterval(timer)
|
|
288
291
|
stream?.unsubscribeAll()
|
|
289
292
|
channel?.removeEventListener('message', sharedChange)
|
|
@@ -152,7 +152,8 @@ export function electricServerPlugin(app, db, models, options = {}) {
|
|
|
152
152
|
return { shapePath, sync, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
// Run once before accepting sync mutations: adds columns 'version' and 'deleted' to each table
|
|
156
|
+
// Existing rows receive a version
|
|
156
157
|
export async function prepareElectricSyncSchema(db, models) {
|
|
157
158
|
const configuredModels = normalizeModels(models)
|
|
158
159
|
await db.query('CREATE SEQUENCE IF NOT EXISTS electric_sync_version_seq')
|
|
@@ -185,8 +186,12 @@ async function syncMutation(db, model, action, id, data, mutation) {
|
|
|
185
186
|
WHERE table_name = $1 AND client_id = $2 AND row_id = $3 FOR UPDATE`, key)
|
|
186
187
|
const cursor = rows[0]
|
|
187
188
|
if (BigInt(revision) <= BigInt(cursor.revision)) return cursor.result
|
|
189
|
+
// A committed create may have lost its response. Later local edits still
|
|
190
|
+
// arrive as create, but must update the row this client already wrote.
|
|
191
|
+
const editedCreate = action === 'create' && BigInt(cursor.revision) > 0n
|
|
192
|
+
&& Object.entries(data).some(([column, value]) => column !== model.primaryKey && value !== undefined)
|
|
188
193
|
let result
|
|
189
|
-
if (action === 'create') {
|
|
194
|
+
if (action === 'create' && !editedCreate) {
|
|
190
195
|
const entries = Object.entries({ ...data, [model.primaryKey]: id }).filter(([, value]) => value !== undefined)
|
|
191
196
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
192
197
|
const values = entries.map(([, value]) => value)
|
|
@@ -195,7 +200,7 @@ async function syncMutation(db, model, action, id, data, mutation) {
|
|
|
195
200
|
ON CONFLICT (${model.quotedPrimaryKey}) DO UPDATE SET ${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}
|
|
196
201
|
RETURNING *`, values)
|
|
197
202
|
result = inserted.rows[0]
|
|
198
|
-
} else if (action === 'update') {
|
|
203
|
+
} else if (action === 'update' || editedCreate) {
|
|
199
204
|
const set = buildSet(data, model.primaryKey)
|
|
200
205
|
const updated = await tx.query(`UPDATE ${model.quotedTable} SET ${set.sql}, version = nextval('electric_sync_version_seq')
|
|
201
206
|
WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} AND NOT deleted RETURNING *`, [...set.values, id])
|
|
@@ -152,6 +152,56 @@ test('stopping a waiting follower cancels takeover; restart can acquire ownershi
|
|
|
152
152
|
}
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
+
test('a returning sync owner preserves shared rows until its new snapshot arrives', async () => {
|
|
156
|
+
const db = new PGlite()
|
|
157
|
+
const locks = new Locks()
|
|
158
|
+
const calls = []
|
|
159
|
+
const service = {
|
|
160
|
+
async create(id, data) {
|
|
161
|
+
calls.push(id)
|
|
162
|
+
return { id, ...data, version: '3', deleted: false }
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
const first = configure(db, locks, service)
|
|
166
|
+
const second = configure(db, locks, service)
|
|
167
|
+
const before = Stream.instances.length
|
|
168
|
+
const original = { id: crypto.randomUUID(), title: 'original', version: '1', deleted: false }
|
|
169
|
+
const changed = { ...original, title: 'updated by next owner', version: '2' }
|
|
170
|
+
const added = { id: crypto.randomUUID(), title: 'added by next owner', version: '2', deleted: false }
|
|
171
|
+
try {
|
|
172
|
+
const owner = first.createElectricModel('todos')
|
|
173
|
+
const follower = second.createElectricModel('todos')
|
|
174
|
+
await owner.prepare(); await follower.prepare()
|
|
175
|
+
await until(() => Stream.instances.length === before + 1)
|
|
176
|
+
Stream.instances.at(-1).shape.emit({ rows: [original] })
|
|
177
|
+
await until(async () => (await owner.findMany()).length === 1)
|
|
178
|
+
owner.stop()
|
|
179
|
+
await until(() => Stream.instances.length === before + 2)
|
|
180
|
+
Stream.instances.at(-1).shape.emit({ rows: [changed, added] })
|
|
181
|
+
await until(async () => (await follower.findMany()).length === 2)
|
|
182
|
+
|
|
183
|
+
follower.stop()
|
|
184
|
+
await owner.start()
|
|
185
|
+
await until(() => Stream.instances.length === before + 3)
|
|
186
|
+
// Flush before the returning owner's stream delivers any snapshot.
|
|
187
|
+
const created = await owner.create({ title: 'created after restart' })
|
|
188
|
+
await until(async () => {
|
|
189
|
+
const { rows } = await db.query('SELECT acknowledged_version FROM electric_mutation_queue WHERE row_id = $1', [created.id])
|
|
190
|
+
return rows[0]?.acknowledged_version != null
|
|
191
|
+
})
|
|
192
|
+
assert.equal(calls.length, 1)
|
|
193
|
+
assert.deepEqual(await owner.findUnique({ id: original.id }), changed)
|
|
194
|
+
assert.deepEqual(await owner.findUnique({ id: added.id }), added)
|
|
195
|
+
assert.equal((await owner.getStatus()).pending, 1)
|
|
196
|
+
|
|
197
|
+
Stream.instances.at(-1).shape.emit({ rows: [changed, added, { ...created, version: '3', deleted: false }] })
|
|
198
|
+
await until(async () => (await owner.getStatus()).pending === 0)
|
|
199
|
+
assert.equal((await owner.findMany()).length, 3)
|
|
200
|
+
} finally {
|
|
201
|
+
await first.disposeElectricSync(); await second.disposeElectricSync(); await db.close()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
155
205
|
test('initialization errors reject writes and observable reads and call onError', async () => {
|
|
156
206
|
const error = new Error('Worker could not load assets')
|
|
157
207
|
const reported = []
|
|
@@ -252,3 +302,62 @@ test('an indeterminate foreground mutation is not replayed automatically', async
|
|
|
252
302
|
assert.equal((await model.getStatus()).pending, 1)
|
|
253
303
|
} finally { await db.close() }
|
|
254
304
|
})
|
|
305
|
+
|
|
306
|
+
test('network lifecycle preserves offline edits, transfers ownership and respects manual stop', async () => {
|
|
307
|
+
const db = new PGlite()
|
|
308
|
+
const locks = new Locks()
|
|
309
|
+
const events = new EventTarget()
|
|
310
|
+
const network = { onLine: false }
|
|
311
|
+
const calls = []
|
|
312
|
+
const service = { async create(id, data) { calls.push(id); return { ...data, id, version: '1' } } }
|
|
313
|
+
const first = configure(db, locks, service, { network, networkEvents: events })
|
|
314
|
+
const second = configure(db, locks, service, { network, networkEvents: events })
|
|
315
|
+
const before = Stream.instances.length
|
|
316
|
+
let subscription
|
|
317
|
+
try {
|
|
318
|
+
const owner = first.createElectricModel('todos')
|
|
319
|
+
const follower = second.createElectricModel('todos')
|
|
320
|
+
await owner.prepare(); await follower.prepare()
|
|
321
|
+
let rows = []
|
|
322
|
+
subscription = follower.getObservable().subscribe(value => { rows = value })
|
|
323
|
+
const created = await owner.create({ title: 'Created offline' })
|
|
324
|
+
await until(() => rows.length === 1)
|
|
325
|
+
assert.equal(Stream.instances.length, before)
|
|
326
|
+
assert.equal(calls.length, 0)
|
|
327
|
+
assert.equal((await owner.getStatus()).pending, 1)
|
|
328
|
+
|
|
329
|
+
network.onLine = true
|
|
330
|
+
events.dispatchEvent(new Event('online'))
|
|
331
|
+
await until(() => Stream.instances.length === before + 1 && calls.length === 1)
|
|
332
|
+
const stream = Stream.instances.at(-1)
|
|
333
|
+
stream.shape.emit({ rows: [{ ...created, version: '1', deleted: false }] })
|
|
334
|
+
await until(async () => (await follower.getStatus()).pending === 0)
|
|
335
|
+
|
|
336
|
+
network.onLine = false
|
|
337
|
+
events.dispatchEvent(new Event('offline'))
|
|
338
|
+
await until(() => stream.stopped === true)
|
|
339
|
+
await follower.update(created.id, { title: 'Updated offline' })
|
|
340
|
+
assert.equal((await owner.findMany())[0].title, 'Updated offline')
|
|
341
|
+
assert.equal((await follower.getStatus()).pending, 1)
|
|
342
|
+
assert.equal(Stream.instances.length, before + 1)
|
|
343
|
+
|
|
344
|
+
owner.stop()
|
|
345
|
+
network.onLine = true
|
|
346
|
+
events.dispatchEvent(new Event('online'))
|
|
347
|
+
await until(() => Stream.instances.length === before + 2)
|
|
348
|
+
follower.stop()
|
|
349
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
350
|
+
assert.equal(Stream.instances.length, before + 2, 'manually stopped owner must not take over')
|
|
351
|
+
await owner.start()
|
|
352
|
+
await until(() => Stream.instances.length === before + 3)
|
|
353
|
+
await first.disposeElectricSync()
|
|
354
|
+
await second.disposeElectricSync()
|
|
355
|
+
events.dispatchEvent(new Event('offline'))
|
|
356
|
+
events.dispatchEvent(new Event('online'))
|
|
357
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
358
|
+
assert.equal(Stream.instances.length, before + 3)
|
|
359
|
+
} finally {
|
|
360
|
+
subscription?.unsubscribe()
|
|
361
|
+
await first.disposeElectricSync(); await second.disposeElectricSync(); await db.close()
|
|
362
|
+
}
|
|
363
|
+
})
|
|
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import { PGlite } from '@electric-sql/pglite'
|
|
4
4
|
import { electricClientPlugin } from '../src/electric-client-plugin.mjs'
|
|
5
|
+
import { electricServerPlugin, prepareElectricSyncSchema } from '../src/electric-server-plugin.mjs'
|
|
5
6
|
|
|
6
7
|
class Stream {
|
|
7
8
|
subscribe() { return () => {} }
|
|
@@ -15,6 +16,70 @@ class Shape {
|
|
|
15
16
|
subscribe(callback) { this.emit = callback; return () => {} }
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
test('edits survive a committed create whose response was lost', async () => {
|
|
20
|
+
const server = new PGlite()
|
|
21
|
+
const db = new PGlite()
|
|
22
|
+
let model
|
|
23
|
+
try {
|
|
24
|
+
await server.exec('CREATE TABLE todos (id UUID PRIMARY KEY, title TEXT NOT NULL)')
|
|
25
|
+
await prepareElectricSyncSchema(server, [{ name: 'todos', tombstoneData: { title: '' } }])
|
|
26
|
+
let service
|
|
27
|
+
const pool = {
|
|
28
|
+
query: server.query.bind(server),
|
|
29
|
+
async connect() { return { query: server.query.bind(server), release() {} } },
|
|
30
|
+
}
|
|
31
|
+
electricServerPlugin({ createService(_name, methods) { service = methods }, get() {} }, pool,
|
|
32
|
+
[{ name: 'todos', tombstoneData: { title: '' } }], { sync: true })
|
|
33
|
+
let loseResponse = true
|
|
34
|
+
const requests = []
|
|
35
|
+
const proxy = {
|
|
36
|
+
async create(...args) {
|
|
37
|
+
requests.push(args)
|
|
38
|
+
const result = await service.create(...args)
|
|
39
|
+
if (loseResponse) { loseResponse = false; throw new Error('response lost') }
|
|
40
|
+
return result
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
const app = { service: () => proxy }
|
|
44
|
+
electricClientPlugin(app, { ShapeStream: Stream, Shape })
|
|
45
|
+
model = app.createElectricModel('todos', { localDb: db, retryMs: 60_000 })
|
|
46
|
+
await model.prepare()
|
|
47
|
+
const row = await model.create({ title: 'original' })
|
|
48
|
+
await model.flushQueue()
|
|
49
|
+
const original = (await server.query('SELECT * FROM todos')).rows[0]
|
|
50
|
+
await model.update(row.id, { title: 'edited' })
|
|
51
|
+
await model.flushQueue()
|
|
52
|
+
const edited = (await server.query('SELECT * FROM todos')).rows[0]
|
|
53
|
+
assert.equal(edited.title, 'edited')
|
|
54
|
+
assert.ok(BigInt(edited.version) > BigInt(original.version))
|
|
55
|
+
|
|
56
|
+
// Replaying either revision must preserve the edited row and its version.
|
|
57
|
+
await service.create(...requests[0])
|
|
58
|
+
await service.create(...requests[1])
|
|
59
|
+
assert.deepEqual((await server.query('SELECT * FROM todos')).rows, [edited])
|
|
60
|
+
model.start()
|
|
61
|
+
Shape.current.emit({ rows: [edited] })
|
|
62
|
+
await model.waitForIdle()
|
|
63
|
+
assert.equal((await model.findUnique({ id: row.id })).title, 'edited')
|
|
64
|
+
assert.equal((await model.getStatus()).pending, 0)
|
|
65
|
+
|
|
66
|
+
// Another client's colliding create still cannot overwrite the row.
|
|
67
|
+
await service.create(row.id, { title: 'collision' }, { clientId: crypto.randomUUID(), revision: '1' })
|
|
68
|
+
assert.deepEqual((await server.query('SELECT * FROM todos')).rows, [edited])
|
|
69
|
+
|
|
70
|
+
// A delayed edited create must not resurrect a tombstone.
|
|
71
|
+
const metadata = requests[1][2]
|
|
72
|
+
await service.delete(row.id, { ...metadata, revision: '100' })
|
|
73
|
+
const tombstone = await service.create(row.id, { title: 'delayed' }, { ...metadata, revision: '101' })
|
|
74
|
+
assert.equal(tombstone.deleted, true)
|
|
75
|
+
} finally {
|
|
76
|
+
model?.stop()
|
|
77
|
+
await model?.waitForIdle()
|
|
78
|
+
await db.close()
|
|
79
|
+
await server.close()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
18
83
|
test('offline writes stay local until the acknowledged Electric version arrives', async () => {
|
|
19
84
|
const db = new PGlite()
|
|
20
85
|
const calls = []
|