@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
|
|
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
|
-
|
|
244
|
-
|
|
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
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
})
|