@jcbuisson/express-x-plugins 5.0.2 → 5.0.4

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
@@ -239,11 +262,9 @@ can pass plugin-level `localDb` to retain automatic ownership management.
239
262
 
240
263
  ### Explicit offline models
241
264
 
242
- For offline writes, install `@electric-sql/pglite`, pass a shared PGlite database
243
- as `localDb`, and use the sync
244
- server mode above. `PGliteWorker` with an IndexedDB data directory is recommended
245
- for multiple tabs. Only one worker should use `ownsSync: true`; use a shared
246
- `BroadcastChannel` so other tabs receive change notifications.
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.
247
268
 
248
269
  ```js
249
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.2",
3
+ "version": "5.0.4",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -158,20 +158,23 @@ export function createOfflineElectricModel({ db, service, modelName, url, primar
158
158
 
159
159
  async function reconcileSnapshot(rows) {
160
160
  await retryLeader(() => db.transaction(async tx => {
161
- for (const row of rows) {
162
- const id = String(row[primaryKey])
163
- if (row.version != null) {
164
- await tx.query(`DELETE FROM electric_mutation_queue
165
- WHERE model_name = $1 AND row_id = $2 AND acknowledged_version <= $3::numeric`,
166
- [modelName, id, String(row.version)])
167
- }
168
- if (row.deleted) continue
169
- await tx.query(`INSERT INTO electric_local_rows (model_name, row_id, data)
170
- SELECT $1, $2, $3::jsonb WHERE NOT EXISTS
171
- (SELECT 1 FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2)
172
- ON CONFLICT (model_name, row_id) DO UPDATE SET data = EXCLUDED.data`,
173
- [modelName, id, json(row)])
174
- }
161
+ // Send the snapshot once instead of making two worker round trips per row.
162
+ const incoming = json(rows)
163
+ await tx.query(`DELETE FROM electric_mutation_queue AS pending
164
+ USING jsonb_array_elements($2::jsonb) AS incoming(data)
165
+ WHERE pending.model_name = $1
166
+ AND pending.row_id = incoming.data->>$3
167
+ AND pending.acknowledged_version <= (incoming.data->>'version')::numeric`,
168
+ [modelName, incoming, primaryKey])
169
+ await tx.query(`INSERT INTO electric_local_rows (model_name, row_id, data)
170
+ SELECT $1, incoming.data->>$3, incoming.data
171
+ FROM jsonb_array_elements($2::jsonb) AS incoming(data)
172
+ WHERE NOT COALESCE((incoming.data->>'deleted')::boolean, false)
173
+ AND NOT EXISTS (SELECT 1 FROM electric_mutation_queue AS pending
174
+ WHERE pending.model_name = $1 AND pending.row_id = incoming.data->>$3)
175
+ ON CONFLICT (model_name, row_id) DO UPDATE SET data = EXCLUDED.data
176
+ WHERE electric_local_rows.data IS DISTINCT FROM EXCLUDED.data`,
177
+ [modelName, incoming, primaryKey])
175
178
  const activeIds = rows.filter(row => !row.deleted).map(row => String(row[primaryKey]))
176
179
  await tx.query(`DELETE FROM electric_local_rows AS local
177
180
  WHERE local.model_name = $1 AND NOT (local.row_id = ANY($2::text[]))
@@ -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`)
@@ -66,3 +66,43 @@ test('offline writes stay local until the acknowledged Electric version arrives'
66
66
  await db.close()
67
67
  }
68
68
  })
69
+
70
+ test('large snapshots use three queries and preserve bigint versions and tombstones', async () => {
71
+ const db = new PGlite()
72
+ const { createOfflineElectricModel } = await import('../src/electric-offline-model.mjs')
73
+ let queries = 0
74
+ const countedDb = {
75
+ exec: (...args) => db.exec(...args),
76
+ query: (...args) => db.query(...args),
77
+ transaction: callback => db.transaction(tx => callback({
78
+ query: (...args) => { queries++; return tx.query(...args) },
79
+ })),
80
+ }
81
+ const model = createOfflineElectricModel({
82
+ db: countedDb, service: {}, modelName: 'range', primaryKey: 'uid',
83
+ ShapeStreamClass: Stream, ShapeClass: Shape, ownsSync: true,
84
+ network: { onLine: true }, retryMs: 60_000,
85
+ })
86
+ try {
87
+ await model.prepare()
88
+ model.start()
89
+ await model.waitForIdle()
90
+ const rows = Array.from({ length: 100 }, () => ({
91
+ uid: crypto.randomUUID(), label: 'shared', version: '9007199254740993', deleted: false,
92
+ }))
93
+ Shape.current.emit({ rows })
94
+ await model.waitForIdle()
95
+ assert.equal(queries, 3)
96
+ assert.equal((await model.findMany()).length, 100)
97
+ assert.equal((await model.findMany())[0].version, '9007199254740993')
98
+ queries = 0
99
+ Shape.current.emit({ rows: [{ ...rows[0], deleted: true }, { ...rows[1], label: 'changed' }] })
100
+ await model.waitForIdle()
101
+ assert.equal(queries, 3)
102
+ assert.deepEqual(await model.findMany(), [{ ...rows[1], label: 'changed' }])
103
+ } finally {
104
+ model.stop()
105
+ await model.waitForIdle()
106
+ await db.close()
107
+ }
108
+ })
@@ -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
+ })