@jcbuisson/express-x-plugins 5.0.1 → 5.0.3

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
@@ -13,7 +13,7 @@ ALTER SYSTEM SET max_replication_slots = 10;
13
13
  ALTER SYSTEM SET max_wal_senders = 10;
14
14
  ALTER ROLE chris WITH REPLICATION;
15
15
 
16
- (restart postgres: `sudo systemctl restart postgresql`)
16
+ (restart postgres)
17
17
 
18
18
  ## Use HTTP2
19
19
  nginx: `listen 443 ssl http2;`
@@ -100,6 +100,29 @@ rows. Configure `tombstoneData` for required columns without defaults, such as a
100
100
  required title. Keep the cursor table and tombstones to protect delayed retries and
101
101
  offline clients. This does not resolve concurrent edits by different clients.
102
102
 
103
+ `tombstoneData` can also be a function receiving `{ id }` and returning a plain
104
+ object (or a promise of one). It runs inside the mutation transaction whenever a
105
+ tombstone is written, including an update targeting a missing or deleted row.
106
+ Replayed or stale revisions return the saved result without calling it again.
107
+ For a unique, required email column, derive a dummy value from the row's UUID:
108
+
109
+ ```js
110
+ {
111
+ name: 'user',
112
+ primaryKey: 'uid',
113
+ tombstoneData: ({ id }) => ({
114
+ email: `deleted-${id}@tombstone.invalid`,
115
+ name: '',
116
+ color: '',
117
+ }),
118
+ }
119
+ ```
120
+
121
+ The returned keys must be valid column identifiers and cannot include the primary
122
+ key, `version`, or `deleted`. Reserve the dummy email pattern for tombstones so
123
+ active users cannot occupy those values. A deterministic value remains stable
124
+ across repeated deletions; a function can also generate a fresh random value.
125
+
103
126
  Import `prepareElectricSyncSchema` alongside `electricServerPlugin`. It adds
104
127
  `version` and `deleted` columns and creates the shared version sequence and cursor
105
128
  table. The primary key must be UUID in sync mode. All writes to synced tables must
@@ -150,11 +173,98 @@ console.log(created.id)
150
173
  subscription.unsubscribe()
151
174
  ```
152
175
 
153
- For offline writes, install `@electric-sql/pglite`, pass a shared PGlite database
154
- as `localDb`, and use the sync
155
- server mode above. `PGliteWorker` with an IndexedDB data directory is recommended
156
- for multiple tabs. Only one worker should use `ownsSync: true`; use a shared
157
- `BroadcastChannel` so other tabs receive change notifications.
176
+ ### Managed browser sync
177
+
178
+ With a server configured with `sync: true`, the client plugin can own the shared
179
+ PGlite database and multi-tab synchronization:
180
+
181
+ ```sh
182
+ npm install @electric-sql/pglite @electric-sql/client rxjs vue
183
+ ```
184
+
185
+ ```js
186
+ app.configure(electricClientPlugin, {
187
+ sync: true,
188
+ databaseName: 'selommes', // persistent IndexedDB name; unique to this app/backend
189
+ shapePath: '/electric/v1/shape',
190
+ onError: (error, { modelName }) => console.error(modelName, error),
191
+ })
192
+
193
+ const ranges = app.createElectricModel('range', { primaryKey: 'uid' })
194
+ const subscription = ranges.getObservable().subscribe(rows => console.log(rows))
195
+ await ranges.create({ label: 'Works offline' })
196
+ await ranges.update(uid, { label: 'Edited offline' })
197
+ await ranges.remove(uid)
198
+ console.log(await ranges.getStatus()) // pending, failed, online, error
199
+
200
+ // During app teardown (for example, HMR disposal):
201
+ subscription.unsubscribe()
202
+ await app.disposeElectricSync()
203
+ ```
204
+
205
+ Models prepare and start automatically; operations wait for schema readiness.
206
+ Repeated calls for the same model reuse its instance. `findMany` and `findUnique`
207
+ read the local cache immediately after preparation, which can be empty before
208
+ Electric's first snapshot. Use `app.service('user').findMany({ deleted: false, ...filter })`
209
+ when an online authentication flow requires an authoritative server read.
210
+
211
+ Each plugin instance shares one persistent PGliteWorker. PGlite coordinates its
212
+ database worker across tabs. A separate Web Lock per database/model elects one
213
+ tab to run the Electric stream and mutation queue. Follower writes notify the
214
+ owner through BroadcastChannel. When the owner closes or stops, a waiting tab
215
+ takes over. Stream ownership does not depend on PGlite's leader event timing.
216
+ BIGINT values in cached rows are stored as lossless decimal strings. Snapshot
217
+ reconciliation and reads retry transient database leader changes. An indeterminate
218
+ foreground local mutation is reported instead of blindly repeated.
219
+
220
+ `model.stop()` stops synchronization and releases ownership without deleting local
221
+ rows or queued edits; `await model.start()` resumes it. `await model.dispose()`
222
+ also closes its channel. `await app.disposeElectricSync()` disposes all managed
223
+ models and closes the plugin-owned database. A supplied `localDb` remains owned
224
+ by the caller. Background errors appear in `getStatus()` and invoke `onError`;
225
+ initialization failures reject reads/writes and error observable subscriptions.
226
+ Worker startup times out after 30 seconds (`initTimeoutMs` can override this).
227
+
228
+ Managed sync requires HTTPS or localhost, Web Locks, BroadcastChannel, workers,
229
+ and IndexedDB. Configure Vite to keep the plugin and PGlite worker asset URLs
230
+ intact during development and emit workers as ES modules:
231
+
232
+ ```js
233
+ export default defineConfig({
234
+ optimizeDeps: {
235
+ exclude: ['@jcbuisson/express-x-plugins', '@electric-sql/pglite'],
236
+ },
237
+ worker: { format: 'es' },
238
+ })
239
+ ```
240
+
241
+ For other bundlers, provide `workerFactory` if their worker discovery requires
242
+ an application-owned entry point:
243
+
244
+ ```js
245
+ // electric.worker.js
246
+ import '@jcbuisson/express-x-plugins/electric-worker'
247
+
248
+ // Client configuration:
249
+ app.configure(electricClientPlugin, {
250
+ sync: true,
251
+ databaseName: 'selommes',
252
+ workerFactory: () => new Worker(new URL('./electric.worker.js', import.meta.url), {
253
+ type: 'module',
254
+ }),
255
+ })
256
+ ```
257
+
258
+ PWA applications should cache the emitted PGlite `.wasm` and `.data` assets to
259
+ initialize after an offline reload. The WASM asset is about 10 MB; increase the
260
+ precache file-size limit accordingly. Apps that already provide a shared database
261
+ can pass plugin-level `localDb` to retain automatic ownership management.
262
+
263
+ ### Explicit offline models
264
+
265
+ For offline writes, install `@electric-sql/pglite`, pass a shared PGlite database as `localDb`, and use the sync server mode above.
266
+ `PGliteWorker` with an IndexedDB data directory is recommended for multiple tabs.
267
+ Only one worker should use `ownsSync: true`; use a shared `BroadcastChannel` so other tabs receive change notifications.
158
268
 
159
269
  ```js
