@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 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
 
@@ -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 `id` primary key:
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', { primaryKey: 'uid' })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "5.0.4",
3
+ "version": "5.0.7",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -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') // primary key is automatically provided by server (auto-increment)
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 ?? 'id')
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 ?? 'id',
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 ?? 'id'
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 ?? 'id')) throw new TypeError('A cached sync model cannot change 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
- offline = createModel(name, { ...modelOptions, localDb: db, channel, ownsSync: false, onError: report })
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 stop() {
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 ?? 'id' })
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
- /** 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])
@@ -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 ?? 'id'
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', { primaryKey: 'uid' })
80
- const ranges = app.createElectricModel('ranges', { primaryKey: 'uid' })
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.throws(() => app.createElectricModel('users'), /primaryKey/)
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
+ })