@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
- ## Reload plugin
18
+ ## Electric plugin: local-first Postgres
37
19
 
38
- ### Server
20
+ Express-X handles authorized PostgreSQL mutations; Electric streams committed rows to clients.
39
21
 
40
- ```js
41
- import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -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 stop() {
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
- /** Run once before accepting sync mutations. Existing rows receive a version. */
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 = []