160
270
  const todo = app.createElectricModel('todos', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -8,7 +8,8 @@
8
8
  "./electric-server": "./src/electric-server-plugin.mjs",
9
9
  "./electric-client": "./src/electric-client-plugin.mjs",
10
10
  "./reload-server": "./src/reload-server-plugin.mjs",
11
- "./reload-client": "./src/reload-client-plugin.mjs"
11
+ "./reload-client": "./src/reload-client-plugin.mjs",
12
+ "./electric-worker": "./src/electric-pglite-worker.mjs"
12
13
  },
13
14
  "private": false,
14
15
  "publishConfig": {
@@ -2,6 +2,7 @@ import { Shape, ShapeStream } from '@electric-sql/client'
2
2
  import { Observable, firstValueFrom, Subject, takeUntil } from 'rxjs'
3
3
  import { getCurrentScope, onScopeDispose } from 'vue'
4
4
  import { createOfflineElectricModel } from './electric-offline-model.mjs'
5
+ import { createManagedSync } from './electric-managed-sync.mjs'
5
6
 
6
7
  /**
7
8
  * Add Electric-backed reactive models to an Express-X client.
@@ -19,8 +20,21 @@ export function electricClientPlugin(app, options = {}) {
19
20
  const ShapeClass = options.Shape ?? DisposableShape
20
21
  const ObservableClass = options.Observable ?? Observable
21
22
 
23
+ const managed = options.sync === true
24
+ ? createManagedSync(options, createDirectModel)
25
+ : null
26
+
22
27
  function createElectricModel(modelName, modelOptions = {}) {
23
28
  quoteIdentifier(modelName)
29
+ quoteIdentifier(modelOptions.primaryKey ?? 'id')
30
+ if (managed) {
31
+ if (modelOptions.idGeneration === 'server') throw new TypeError('sync models require client-generated UUIDs')
32
+ return managed.model(modelName, modelOptions)
33
+ }
34
+ return createDirectModel(modelName, modelOptions)
35
+ }
36
+
37
+ function createDirectModel(modelName, modelOptions) {
24
38
  const service = app.service(modelName)
25
39
  const url = modelOptions.url ?? modelPath(shapePath, modelName)
26
40
  if (modelOptions.localDb) return createOfflineElectricModel({
@@ -31,6 +45,8 @@ export function electricClientPlugin(app, options = {}) {
31
45
  channel: modelOptions.channel,
32
46
  network: modelOptions.network ?? globalThis.navigator,
33
47
  retryMs: modelOptions.retryMs ?? 5000,
48
+ streamOptions: modelOptions.streamOptions,
49
+ onError: modelOptions.onError ?? options.onError,
34
50
  })
35
51
  const streamOptions = modelOptions.streamOptions ?? {}
36
52
  const idGeneration = modelOptions.idGeneration ?? 'client'
@@ -53,7 +69,7 @@ export function electricClientPlugin(app, options = {}) {
53
69
  let previous
54
70
  const unsubscribe = shape.subscribe(({ rows }) => {
55
71
  const current = [...rows]
56
- const serialized = JSON.stringify(current)
72
+ const serialized = JSON.stringify(current, (_key, value) => typeof value === 'bigint' ? String(value) : value)
57
73
  if (serialized === previous) return
58
74
  previous = serialized
59
75
  subscriber.next(current)
@@ -88,7 +104,7 @@ export function electricClientPlugin(app, options = {}) {
88
104
  if (idGeneration === 'server') return service.create(mutationData)
89
105
  const uid = globalThis.crypto?.randomUUID?.()
90
106
  if (!uid) throw new Error('crypto.randomUUID() is required')
91
- return service.create(uid, data)
107
+ return service.create(uid, mutationData)
92
108
  }
93
109
 
94
110
  async function update(id, data) {
@@ -102,7 +118,7 @@ export function electricClientPlugin(app, options = {}) {
102
118
  return { getObservable, findMany, findUnique, create, update, remove }
103
119
  }
104
120
 
105
- return Object.assign(app, { createElectricModel })
121
+ return Object.assign(app, { createElectricModel, ...(managed ? { disposeElectricSync: managed.dispose } : {}) })
106
122
  }
107
123
 
108
124
 
@@ -0,0 +1,150 @@
1
+ import { Observable } from 'rxjs'
2
+
3
+ /** One database and one cached model per plugin instance. Browser locks span tabs. */
4
+ export function createManagedSync(options, createModel) {
5
+ const databaseName = options.databaseName
6
+ if (typeof databaseName !== 'string' || !databaseName.trim()) {
7
+ throw new TypeError('sync: true requires a non-empty databaseName')
8
+ }
9
+ const locks = options.locks ?? globalThis.navigator?.locks
10
+ const Channel = options.BroadcastChannel ?? globalThis.BroadcastChannel
11
+ if (!locks?.request || !Channel) throw new Error('Managed sync requires Web Locks and BroadcastChannel (use HTTPS or localhost)')
12
+ let database
13
+ let preparation = Promise.resolve()
14
+ let closed = false
15
+ const models = new Map()
16
+
17
+ function getDatabase() {
18
+ database ??= Promise.resolve().then(async () => {
19
+ if (options.localDb) return options.localDb
20
+ if (options.createDatabase) return options.createDatabase({ dataDir: `idb://${databaseName}` })
21
+ const { PGliteWorker } = await import('@electric-sql/pglite/worker')
22
+ const worker = options.workerFactory
23
+ ? options.workerFactory()
24
+ : new Worker(new URL('./electric-pglite-worker.mjs', import.meta.url), { type: 'module' })
25
+ let timer
26
+ let rejectFailure
27
+ const failure = new Promise((_, reject) => { rejectFailure = reject })
28
+ const fail = event => rejectFailure(new Error(event.message ?? 'PGlite worker failed to initialize'))
29
+ worker.addEventListener('error', fail)
30
+ worker.addEventListener('messageerror', fail)
31
+ timer = setTimeout(() => rejectFailure(new Error('PGlite worker initialization timed out')), options.initTimeoutMs ?? 30_000)
32
+ try {
33
+ return await Promise.race([PGliteWorker.create(worker, { dataDir: `idb://${databaseName}` }), failure])
34
+ } catch (error) {
35
+ worker.terminate()
36
+ throw error
37
+ } finally {
38
+ clearTimeout(timer)
39
+ worker.removeEventListener('error', fail)
40
+ worker.removeEventListener('messageerror', fail)
41
+ }
42
+ })
43
+ return database
44
+ }
45
+
46
+ function model(name, modelOptions) {
47
+ if (closed) throw new Error('Electric sync has been disposed')
48
+ const existing = models.get(name)
49
+ if (existing) {
50
+ if (existing.primaryKey !== (modelOptions.primaryKey ?? 'id')) throw new TypeError('A cached sync model cannot change primaryKey')
51
+ return existing.api
52
+ }
53
+ let active = true
54
+ let disposed = false
55
+ let controller
56
+ let release
57
+ let generation = 0
58
+ let channel
59
+ let offline
60
+ let error = null
61
+ function report(cause) {
62
+ error = cause
63
+ try { (modelOptions.onError ?? options.onError)?.(cause, { modelName: name }) } catch (callbackError) { console.error(callbackError) }
64
+ }
65
+ const ready = getDatabase().then(async db => {
66
+ channel = new Channel(`express-x:${databaseName}:${name}`)
67
+ offline = createModel(name, { ...modelOptions, localDb: db, channel, ownsSync: false, onError: report })
68
+ const prepared = preparation.then(() => offline.prepare())
69
+ preparation = prepared.catch(() => {})
70
+ await prepared
71
+ if (disposed) { channel.close(); return offline }
72
+ if (active) startOwnership()
73
+ return offline
74
+ })
75
+ // Report initialization failure even when the first consumer only subscribes.
76
+ void ready.catch(report)
77
+
78
+ function startOwnership() {
79
+ if (controller || disposed || !active) return
80
+ offline.start()
81
+ controller = new AbortController()
82
+ const ownerGeneration = ++generation
83
+ void locks.request(`express-x:${databaseName}:${name}`, { signal: controller.signal }, async () => {
84
+ if (!active || disposed || generation !== ownerGeneration) return
85
+ try {
86
+ offline.setSyncOwner(true)
87
+ await new Promise(resolve => { release = resolve })
88
+ } finally {
89
+ await offline.waitForIdle()
90
+ if (generation === ownerGeneration) { offline.setSyncOwner(false); release = undefined }
91
+ }
92
+ }).catch(cause => { if (cause.name !== 'AbortError') report(cause) })
93
+ }
94
+ function stop() {
95
+ active = false
96
+ ++generation
97
+ controller?.abort()
98
+ controller = undefined
99
+ release?.()
100
+ release = undefined
101
+ offline?.setSyncOwner(false)
102
+ offline?.stop()
103
+ }
104
+ function start() {
105
+ if (disposed) throw new Error('Electric model has been disposed')
106
+ active = true
107
+ return ready.then(() => startOwnership())
108
+ }
109
+ function invoke(method, ...args) {
110
+ if (disposed) return Promise.reject(new Error('Electric model has been disposed'))
111
+ return ready.then(model => model[method](...args))
112
+ }
113
+ const api = {
114
+ prepare: () => ready.then(() => undefined), start, stop,
115
+ async dispose() {
116
+ if (disposed) return
117
+ disposed = true
118
+ stop()
119
+ try { await ready; await offline.waitForIdle() } finally { channel?.close(); models.delete(name) }
120
+ },
121
+ getObservable(where = {}) {
122
+ return new Observable(subscriber => {
123
+ if (disposed) { subscriber.error(new Error('Electric model has been disposed')); return }
124
+ let subscription
125
+ let cancelled = false
126
+ const fail = cause => subscriber.error(cause)
127
+ void ready.then(model => {
128
+ if (!cancelled) subscription = model.getObservable(where).subscribe(subscriber)
129
+ }).catch(fail)
130
+ return () => { cancelled = true; subscription?.unsubscribe() }
131
+ })
132
+ },
133
+ findMany: (where = {}) => invoke('findMany', where),
134
+ findUnique: (where = {}) => invoke('findUnique', where),
135
+ create: data => invoke('create', data),
136
+ update: (id, data) => invoke('update', id, data),
137
+ remove: id => invoke('remove', id),
138
+ flushQueue: () => invoke('flushQueue'),
139
+ getStatus: async () => ({ ...await invoke('getStatus'), error: error?.message ?? null }),
140
+ }
141
+ models.set(name, { api, primaryKey: modelOptions.primaryKey ?? 'id' })
142
+ return api
143
+ }
144
+ async function dispose() {
145
+ closed = true
146
+ await Promise.allSettled([...models.values()].map(({ api }) => api.dispose()))
147
+ if (database && !options.localDb) await (await database).close()
148
+ }
149
+ return { model, dispose }
150
+ }
@@ -1,7 +1,7 @@
1
1
  import { Observable } from 'rxjs'
2
2
 
3
3
  /** Offline model backed by a shared PGlite database (normally PGliteWorker). */
4
- export function createOfflineElectricModel({ db, service, modelName, url, primaryKey, ShapeStreamClass, ShapeClass, ownsSync, channel, network, retryMs }) {
4
+ export function createOfflineElectricModel({ db, service, modelName, url, primaryKey, ShapeStreamClass, ShapeClass, ownsSync, channel, network, retryMs, streamOptions = {}, onError }) {
5
5
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(primaryKey)) throw new TypeError('primaryKey must be a simple SQL identifier')
6
6
  let snapshot = null
7
7
  let operation = Promise.resolve()
@@ -10,10 +10,22 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
10
10
  let connected = false
11
11
  let timer
12
12
  let stream
13
+ let streamGeneration = 0
14
+ let lastError = null
15
+ const pendingBackground = new Set()
16
+ function report(error) {
17
+ lastError = error?.message ?? String(error)
18
+ try { onError?.(error, { modelName }) } catch (callbackError) { console.error(callbackError) }
19
+ notify()
20
+ }
21
+ function background(promise) {
22
+ pendingBackground.add(promise)
23
+ void promise.catch(report).finally(() => pendingBackground.delete(promise))
24
+ }
13
25
  const listeners = new Set()
14
26
 
15
27
  async function prepare() {
16
- await db.exec(`
28
+ await retryLeader(() => db.exec(`
17
29
  CREATE SEQUENCE IF NOT EXISTS electric_mutation_revision_seq;
18
30
  CREATE TABLE IF NOT EXISTS electric_sync_client (
19
31
  singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
@@ -33,11 +45,11 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
33
45
  failure_reason TEXT,
34
46
  PRIMARY KEY (model_name, row_id)
35
47
  );
36
- `)
48
+ `))
37
49
  }
38
50
 
39
51
  function notify() {
40
- channel?.postMessage({ modelName })
52
+ channel?.postMessage({ modelName, ...(ownsSync ? { online: connected && network?.onLine !== false } : {}) })
41
53
  for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
42
54
  }
43
55
 
@@ -47,7 +59,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
47
59
  }
