@jcbuisson/express-x-plugins 5.0.4 → 5.0.7
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 +59 -27
- package/package.json +1 -1
- package/src/electric-client-plugin.mjs +4 -4
- package/src/electric-managed-sync.mjs +29 -6
- package/src/electric-offline-model.mjs +3 -0
- package/src/electric-server-plugin.mjs +9 -4
- package/test/electric-managed-sync.test.mjs +132 -10
- package/test/electric-offline-model.test.mjs +67 -2
- package/test/electric-server-plugin.test.mjs +9 -2
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
|
|
|
@@ -148,11 +150,12 @@ app.configure(electricClientPlugin, {
|
|
|
148
150
|
shapePath: '/electric/v1/shape',
|
|
149
151
|
})
|
|
150
152
|
|
|
151
|
-
// Client-generated UUID stored in the default `
|
|
153
|
+
// Client-generated UUID stored in the default `uid` primary key:
|
|
152
154
|
const todo = app.createElectricModel('todos')
|
|
153
155
|
|
|
154
156
|
// Or, for a database-generated primary key such as SERIAL/IDENTITY:
|
|
155
157
|
const numberedTodo = app.createElectricModel('numberedTodos', {
|
|
158
|
+
primaryKey: 'id',
|
|
156
159
|
idGeneration: 'server',
|
|
157
160
|
})
|
|
158
161
|
|
|
@@ -190,7 +193,7 @@ app.configure(electricClientPlugin, {
|
|
|
190
193
|
onError: (error, { modelName }) => console.error(modelName, error),
|
|
191
194
|
})
|
|
192
195
|
|
|
193
|
-
const ranges = app.createElectricModel('range'
|
|
196
|
+
const ranges = app.createElectricModel('range')
|
|
194
197
|
const subscription = ranges.getObservable().subscribe(rows => console.log(rows))
|
|
195
198
|
await ranges.create({ label: 'Works offline' })
|
|
196
199
|
await ranges.update(uid, { label: 'Edited offline' })
|
|
@@ -343,3 +346,32 @@ volumes:
|
|
|
343
346
|
docker compose pull electric
|
|
344
347
|
docker compose up electric
|
|
345
348
|
```
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
## Reload plugin
|
|
353
|
+
|
|
354
|
+
### Server
|
|
355
|
+
|
|
356
|
+
```js
|
|
357
|
+
import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
Managed sync automatically pauses network ownership when `navigator.onLine` is
|
|
362
|
+
false and resumes active models on the browser's `online` event. Local reads,
|
|
363
|
+
writes, queued edits, and cross-tab notifications remain available while offline.
|
|
364
|
+
A model explicitly stopped with `model.stop()` stays stopped after reconnection.
|
|
365
|
+
Disposing a model removes its network listeners. Browser connectivity is a hint;
|
|
366
|
+
requests can still fail while reported online and retain the usual retry behavior.
|
|
367
|
+
For custom runtimes/tests, plugin options `network` and `networkEvents` can supply
|
|
368
|
+
the connectivity state and an EventTarget respectively.
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
Models default to `primaryKey: 'uid'` on both client and server. Set
|
|
372
|
+
`primaryKey: 'id'` explicitly for tables that use `id`.
|
|
373
|
+
|
|
374
|
+
With `sync: true`, the sync owner's Electric stream stays active when its tab is
|
|
375
|
+
hidden because it serves other tabs too. To customize this behavior, pass
|
|
376
|
+
`streamOptions.runtimeVisibility` to `createElectricModel`. Other stream options
|
|
377
|
+
are preserved. Independent streams retain Electric's normal visibility behavior.
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ import { createManagedSync } from './electric-managed-sync.mjs'
|
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* electricClientPlugin(app)
|
|
12
|
-
* const todoModel = app.createElectricModel('todos') //
|
|
12
|
+
* const todoModel = app.createElectricModel('todos') // client-generated UUID in the default uid primary key
|
|
13
13
|
* const todoMilk = await todoModel.create({ label: "Buy milk" })
|
|
14
14
|
* todoMilk.update({ completed: true })
|
|
15
15
|
* todoModel.getObservable({ completed: false }).subscribe(...) // subscribe to an observable for todos with completed===false
|
|
@@ -26,7 +26,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
26
26
|
|
|
27
27
|
function createElectricModel(modelName, modelOptions = {}) {
|
|
28
28
|
quoteIdentifier(modelName)
|
|
29
|
-
quoteIdentifier(modelOptions.primaryKey ?? '
|
|
29
|
+
quoteIdentifier(modelOptions.primaryKey ?? 'uid')
|
|
30
30
|
if (managed) {
|
|
31
31
|
if (modelOptions.idGeneration === 'server') throw new TypeError('sync models require client-generated UUIDs')
|
|
32
32
|
return managed.model(modelName, modelOptions)
|
|
@@ -39,7 +39,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
39
39
|
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
40
40
|
if (modelOptions.localDb) return createOfflineElectricModel({
|
|
41
41
|
db: modelOptions.localDb, service, modelName, url,
|
|
42
|
-
primaryKey: modelOptions.primaryKey ?? '
|
|
42
|
+
primaryKey: modelOptions.primaryKey ?? 'uid',
|
|
43
43
|
ShapeStreamClass, ShapeClass,
|
|
44
44
|
ownsSync: modelOptions.ownsSync ?? true,
|
|
45
45
|
channel: modelOptions.channel,
|
|
@@ -50,7 +50,7 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
50
50
|
})
|
|
51
51
|
const streamOptions = modelOptions.streamOptions ?? {}
|
|
52
52
|
const idGeneration = modelOptions.idGeneration ?? 'client'
|
|
53
|
-
const primaryKey = modelOptions.primaryKey ?? '
|
|
53
|
+
const primaryKey = modelOptions.primaryKey ?? 'uid'
|
|
54
54
|
quoteIdentifier(primaryKey)
|
|
55
55
|
if (!['client', 'server'].includes(idGeneration)) {
|
|
56
56
|
throw new TypeError("idGeneration must be 'client' or 'server'")
|
|
@@ -47,7 +47,7 @@ export function createManagedSync(options, createModel) {
|
|
|
47
47
|
if (closed) throw new Error('Electric sync has been disposed')
|
|
48
48
|
const existing = models.get(name)
|
|
49
49
|
if (existing) {
|
|
50
|
-
if (existing.primaryKey !== (modelOptions.primaryKey ?? '
|
|
50
|
+
if (existing.primaryKey !== (modelOptions.primaryKey ?? 'uid')) throw new TypeError('A cached sync model cannot change primaryKey')
|
|
51
51
|
return existing.api
|
|
52
52
|
}
|
|
53
53
|
let active = true
|
|
@@ -58,13 +58,23 @@ 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
|
-
|
|
69
|
+
// The sync owner serves every tab, even while its own tab is hidden.
|
|
70
|
+
const streamOptions = {
|
|
71
|
+
...modelOptions.streamOptions,
|
|
72
|
+
runtimeVisibility: modelOptions.streamOptions?.runtimeVisibility ?? {
|
|
73
|
+
getCurrentState: () => 'visible',
|
|
74
|
+
subscribe: () => () => {},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
offline = createModel(name, { ...modelOptions, streamOptions, network, localDb: db, channel, ownsSync: false, onError: report })
|
|
68
78
|
const prepared = preparation.then(() => offline.prepare())
|
|
69
79
|
preparation = prepared.catch(() => {})
|
|
70
80
|
await prepared
|
|
@@ -78,10 +88,11 @@ export function createManagedSync(options, createModel) {
|
|
|
78
88
|
function startOwnership() {
|
|
79
89
|
if (controller || disposed || !active) return
|
|
80
90
|
offline.start()
|
|
91
|
+
if (network?.onLine === false) return
|
|
81
92
|
controller = new AbortController()
|
|
82
93
|
const ownerGeneration = ++generation
|
|
83
94
|
void locks.request(`express-x:${databaseName}:${name}`, { signal: controller.signal }, async () => {
|
|
84
|
-
if (!active || disposed || generation !== ownerGeneration) return
|
|
95
|
+
if (!active || disposed || network?.onLine === false || generation !== ownerGeneration) return
|
|
85
96
|
try {
|
|
86
97
|
offline.setSyncOwner(true)
|
|
87
98
|
await new Promise(resolve => { release = resolve })
|
|
@@ -91,16 +102,26 @@ export function createManagedSync(options, createModel) {
|
|
|
91
102
|
}
|
|
92
103
|
}).catch(cause => { if (cause.name !== 'AbortError') report(cause) })
|
|
93
104
|
}
|
|
94
|
-
function
|
|
95
|
-
active = false
|
|
105
|
+
function releaseOwnership() {
|
|
96
106
|
++generation
|
|
97
107
|
controller?.abort()
|
|
98
108
|
controller = undefined
|
|
99
109
|
release?.()
|
|
100
110
|
release = undefined
|
|
101
111
|
offline?.setSyncOwner(false)
|
|
112
|
+
}
|
|
113
|
+
function stop() {
|
|
114
|
+
active = false
|
|
115
|
+
releaseOwnership()
|
|
102
116
|
offline?.stop()
|
|
103
117
|
}
|
|
118
|
+
function updateNetwork() {
|
|
119
|
+
if (disposed) return
|
|
120
|
+
if (network?.onLine === false) releaseOwnership()
|
|
121
|
+
else if (active) void ready.then(startOwnership).catch(report)
|
|
122
|
+
}
|
|
123
|
+
networkEvents.addEventListener?.('offline', updateNetwork)
|
|
124
|
+
networkEvents.addEventListener?.('online', updateNetwork)
|
|
104
125
|
function start() {
|
|
105
126
|
if (disposed) throw new Error('Electric model has been disposed')
|
|
106
127
|
active = true
|
|
@@ -115,6 +136,8 @@ export function createManagedSync(options, createModel) {
|
|
|
115
136
|
async dispose() {
|
|
116
137
|
if (disposed) return
|
|
117
138
|
disposed = true
|
|
139
|
+
networkEvents.removeEventListener?.('offline', updateNetwork)
|
|
140
|
+
networkEvents.removeEventListener?.('online', updateNetwork)
|
|
118
141
|
stop()
|
|
119
142
|
try { await ready; await offline.waitForIdle() } finally { channel?.close(); models.delete(name) }
|
|
120
143
|
},
|
|
@@ -138,7 +161,7 @@ export function createManagedSync(options, createModel) {
|
|
|
138
161
|
flushQueue: () => invoke('flushQueue'),
|
|
139
162
|
getStatus: async () => ({ ...await invoke('getStatus'), error: error?.message ?? null }),
|
|
140
163
|
}
|
|
141
|
-
models.set(name, { api, primaryKey: modelOptions.primaryKey ?? '
|
|
164
|
+
models.set(name, { api, primaryKey: modelOptions.primaryKey ?? 'uid' })
|
|
142
165
|
return api
|
|
143
166
|
}
|
|
144
167
|
async function dispose() {
|
|
@@ -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])
|
|
@@ -248,7 +253,7 @@ function normalizeModels(models) {
|
|
|
248
253
|
}
|
|
249
254
|
const name = config.name
|
|
250
255
|
const table = config.table ?? name
|
|
251
|
-
const primaryKey = config.primaryKey ?? '
|
|
256
|
+
const primaryKey = config.primaryKey ?? 'uid'
|
|
252
257
|
quoteIdentifier(name, 'model name')
|
|
253
258
|
const tombstoneData = config.tombstoneData ?? {}
|
|
254
259
|
if (typeof tombstoneData !== 'function') validateTombstoneData(tombstoneData, primaryKey)
|
|
@@ -76,13 +76,26 @@ test('managed models initialize automatically, share one database, and cache mod
|
|
|
76
76
|
assert.equal(dataDir, 'idb://test-db'); created++; return db
|
|
77
77
|
} })
|
|
78
78
|
try {
|
|
79
|
-
const users = app.createElectricModel('users'
|
|
80
|
-
const
|
|
79
|
+
const users = app.createElectricModel('users')
|
|
80
|
+
const customVisibility = { getCurrentState: () => 'hidden', subscribe: () => () => {} }
|
|
81
|
+
const ranges = app.createElectricModel('ranges', { streamOptions: { runtimeVisibility: customVisibility, params: { test: 'preserved' } } })
|
|
81
82
|
assert.equal(app.createElectricModel('users', { primaryKey: 'uid' }), users)
|
|
82
|
-
assert.
|
|
83
|
+
assert.equal(app.createElectricModel('users'), users)
|
|
84
|
+
assert.throws(() => app.createElectricModel('users', { primaryKey: 'id' }), /primaryKey/)
|
|
83
85
|
const row = await users.create({ name: 'Works before prepare' })
|
|
84
86
|
await ranges.prepare()
|
|
85
87
|
assert.equal(created, 1)
|
|
88
|
+
await until(() => Stream.instances.some(stream => stream.options.url.endsWith('/users')))
|
|
89
|
+
const visibility = Stream.instances.findLast(stream => stream.options.url.endsWith('/users')).options.runtimeVisibility
|
|
90
|
+
assert.equal(visibility.getCurrentState(), 'visible')
|
|
91
|
+
let notified = false
|
|
92
|
+
const cleanup = visibility.subscribe(() => { notified = true })
|
|
93
|
+
cleanup()
|
|
94
|
+
assert.equal(notified, false)
|
|
95
|
+
await until(() => Stream.instances.some(stream => stream.options.url.endsWith('/ranges')))
|
|
96
|
+
const rangeOptions = Stream.instances.findLast(stream => stream.options.url.endsWith('/ranges')).options
|
|
97
|
+
assert.equal(rangeOptions.runtimeVisibility, customVisibility)
|
|
98
|
+
assert.deepEqual(rangeOptions.params, { test: 'preserved' })
|
|
86
99
|
assert.equal((await users.findUnique({ uid: row.uid })).name, row.name)
|
|
87
100
|
} finally {
|
|
88
101
|
await app.disposeElectricSync()
|
|
@@ -100,8 +113,8 @@ test('only one tab streams and flushes; follower edits broadcast and ownership t
|
|
|
100
113
|
const before = Stream.instances.length
|
|
101
114
|
let subscription
|
|
102
115
|
try {
|
|
103
|
-
const owner = first.createElectricModel('todos')
|
|
104
|
-
const follower = second.createElectricModel('todos')
|
|
116
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
117
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
105
118
|
await owner.prepare()
|
|
106
119
|
await follower.prepare()
|
|
107
120
|
await until(() => Stream.instances.length === before + 1)
|
|
@@ -137,8 +150,8 @@ test('stopping a waiting follower cancels takeover; restart can acquire ownershi
|
|
|
137
150
|
const second = configure(db, locks)
|
|
138
151
|
const before = Stream.instances.length
|
|
139
152
|
try {
|
|
140
|
-
const owner = first.createElectricModel('todos')
|
|
141
|
-
const follower = second.createElectricModel('todos')
|
|
153
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
154
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
142
155
|
await owner.prepare(); await follower.prepare()
|
|
143
156
|
await until(() => Stream.instances.length === before + 1)
|
|
144
157
|
follower.stop()
|
|
@@ -152,6 +165,56 @@ test('stopping a waiting follower cancels takeover; restart can acquire ownershi
|
|
|
152
165
|
}
|
|
153
166
|
})
|
|
154
167
|
|
|
168
|
+
test('a returning sync owner preserves shared rows until its new snapshot arrives', async () => {
|
|
169
|
+
const db = new PGlite()
|
|
170
|
+
const locks = new Locks()
|
|
171
|
+
const calls = []
|
|
172
|
+
const service = {
|
|
173
|
+
async create(id, data) {
|
|
174
|
+
calls.push(id)
|
|
175
|
+
return { id, ...data, version: '3', deleted: false }
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
const first = configure(db, locks, service)
|
|
179
|
+
const second = configure(db, locks, service)
|
|
180
|
+
const before = Stream.instances.length
|
|
181
|
+
const original = { id: crypto.randomUUID(), title: 'original', version: '1', deleted: false }
|
|
182
|
+
const changed = { ...original, title: 'updated by next owner', version: '2' }
|
|
183
|
+
const added = { id: crypto.randomUUID(), title: 'added by next owner', version: '2', deleted: false }
|
|
184
|
+
try {
|
|
185
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
186
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
187
|
+
await owner.prepare(); await follower.prepare()
|
|
188
|
+
await until(() => Stream.instances.length === before + 1)
|
|
189
|
+
Stream.instances.at(-1).shape.emit({ rows: [original] })
|
|
190
|
+
await until(async () => (await owner.findMany()).length === 1)
|
|
191
|
+
owner.stop()
|
|
192
|
+
await until(() => Stream.instances.length === before + 2)
|
|
193
|
+
Stream.instances.at(-1).shape.emit({ rows: [changed, added] })
|
|
194
|
+
await until(async () => (await follower.findMany()).length === 2)
|
|
195
|
+
|
|
196
|
+
follower.stop()
|
|
197
|
+
await owner.start()
|
|
198
|
+
await until(() => Stream.instances.length === before + 3)
|
|
199
|
+
// Flush before the returning owner's stream delivers any snapshot.
|
|
200
|
+
const created = await owner.create({ title: 'created after restart' })
|
|
201
|
+
await until(async () => {
|
|
202
|
+
const { rows } = await db.query('SELECT acknowledged_version FROM electric_mutation_queue WHERE row_id = $1', [created.id])
|
|
203
|
+
return rows[0]?.acknowledged_version != null
|
|
204
|
+
})
|
|
205
|
+
assert.equal(calls.length, 1)
|
|
206
|
+
assert.deepEqual(await owner.findUnique({ id: original.id }), changed)
|
|
207
|
+
assert.deepEqual(await owner.findUnique({ id: added.id }), added)
|
|
208
|
+
assert.equal((await owner.getStatus()).pending, 1)
|
|
209
|
+
|
|
210
|
+
Stream.instances.at(-1).shape.emit({ rows: [changed, added, { ...created, version: '3', deleted: false }] })
|
|
211
|
+
await until(async () => (await owner.getStatus()).pending === 0)
|
|
212
|
+
assert.equal((await owner.findMany()).length, 3)
|
|
213
|
+
} finally {
|
|
214
|
+
await first.disposeElectricSync(); await second.disposeElectricSync(); await db.close()
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
155
218
|
test('initialization errors reject writes and observable reads and call onError', async () => {
|
|
156
219
|
const error = new Error('Worker could not load assets')
|
|
157
220
|
const reported = []
|
|
@@ -159,7 +222,7 @@ test('initialization errors reject writes and observable reads and call onError'
|
|
|
159
222
|
createDatabase: async () => { throw error },
|
|
160
223
|
onError: cause => reported.push(cause),
|
|
161
224
|
})
|
|
162
|
-
const model = app.createElectricModel('todos')
|
|
225
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
163
226
|
await assert.rejects(model.create({ title: 'No silent failure' }), /load assets/)
|
|
164
227
|
await new Promise(resolve => model.getObservable().subscribe({ error: cause => {
|
|
165
228
|
assert.equal(cause, error); resolve()
|
|
@@ -189,7 +252,7 @@ test('default worker errors and timeouts reject preparation instead of hanging',
|
|
|
189
252
|
terminate() { terminated = true }
|
|
190
253
|
}
|
|
191
254
|
const app = configure(undefined, new Locks(), {}, { workerFactory: () => new Worker(), initTimeoutMs: 30 })
|
|
192
|
-
const model = app.createElectricModel('todos')
|
|
255
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
193
256
|
await assert.rejects(model.prepare(), triggerError ? /module not found/ : /timed out/)
|
|
194
257
|
assert.equal(terminated, true)
|
|
195
258
|
await assert.rejects(app.disposeElectricSync())
|
|
@@ -216,7 +279,7 @@ test('snapshots and reads recover after a database leader changes', async () =>
|
|
|
216
279
|
const app = configure(shared, new Locks())
|
|
217
280
|
const before = Stream.instances.length
|
|
218
281
|
try {
|
|
219
|
-
const model = app.createElectricModel('todos')
|
|
282
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id' })
|
|
220
283
|
await model.prepare()
|
|
221
284
|
await until(() => Stream.instances.length === before + 1)
|
|
222
285
|
failSnapshot = true
|
|
@@ -252,3 +315,62 @@ test('an indeterminate foreground mutation is not replayed automatically', async
|
|
|
252
315
|
assert.equal((await model.getStatus()).pending, 1)
|
|
253
316
|
} finally { await db.close() }
|
|
254
317
|
})
|
|
318
|
+
|
|
319
|
+
test('network lifecycle preserves offline edits, transfers ownership and respects manual stop', async () => {
|
|
320
|
+
const db = new PGlite()
|
|
321
|
+
const locks = new Locks()
|
|
322
|
+
const events = new EventTarget()
|
|
323
|
+
const network = { onLine: false }
|
|
324
|
+
const calls = []
|
|
325
|
+
const service = { async create(id, data) { calls.push(id); return { ...data, id, version: '1' } } }
|
|
326
|
+
const first = configure(db, locks, service, { network, networkEvents: events })
|
|
327
|
+
const second = configure(db, locks, service, { network, networkEvents: events })
|
|
328
|
+
const before = Stream.instances.length
|
|
329
|
+
let subscription
|
|
330
|
+
try {
|
|
331
|
+
const owner = first.createElectricModel('todos', { primaryKey: 'id' })
|
|
332
|
+
const follower = second.createElectricModel('todos', { primaryKey: 'id' })
|
|
333
|
+
await owner.prepare(); await follower.prepare()
|
|
334
|
+
let rows = []
|
|
335
|
+
subscription = follower.getObservable().subscribe(value => { rows = value })
|
|
336
|
+
const created = await owner.create({ title: 'Created offline' })
|
|
337
|
+
await until(() => rows.length === 1)
|
|
338
|
+
assert.equal(Stream.instances.length, before)
|
|
339
|
+
assert.equal(calls.length, 0)
|
|
340
|
+
assert.equal((await owner.getStatus()).pending, 1)
|
|
341
|
+
|
|
342
|
+
network.onLine = true
|
|
343
|
+
events.dispatchEvent(new Event('online'))
|
|
344
|
+
await until(() => Stream.instances.length === before + 1 && calls.length === 1)
|
|
345
|
+
const stream = Stream.instances.at(-1)
|
|
346
|
+
stream.shape.emit({ rows: [{ ...created, version: '1', deleted: false }] })
|
|
347
|
+
await until(async () => (await follower.getStatus()).pending === 0)
|
|
348
|
+
|
|
349
|
+
network.onLine = false
|
|
350
|
+
events.dispatchEvent(new Event('offline'))
|
|
351
|
+
await until(() => stream.stopped === true)
|
|
352
|
+
await follower.update(created.id, { title: 'Updated offline' })
|
|
353
|
+
assert.equal((await owner.findMany())[0].title, 'Updated offline')
|
|
354
|
+
assert.equal((await follower.getStatus()).pending, 1)
|
|
355
|
+
assert.equal(Stream.instances.length, before + 1)
|
|
356
|
+
|
|
357
|
+
owner.stop()
|
|
358
|
+
network.onLine = true
|
|
359
|
+
events.dispatchEvent(new Event('online'))
|
|
360
|
+
await until(() => Stream.instances.length === before + 2)
|
|
361
|
+
follower.stop()
|
|
362
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
363
|
+
assert.equal(Stream.instances.length, before + 2, 'manually stopped owner must not take over')
|
|
364
|
+
await owner.start()
|
|
365
|
+
await until(() => Stream.instances.length === before + 3)
|
|
366
|
+
await first.disposeElectricSync()
|
|
367
|
+
await second.disposeElectricSync()
|
|
368
|
+
events.dispatchEvent(new Event('offline'))
|
|
369
|
+
events.dispatchEvent(new Event('online'))
|
|
370
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
371
|
+
assert.equal(Stream.instances.length, before + 3)
|
|
372
|
+
} finally {
|
|
373
|
+
subscription?.unsubscribe()
|
|
374
|
+
await first.disposeElectricSync(); await second.disposeElectricSync(); await db.close()
|
|
375
|
+
}
|
|
376
|
+
})
|
|
@@ -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', primaryKey: 'id', 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', primaryKey: 'id', 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', { primaryKey: 'id', 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 = []
|
|
@@ -30,7 +95,7 @@ test('offline writes stay local until the acknowledged Electric version arrives'
|
|
|
30
95
|
}
|
|
31
96
|
const app = { service: () => service }
|
|
32
97
|
electricClientPlugin(app, { ShapeStream: Stream, Shape })
|
|
33
|
-
const model = app.createElectricModel('todos', { localDb: db, network: { onLine: false }, retryMs: 60_000 })
|
|
98
|
+
const model = app.createElectricModel('todos', { primaryKey: 'id', localDb: db, network: { onLine: false }, retryMs: 60_000 })
|
|
34
99
|
try {
|
|
35
100
|
await model.prepare()
|
|
36
101
|
const local = await model.create({ title: 'offline' })
|
|
@@ -40,7 +105,7 @@ test('offline writes stay local until the acknowledged Electric version arrives'
|
|
|
40
105
|
assert.equal(calls.length, 0)
|
|
41
106
|
|
|
42
107
|
// Simulate reconnect, then an old Shape snapshot followed by confirmation.
|
|
43
|
-
const onlineModel = app.createElectricModel('todos', { localDb: db, network: { onLine: true }, retryMs: 60_000 })
|
|
108
|
+
const onlineModel = app.createElectricModel('todos', { primaryKey: 'id', localDb: db, network: { onLine: true }, retryMs: 60_000 })
|
|
44
109
|
await onlineModel.flushQueue()
|
|
45
110
|
assert.equal(calls.length, 1)
|
|
46
111
|
assert.equal(calls[0].metadata.revision, '1')
|
|
@@ -17,7 +17,7 @@ function fixture({ authorize = async () => true, fetch } = {}) {
|
|
|
17
17
|
createService(name, methods) { services.set(name, methods) },
|
|
18
18
|
get(path, handler) { routes.set(path, handler) },
|
|
19
19
|
}
|
|
20
|
-
const registration = electricServerPlugin(app, db, ['todos'], { authorize, fetch: fetch ?? globalThis.fetch })
|
|
20
|
+
const registration = electricServerPlugin(app, db, [{ name: 'todos', primaryKey: 'id' }], { authorize, fetch: fetch ?? globalThis.fetch })
|
|
21
21
|
return { app, db, services, routes, queries, registration }
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -192,7 +192,7 @@ test('sync mode commits a versioned write and rejects replayed or stale revision
|
|
|
192
192
|
|
|
193
193
|
test('sync schema prepares version and tombstone columns', async () => {
|
|
194
194
|
const queries = []
|
|
195
|
-
await prepareElectricSyncSchema({ query: async sql => { queries.push(sql) } }, ['todos'])
|
|
195
|
+
await prepareElectricSyncSchema({ query: async sql => { queries.push(sql) } }, [{ name: 'todos', primaryKey: 'id' }])
|
|
196
196
|
assert.equal(queries.length, 4)
|
|
197
197
|
assert.match(queries[2], /ADD COLUMN IF NOT EXISTS version BIGINT/)
|
|
198
198
|
assert.match(queries[3], /ADD COLUMN IF NOT EXISTS deleted BOOLEAN/)
|
|
@@ -301,3 +301,10 @@ test('invalid tombstone factory results roll back before writing a tombstone', a
|
|
|
301
301
|
assert.ok(!queries.some(sql => sql.includes('INSERT INTO "users"')))
|
|
302
302
|
}
|
|
303
303
|
})
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
test('server models default to the uid primary key', () => {
|
|
307
|
+
const app = { createService() {}, get() {} }
|
|
308
|
+
const result = electricServerPlugin(app, { query() {} }, ['users'], { authorize: async () => true })
|
|
309
|
+
assert.equal(result.models[0].primaryKey, 'uid')
|
|
310
|
+
})
|