48
60
 
49
61
  async function findMany(where = {}) {
50
- const { rows } = await db.query('SELECT data FROM electric_local_rows WHERE model_name = $1 ORDER BY row_id', [modelName])
62
+ const { rows } = await retryLeader(() => db.query('SELECT data FROM electric_local_rows WHERE model_name = $1 ORDER BY row_id', [modelName]))
51
63
  return rows.map(row => row.data).filter(row => matchesWhere(row, where))
52
64
  }
53
65
 
@@ -73,11 +85,11 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
73
85
  assertId(id)
74
86
  const row = { ...data, [primaryKey]: id }
75
87
  await db.transaction(async tx => {
76
- await tx.query('INSERT INTO electric_local_rows (model_name, row_id, data) VALUES ($1, $2, $3::jsonb)', [modelName, String(id), JSON.stringify(row)])
88
+ await tx.query('INSERT INTO electric_local_rows (model_name, row_id, data) VALUES ($1, $2, $3::jsonb)', [modelName, String(id), json(row)])
77
89
  await queueMutation(tx, id, 'create', row)
78
90
  })
79
91
  notify()
80
- if (started) void flushQueue()
92
+ if (started) background(flushQueue())
81
93
  return row
82
94
  }
83
95
 
@@ -90,14 +102,14 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
90
102
  const current = await tx.query('SELECT data FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
91
103
  if (!current.rows[0]) throw new Error('row not found')
92
104
  row = { ...current.rows[0].data, ...data }
93
- await tx.query('UPDATE electric_local_rows SET data = $3::jsonb WHERE model_name = $1 AND row_id = $2', [modelName, String(id), JSON.stringify(row)])
105
+ await tx.query('UPDATE electric_local_rows SET data = $3::jsonb WHERE model_name = $1 AND row_id = $2', [modelName, String(id), json(row)])
94
106
  const queued = await tx.query('SELECT action, acknowledged_version FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
95
107
  if (queued.rows[0]?.action === 'delete') throw new Error('row has a pending delete')
96
108
  const action = queued.rows[0]?.action === 'create' && queued.rows[0].acknowledged_version == null ? 'create' : 'update'
97
109
  await queueMutation(tx, id, action, row)
98
110
  })
99
111
  notify()
100
- if (started) void flushQueue()
112
+ if (started) background(flushQueue())
101
113
  return row
102
114
  }
103
115
 
@@ -108,7 +120,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
108
120
  await queueMutation(tx, id, 'delete', null)
109
121
  })
110
122
  notify()
111
- if (started) void flushQueue()
123
+ if (started) background(flushQueue())
112
124
  }
113
125
 
114
126
  async function queueMutation(tx, id, action, data) {
@@ -118,7 +130,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
118
130
  action = EXCLUDED.action, data = EXCLUDED.data,
119
131
  revision = nextval('electric_mutation_revision_seq'),
120
132
  acknowledged_version = NULL, status = 'pending', failure_reason = NULL`,
121
- [modelName, String(id), action, data == null ? null : JSON.stringify(data)])
133
+ [modelName, String(id), action, data == null ? null : json(data)])
122
134
  }
123
135
 
124
136
  function ordered(task) {
@@ -145,7 +157,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
145
157
  }
146
158
 
147
159
  async function reconcileSnapshot(rows) {
148
- await db.transaction(async tx => {
160
+ await retryLeader(() => db.transaction(async tx => {
149
161
  for (const row of rows) {
150
162
  const id = String(row[primaryKey])
151
163
  if (row.version != null) {
@@ -158,7 +170,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
158
170
  SELECT $1, $2, $3::jsonb WHERE NOT EXISTS
159
171
  (SELECT 1 FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2)
160
172
  ON CONFLICT (model_name, row_id) DO UPDATE SET data = EXCLUDED.data`,
161
- [modelName, id, JSON.stringify(row)])
173
+ [modelName, id, json(row)])
162
174
  }
163
175
  const activeIds = rows.filter(row => !row.deleted).map(row => String(row[primaryKey]))
164
176
  await tx.query(`DELETE FROM electric_local_rows AS local
@@ -166,7 +178,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
166
178
  AND NOT EXISTS (SELECT 1 FROM electric_mutation_queue AS pending
167
179
  WHERE pending.model_name = local.model_name AND pending.row_id = local.row_id)`,
168
180
  [modelName, activeIds])
169
- })
181
+ }))
170
182
  notify()
171
183
  }
172
184
 
@@ -174,15 +186,17 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
174
186
  if (!ownsSync || flushing || network?.onLine === false) return
175
187
  flushing = true
176
188
  try {
177
- while (true) {
178
- const { rows } = await db.query(`SELECT * FROM electric_mutation_queue
189
+ while (ownsSync) {
190
+ const { rows } = await retryLeader(() => db.query(`SELECT * FROM electric_mutation_queue
179
191
  WHERE model_name = $1 AND status = 'pending' AND acknowledged_version IS NULL
180
- ORDER BY revision LIMIT 1`, [modelName])
192
+ ORDER BY revision LIMIT 1`, [modelName]))
181
193
  const mutation = rows[0]
182
194
  if (!mutation) break
183
195
  try {
184
196
  await sendMutation(mutation)
197
+ lastError = null
185
198
  } catch (error) {
199
+ report(error)
186
200
  if (!isPermanent(error)) break
187
201
  await db.query(`UPDATE electric_mutation_queue SET status = 'failed', failure_reason = $3
188
202
  WHERE model_name = $1 AND row_id = $2 AND revision = $4`,
@@ -196,7 +210,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
196
210
  }
197
211
 
198
212
  async function sendMutation(mutation) {
199
- const client = await db.query('SELECT id FROM electric_sync_client WHERE singleton = true')
213
+ const client = await retryLeader(() => db.query('SELECT id FROM electric_sync_client WHERE singleton = true'))
200
214
  const metadata = { clientId: client.rows[0].id, revision: String(mutation.revision) }
201
215
  const { row_id: id, data, action } = mutation
202
216
  const result = action === 'create'
@@ -205,7 +219,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
205
219
  ? await service.update(id, withoutKey(data), metadata)
206
220
  : await service.delete(id, metadata)
207
221
  if (result?.version == null) throw new Error('sync mutation did not return a version')
208
- await db.transaction(async tx => {
222
+ await retryLeader(() => db.transaction(async tx => {
209
223
  const current = await tx.query('SELECT action, revision, data FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, id])
210
224
  const queued = current.rows[0]
211
225
  if (!queued) return
@@ -217,7 +231,7 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
217
231
  await tx.query(`UPDATE electric_mutation_queue SET action = 'update',
218
232
  revision = nextval('electric_mutation_revision_seq') WHERE model_name = $1 AND row_id = $2`, [modelName, id])
219
233
  }
220
- })
234
+ }))
221
235
  await reconcile()
222
236
  notify()
223
237
  }
@@ -235,45 +249,64 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
235
249
  started = true
236
250
  channel?.addEventListener('message', sharedChange)
237
251
  globalThis.addEventListener?.('online', online)
238
- if (!ownsSync) return
239
- stream = new ShapeStreamClass({ url })
252
+ if (!ownsSync) { channel?.postMessage({ modelName, requestStatus: true }); return }
253
+ const generation = ++streamGeneration
254
+ stream = new ShapeStreamClass({ ...streamOptions, url })
240
255
  stream.subscribe(messages => {
241
- if (messages.some(message => message.headers?.control === 'must-refetch')) void resetSnapshot()
256
+ if (generation !== streamGeneration) return
257
+ if (messages.some(message => message.headers?.control === 'must-refetch')) background(resetSnapshot())
242
258
  connected = stream.isConnected?.() ?? true
243
259
  notify()
244
- }, () => { connected = false; notify() })
260
+ }, error => { if (generation !== streamGeneration) return; connected = false; report(error) })
245
261
  const shape = new ShapeClass(stream)
246
- shape.subscribe(({ rows }) => { connected = true; void applySnapshot([...rows]) })
247
- timer = setInterval(() => { void flushQueue() }, retryMs)
248
- void flushQueue()
262
+ shape.subscribe(({ rows }) => { if (generation !== streamGeneration) return; connected = true; background(applySnapshot([...rows])) })
263
+ timer = setInterval(() => { background(flushQueue()) }, retryMs)
264
+ background(flushQueue())
265
+ }
266
+
267
+ function setSyncOwner(value) {
268
+ if (ownsSync === value) return
269
+ const restart = started
270
+ stop()
271
+ ownsSync = value
272
+ if (restart) start()
273
+ }
274
+
275
+ async function waitForIdle() {
276
+ while (pendingBackground.size) await Promise.allSettled([...pendingBackground])
277
+ await operation
249
278
  }
250
279
 
251
280
  function stop() {
252
281
  if (!started) return
253
282
  started = false
283
+ ++streamGeneration
254
284
  clearInterval(timer)
255
285
  stream?.unsubscribeAll()
256
286
  channel?.removeEventListener('message', sharedChange)
257
287
  globalThis.removeEventListener?.('online', online)
258
288
  connected = false
289
+ notify()
259
290
  }
260
291
 
261
- function online() { void flushQueue() }
292
+ function online() { background(flushQueue()) }
262
293
  function sharedChange(event) {
263
294
  if (event.data?.modelName !== modelName) return
295
+ if (!ownsSync && typeof event.data.online === 'boolean') connected = event.data.online
296
+ if (ownsSync && event.data.requestStatus) notify()
264
297
  for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
265
- if (ownsSync) void flushQueue()
298
+ if (ownsSync) background(flushQueue())
266
299
  }
267
300
 
268
301
  async function getStatus() {
269
- const { rows } = await db.query(`SELECT
302
+ const { rows } = await retryLeader(() => db.query(`SELECT
270
303
  count(*) FILTER (WHERE status = 'pending')::int AS pending,
271
304
  count(*) FILTER (WHERE status = 'failed')::int AS failed
272
- FROM electric_mutation_queue WHERE model_name = $1`, [modelName])
273
- return { ...rows[0], online: network?.onLine !== false && connected }
305
+ FROM electric_mutation_queue WHERE model_name = $1`, [modelName]))
306
+ return { ...rows[0], online: network?.onLine !== false && connected, ...(lastError ? { error: lastError } : {}) }
274
307
  }
275
308
 
276
- return { prepare, start, stop, subscribe, findMany, findUnique, getObservable,
309
+ return { prepare, start, stop, setSyncOwner, waitForIdle, subscribe, findMany, findUnique, getObservable,
277
310
  create, update, remove, flushQueue, getStatus }
278
311
  }
279
312
 
@@ -309,3 +342,19 @@ function isPermanent(error) {
309
342
  return error?.code === 'forbidden' || error instanceof TypeError ||
310
343
  status >= 400 && status < 500 && ![408, 425, 429].includes(status)
311
344
  }
345
+
346
+ function json(value) {
347
+ return JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? String(item) : item)
348
+ }
349
+
350
+ // Snapshots and reads are safe to repeat if PGlite's worker changes mid-request.
351
+ // Foreground local mutation transactions may have committed; callers see their error.
352
+ async function retryLeader(action) {
353
+ for (let attempt = 0; ; attempt++) {
354
+ try { return await action() } catch (error) {
355
+ if (!(error?.name === 'LeaderChangedError' || error?.constructor?.name === 'LeaderChangedError' ||
356
+ error?.message === 'Leader changed, pending operation in indeterminate state') || attempt >= 7) throw error
357
+ await new Promise(resolve => setTimeout(resolve, Math.min(25 * 2 ** attempt, 250)))
358
+ }
359
+ }
360
+ }
@@ -0,0 +1,4 @@
1
+ import { PGlite } from '@electric-sql/pglite'
2
+ import { worker } from '@electric-sql/pglite/worker'
3
+
4
+ await worker({ init: options => PGlite.create(options) })
@@ -210,7 +210,11 @@ async function syncMutation(db, model, action, id, data, mutation) {
210
210
  }
211
211
 
212
212
  async function writeTombstone(tx, model, id) {
213
- const tombstone = Object.entries(model.tombstoneData)
213
+ const data = typeof model.tombstoneData === 'function'
214
+ ? await model.tombstoneData({ id })
215
+ : model.tombstoneData
216
+ validateTombstoneData(data, model.primaryKey)
217
+ const tombstone = Object.entries(data)
214
218
  const columns = [model.quotedPrimaryKey, ...tombstone.map(([column]) => quoteIdentifier(column, 'tombstone column')), 'deleted']
215
219
  const values = [id, ...tombstone.map(([, value]) => value)]
216
220
  const parameters = values.map((_, index) => `$${index + 1}`)
@@ -247,11 +251,7 @@ function normalizeModels(models) {
247
251
  const primaryKey = config.primaryKey ?? 'id'
248
252
  quoteIdentifier(name, 'model name')
249
253
  const tombstoneData = config.tombstoneData ?? {}
250
- assertPlainObject(tombstoneData, 'tombstoneData')
251
- for (const column of Object.keys(tombstoneData)) {
252
- quoteIdentifier(column, 'tombstone column')
253
- if ([primaryKey, 'version', 'deleted'].includes(column)) throw new TypeError('tombstoneData contains a sync-managed column')
254
- }
254
+ if (typeof tombstoneData !== 'function') validateTombstoneData(tombstoneData, primaryKey)
255
255
  return {
256
256
  name,
257
257
  table,
@@ -263,6 +263,14 @@ function normalizeModels(models) {
263
263
  })
264
264
  }
265
265
 
266
+ function validateTombstoneData(data, primaryKey) {
267
+ assertPlainObject(data, 'tombstoneData')
268
+ for (const column of Object.keys(data)) {
269
+ quoteIdentifier(column, 'tombstone column')
270
+ if ([primaryKey, 'version', 'deleted'].includes(column)) throw new TypeError('tombstoneData contains a sync-managed column')
271
+ }
272
+ }
273
+
266
274
  function assertPlainObject(value, label) {
267
275
  if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
268
276
  throw new TypeError(`${label} must be a plain object`)
@@ -142,6 +142,7 @@ test('model mutations retain the simple Express-X API', async () => {
142
142
  await todo.update(created.uid, { completed: true })
143
143
  await todo.remove(created.uid)
144
144
  assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
145
+ assert.deepEqual(calls[0][2], { title: 'Test' })
145
146
  })
146
147
 
147
148
  test('model creation supports server-generated IDs', async () => {
@@ -0,0 +1,254 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { PGlite } from '@electric-sql/pglite'
4
+ import { electricClientPlugin } from '../src/electric-client-plugin.mjs'
5
+
6
+ class Locks {
7
+ queues = new Map()
8
+ request(name, { signal }, callback) {
9
+ return new Promise((resolve, reject) => {
10
+ const queue = this.queues.get(name) ?? []
11
+ this.queues.set(name, queue)
12
+ const job = { callback, resolve, reject, active: false }
13
+ signal.addEventListener('abort', () => {
14
+ if (job.active) return
15
+ const index = queue.indexOf(job)
16
+ if (index !== -1) queue.splice(index, 1)
17
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
18
+ }, { once: true })
19
+ queue.push(job)
20
+ if (queue.length === 1) this.run(name)
21
+ })
22
+ }
23
+ run(name) {
24
+ const queue = this.queues.get(name)
25
+ const job = queue[0]
26
+ if (!job) return
27
+ job.active = true
28
+ Promise.resolve().then(job.callback).then(job.resolve, job.reject).finally(() => {
29
+ queue.shift()
30
+ this.run(name)
31
+ })
32
+ }
33
+ }
34
+ class Channel extends EventTarget {
35
+ static instances = new Set()
36
+ constructor(name) { super(); this.name = name; Channel.instances.add(this) }
37
+ postMessage(data) {
38
+ for (const channel of Channel.instances) {
39
+ if (channel !== this && channel.name === this.name) channel.dispatchEvent(new MessageEvent('message', { data }))
40
+ }
41
+ }
42
+ close() { Channel.instances.delete(this) }
43
+ }
44
+ class Stream {
45
+ static instances = []
46
+ constructor(options) { this.options = options; Stream.instances.push(this) }
47
+ subscribe() { return () => {} }
48
+ isConnected() { return true }
49
+ unsubscribeAll() { this.stopped = true }
50
+ }
51
+ class Shape {
52
+ constructor(stream) { stream.shape = this }
53
+ subscribe(callback) { this.emit = callback; return () => {} }
54
+ }
55
+ async function until(predicate) {
56
+ for (let i = 0; i < 100; i++) {
57
+ if (await predicate()) return
58
+ await new Promise(resolve => setTimeout(resolve, 5))
59
+ }
60
+ assert.fail('condition did not become true')
61
+ }
62
+ function configure(db, locks, service = {}, extra = {}) {
63
+ const app = { service: () => service }
64
+ electricClientPlugin(app, {
65
+ sync: true, databaseName: 'test-db', localDb: db, locks,
66
+ BroadcastChannel: Channel, ShapeStream: Stream, Shape, ...extra,
67
+ })
68
+ return app
69
+ }
70
+
71
+ test('managed models initialize automatically, share one database, and cache models', async () => {
72
+ const db = new PGlite()
73
+ let created = 0
74
+ const locks = new Locks()
75
+ const app = configure(undefined, locks, {}, { createDatabase: async ({ dataDir }) => {
76
+ assert.equal(dataDir, 'idb://test-db'); created++; return db
77
+ } })
78
+ try {
79
+ const users = app.createElectricModel('users', { primaryKey: 'uid' })
80
+ const ranges = app.createElectricModel('ranges', { primaryKey: 'uid' })
81
+ assert.equal(app.createElectricModel('users', { primaryKey: 'uid' }), users)
82
+ assert.throws(() => app.createElectricModel('users'), /primaryKey/)
83
+ const row = await users.create({ name: 'Works before prepare' })
84
+ await ranges.prepare()
85
+ assert.equal(created, 1)
86
+ assert.equal((await users.findUnique({ uid: row.uid })).name, row.name)
87
+ } finally {
88
+ await app.disposeElectricSync()
89
+ }
90
+ assert.equal(db.closed, true)
91
+ })
92
+
93
+ test('only one tab streams and flushes; follower edits broadcast and ownership transfers', async () => {
94
+ const db = new PGlite()
95
+ const locks = new Locks()
96
+ const calls = []
97
+ const service = { async create(id, data) { calls.push(id); return { id, ...data, version: '42' } } }
98
+ const first = configure(db, locks, service)
99
+ const second = configure(db, locks, service)
100
+ const before = Stream.instances.length
101
+ let subscription
102
+ try {
103
+ const owner = first.createElectricModel('todos')
104
+ const follower = second.createElectricModel('todos')
105
+ await owner.prepare()
106
+ await follower.prepare()
107
+ await until(() => Stream.instances.length === before + 1)
108
+ let observed = []
109
+ subscription = owner.getObservable().subscribe(rows => { observed = rows })
110
+ const row = await follower.create({ title: 'Written in follower' })
111
+ await until(() => calls.length === 1 && observed.length === 1)
112
+ assert.equal(observed[0].id, row.id)
113
+ assert.equal(Stream.instances.length, before + 1)
114
+ const stream = Stream.instances.at(-1)
115
+ stream.shape.emit({ rows: [{ ...row, version: 42n, deleted: false }] })
116
+ await until(async () => (await follower.getStatus()).pending === 0)
117
+ assert.equal((await follower.findMany())[0].version, '42')
118
+ await owner.dispose()
119
+ await until(() => Stream.instances.length === before + 2)
120
+ assert.equal(stream.stopped, true)
121
+ const next = Stream.instances.at(-1)
122
+ next.shape.emit({ rows: [{ ...row, version: 42n, deleted: false }] })
123
+ await until(async () => (await follower.findMany()).length === 1)
124
+ assert.equal(calls.length, 1)
125
+ } finally {
126
+ subscription?.unsubscribe()
127
+ await first.disposeElectricSync()
128
+ await second.disposeElectricSync()
129
+ await db.close()
130
+ }
131
+ })
132
+
133
+ test('stopping a waiting follower cancels takeover; restart can acquire ownership', async () => {
134
+ const db = new PGlite()
135
+ const locks = new Locks()
136
+ const first = configure(db, locks)
137
+ const second = configure(db, locks)
138
+ const before = Stream.instances.length
139
+ try {
140
+ const owner = first.createElectricModel('todos')
141
+ const follower = second.createElectricModel('todos')
142
+ await owner.prepare(); await follower.prepare()
143
+ await until(() => Stream.instances.length === before + 1)
144
+ follower.stop()
145
+ owner.stop()
146
+ await new Promise(resolve => setTimeout(resolve, 10))
147
+ assert.equal(Stream.instances.length, before + 1)
148
+ await follower.start()
149
+ await until(() => Stream.instances.length === before + 2)
150
+ } finally {
151
+ await first.disposeElectricSync(); await second.disposeElectricSync(); await db.close()
152
+ }
153
+ })
154
+
155
+ test('initialization errors reject writes and observable reads and call onError', async () => {
156
+ const error = new Error('Worker could not load assets')
157
+ const reported = []
158
+ const app = configure(undefined, new Locks(), {}, {
159
+ createDatabase: async () => { throw error },
160
+ onError: cause => reported.push(cause),
161
+ })
162
+ const model = app.createElectricModel('todos')
163
+ await assert.rejects(model.create({ title: 'No silent failure' }), /load assets/)
164
+ await new Promise(resolve => model.getObservable().subscribe({ error: cause => {
165
+ assert.equal(cause, error); resolve()
166
+ } }))
167
+ assert.deepEqual(reported, [error])
168
+ await assert.rejects(app.disposeElectricSync(), /load assets/)
169
+ })
170
+
171
+ test('sync mode validates browser capabilities and persistent database identity', () => {
172
+ const app = { service: () => ({}) }
173
+ assert.throws(() => electricClientPlugin(app, { sync: true }), /databaseName/)
174
+ assert.throws(() => electricClientPlugin(app, { sync: true, databaseName: 'todos', locks: {} }), /Web Locks/)
175
+ })
176
+
177
+ test('default worker errors and timeouts reject preparation instead of hanging', async () => {
178
+ for (const triggerError of [true, false]) {
179
+ let terminated = false
180
+ class Worker extends EventTarget {
181
+ constructor() {
182
+ super()
183
+ if (triggerError) setTimeout(() => {
184
+ const event = new Event('error')
185
+ event.message = 'worker module not found'
186
+ this.dispatchEvent(event)
187
+ }, 10)
188
+ }
189
+ terminate() { terminated = true }
190
+ }
191
+ const app = configure(undefined, new Locks(), {}, { workerFactory: () => new Worker(), initTimeoutMs: 30 })
192
+ const model = app.createElectricModel('todos')
193
+ await assert.rejects(model.prepare(), triggerError ? /module not found/ : /timed out/)
194
+ assert.equal(terminated, true)
195
+ await assert.rejects(app.disposeElectricSync())
196
+ }
197
+ })
198
+
199
+ test('snapshots and reads recover after a database leader changes', async () => {
200
+ const db = new PGlite()
201
+ let failSnapshot = false
202
+ let failRead = false
203
+ let snapshotRetries = 0
204
+ const leaderError = () => Object.assign(new Error('Leader changed, pending operation in indeterminate state'), { name: 'LeaderChangedError' })
205
+ const shared = {
206
+ exec: db.exec.bind(db),
207
+ async query(...args) {
208
+ if (failRead) { failRead = false; throw leaderError() }
209
+ return db.query(...args)
210
+ },
211
+ async transaction(callback) {
212
+ if (failSnapshot) { failSnapshot = false; snapshotRetries++; throw leaderError() }
213
+ return db.transaction(callback)
214
+ },
215
+ }
216
+ const app = configure(shared, new Locks())
217
+ const before = Stream.instances.length
218
+ try {
219
+ const model = app.createElectricModel('todos')
220
+ await model.prepare()
221
+ await until(() => Stream.instances.length === before + 1)
222
+ failSnapshot = true
223
+ const id = crypto.randomUUID()
224
+ Stream.instances.at(-1).shape.emit({ rows: [{ id, title: 'After election', version: 99n, deleted: false }] })
225
+ await until(async () => (await model.findMany()).length === 1)
226
+ assert.equal(snapshotRetries, 1)
227
+ failRead = true
228
+ assert.equal((await model.findUnique({ id })).version, '99')
229
+ assert.equal((await model.getStatus()).error, null)
230
+ } finally { await app.disposeElectricSync(); await db.close() }
231
+ })
232
+
233
+ test('an indeterminate foreground mutation is not replayed automatically', async () => {
234
+ const db = new PGlite()
235
+ let writes = 0
236
+ const app = { service: () => ({}) }
237
+ const localDb = {
238
+ exec: db.exec.bind(db), query: db.query.bind(db),
239
+ async transaction(callback) {
240
+ writes++
241
+ await db.transaction(callback)
242
+ throw Object.assign(new Error('Leader changed, pending operation in indeterminate state'), { name: 'LeaderChangedError' })
243
+ },
244
+ }
245
+ electricClientPlugin(app)
246
+ const model = app.createElectricModel('todos', { localDb, ownsSync: false })
247
+ try {
248
+ await model.prepare()
249
+ await assert.rejects(model.create({ title: 'Committed but reply lost' }), /Leader changed/)
250
+ assert.equal(writes, 1)
251
+ assert.equal((await model.findMany()).length, 1)
252
+ assert.equal((await model.getStatus()).pending, 1)
253
+ } finally { await db.close() }
254
+ })
@@ -223,3 +223,81 @@ test('sync delete writes a versioned tombstone and clears configured fields', as
223
223
  assert.match(tombstone.sql, /version = nextval/)
224
224
  assert.deepEqual(tombstone.values, [id, ''])
225
225
  })
226
+
227
+
228
+ test('sync tombstone factories generate per-row values and skip replayed revisions', async () => {
229
+ const queries = []
230
+ const cursors = new Map()
231
+ const calls = []
232
+ const client = {
233
+ async query(sql, values = []) {
234
+ queries.push({ sql, values })
235
+ if (sql.includes('SELECT revision, result FROM electric_mutation_cursor')) {
236
+ return { rows: [cursors.get(values[2]) ?? { revision: '0', result: null }] }
237
+ }
238
+ if (sql.startsWith('UPDATE "users"')) return { rows: [] }
239
+ if (sql.includes('INSERT INTO "users"')) {
240
+ return { rows: [{ uid: values[0], email: values[1], deleted: true, version: '7' }] }
241
+ }
242
+ if (sql.includes('UPDATE electric_mutation_cursor')) {
243
+ cursors.set(values[2], { revision: values[3], result: JSON.parse(values[4]) })
244
+ }
245
+ return { rows: [] }
246
+ },
247
+ release() {},
248
+ }
249
+ const db = { query: client.query.bind(client), async connect() { return client } }
250
+ const services = new Map()
251
+ const app = { createService(name, methods) { services.set(name, methods) }, get() {} }
252
+ electricServerPlugin(app, db, [{
253
+ name: 'users', primaryKey: 'uid',
254
+ tombstoneData: async ({ id }) => {
255
+ calls.push(id)
256
+ return { email: `deleted-${id}@tombstone.invalid` }
257
+ },
258
+ }], { sync: true })
259
+ const service = services.get('users')
260
+ const first = '64d76168-775f-481e-8974-18d31d835d9e'
261
+ const second = '64d76168-775f-481e-8974-18d31d835d9f'
262
+ const metadata = { clientId: '5e199e1c-23a4-473e-9989-14d4a1fb857e', revision: '2' }
263
+ const result = await service.delete.call({}, first, metadata)
264
+ assert.deepEqual(await service.delete.call({}, first, metadata), result)
265
+ assert.deepEqual(await service.delete.call({}, first, { ...metadata, revision: '1' }), result)
266
+ await service.update.call({}, second, { name: 'missing' }, metadata)
267
+ await service.delete.call({}, first, { ...metadata, revision: '3' })
268
+ assert.deepEqual(calls, [first, second, first])
269
+ assert.deepEqual(queries.filter(({ sql }) => sql.includes('INSERT INTO "users"')).map(({ values }) => values), [
270
+ [first, `deleted-${first}@tombstone.invalid`],
271
+ [second, `deleted-${second}@tombstone.invalid`],
272
+ [first, `deleted-${first}@tombstone.invalid`],
273
+ ])
274
+ })
275
+
276
+ test('invalid tombstone factory results roll back before writing a tombstone', async () => {
277
+ for (const [data, message] of [
278
+ [null, /plain object/],
279
+ [[], /plain object/],
280
+ [{ 'email; DROP TABLE users': '' }, /simple SQL identifier/],
281
+ [{ uid: 'override' }, /sync-managed column/],
282
+ [{ version: '0' }, /sync-managed column/],
283
+ [{ deleted: false }, /sync-managed column/],
284
+ ]) {
285
+ const queries = []
286
+ const client = {
287
+ async query(sql) {
288
+ queries.push(sql)
289
+ return { rows: sql.includes('SELECT revision, result') ? [{ revision: '0', result: null }] : [] }
290
+ },
291
+ release() {},
292
+ }
293
+ const services = new Map()
294
+ electricServerPlugin({ createService(name, methods) { services.set(name, methods) }, get() {} },
295
+ { query: client.query.bind(client), async connect() { return client } },
296
+ [{ name: 'users', primaryKey: 'uid', tombstoneData: () => data }], { sync: true })
297
+ await assert.rejects(services.get('users').delete.call({}, '64d76168-775f-481e-8974-18d31d835d9e', {
298
+ clientId: '5e199e1c-23a4-473e-9989-14d4a1fb857e', revision: '1',
299
+ }), message)
300
+ assert.ok(queries.includes('ROLLBACK'))
301
+ assert.ok(!queries.some(sql => sql.includes('INSERT INTO "users"')))
302
+ }
303
+ })