@jcbuisson/express-x-plugins 4.1.1 → 5.0.0

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
@@ -44,8 +44,8 @@ import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
44
44
 
45
45
  ## Local-first Postgres plugin
46
46
 
47
- The smallest useful ElectricSQL integration for Express-X. Express-X handles authorized PostgreSQL mutations;
48
- Electric's Shape API streams those changes to clients: Electric is the sync engine.
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
49
 
50
50
  ### Server
51
51
 
@@ -56,15 +56,15 @@ npm install pg
56
56
  ```js
57
57
  import { Pool } from 'pg'
58
58
  import { expressX } from '@jcbuisson/express-x/server'
59
- import { electricOfflinePlugin } from '@jcbuisson/express-x-plugins/electric-server'
59
+ import { electricOfflinePlugin, prepareElectricSyncSchema } from '@jcbuisson/express-x-plugins/electric-server'
60
60
 
61
61
  const app = expressX()
62
62
  const db = new Pool({ connectionString: process.env.DATABASE_URL })
63
63
 
64
64
  app.configure(electricOfflinePlugin, db, [
65
- 'todos',
66
- { name: 'projects', table: 'projects', primaryKey: 'uid' },
65
+ { name: 'todos', tombstoneData: { title: '' } },
67
66
  ], {
67
+ sync: true,
68
68
  electricUrl: process.env.ELECTRIC_URL, // ElectricSQL sync service, e.g. http://localhost:3000/v1/shape
69
69
  sourceId: process.env.ELECTRIC_SOURCE_ID,
70
70
  sourceSecret: process.env.ELECTRIC_SOURCE_SECRET,
@@ -73,9 +73,12 @@ app.configure(electricOfflinePlugin, db, [
73
73
  return Boolean(context.request?.user || context.socket?.data?.user)
74
74
  },
75
75
  })
76
+
77
+ // Run this during startup, before serving sync mutations:
78
+ await prepareElectricSyncSchema(db, [{ name: 'todos' }])
76
79
  ```
77
80
 
78
- This registers one Express-X service per model with the familiar API:
81
+ This registers one Express-X service per model. In direct mode, it has the familiar API:
79
82
 
80
83
  - `findUnique(where)`
81
84
  - `findMany(where, queryOptions)`
@@ -88,7 +91,22 @@ The client model provides synchronized reads through `findMany(where)` and
88
91
  `getObservable(where)`. The service also exposes direct one-shot server reads
89
92
  when synchronization is not required.
90
93
 
91
- Mutation methods return the created, updated, or deleted row directly.
94
+ In sync mode, create requires a client-generated UUID and every mutation requires
95
+ `{ clientId, revision }` as its last argument. The returned row includes `version`.
96
+ The server records the latest revision per client and row in the same transaction
97
+ as the write. Retries replay the saved result; older requests cannot overwrite a
98
+ newer edit from that client. Deletes write versioned tombstones instead of removing
99
+ rows. Configure `tombstoneData` for required columns without defaults, such as a
100
+ required title. Keep the cursor table and tombstones to protect delayed retries and
101
+ offline clients. This does not resolve concurrent edits by different clients.
102
+
103
+ Import `prepareElectricSyncSchema` alongside `electricOfflinePlugin`. It adds
104
+ `version` and `deleted` columns and creates the shared version sequence and cursor
105
+ table. The primary key must be UUID in sync mode. All writes to synced tables must
106
+ advance `version`; hard deletes bypass tombstone confirmation.
107
+
108
+ Without `sync: true`, the existing direct service methods remain available and
109
+ return created, updated, or deleted rows directly.
92
110
 
93
111
  ### Client
94
112
 
@@ -98,6 +116,8 @@ Install the optional client dependencies in the browser application:
98
116
  npm install @electric-sql/client rxjs
99
117
  ```
100
118
 
119
+ For a server configured without `sync: true`, the direct client API is:
120
+
101
121
  ```js
102
122
  import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
103
123
 
@@ -105,7 +125,7 @@ app.configure(electricClientPlugin, {
105
125
  shapePath: '/electric/v1/shape',
106
126
  })
107
127
 
108
- // Client-generated UUID stored in the default `uid` primary key:
128
+ // Client-generated UUID stored in the default `id` primary key:
109
129
  const todo = app.createElectricModel('todos')
110
130
 
111
131
  // Or, for a database-generated primary key such as SERIAL/IDENTITY:
@@ -119,10 +139,10 @@ const subscription = todo.getObservable({ completed: false }).subscribe(rows =>
119
139
  console.log(rows)
120
140
  })
121
141
 
122
- // Mutations use the matching Express-X service and are reflected by Electric.
142
+ // Direct mutations use the matching Express-X service and are reflected by Electric.
123
143
  await todo.create({ title: 'Learn Shapes', completed: false })
124
- await todo.update(uid, { completed: true })
125
- await todo.remove(uid)
144
+ await todo.update(id, { completed: true })
145
+ await todo.remove(id)
126
146
 
127
147
  const created = await numberedTodo.create({ title: 'Assigned by PostgreSQL' })
128
148
  console.log(created.id)
@@ -130,6 +150,32 @@ console.log(created.id)
130
150
  subscription.unsubscribe()
131
151
  ```
132
152
 
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.
158
+
159
+ ```js
160
+ const todo = app.createElectricModel('todos', {
161
+ localDb: db, // PGliteWorker with a persistent idb:// data directory
162
+ channel: new BroadcastChannel('todos-sync'),
163
+ })
164
+ await todo.prepare() // creates local rows, mutation queue, client ID, revision sequence
165
+ todo.start() // starts Electric and mutation retries
166
+ await todo.create({ title: 'Works offline' })
167
+ await todo.update(id, { title: 'Still offline' })
168
+ await todo.remove(id)
169
+ const rows = await todo.findMany()
170
+ const status = await todo.getStatus() // pending, failed, online
171
+ ```
172
+
173
+ Local edits and queue entries commit together. Successful service responses leave
174
+ queue entries in place until Electric sends the acknowledged `version` or newer.
175
+ Rows with `deleted = true` confirm deletes but stay hidden locally. Network errors
176
+ remain queued for retry; permanent HTTP 4xx errors are marked failed. The local
177
+ database, including its identity and revision sequence, must remain persistent.
178
+
133
179
  `findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
134
180
 
135
181
  It unsubscribes after the first emission; if called within a Vue scope, it also
@@ -143,7 +189,7 @@ configured table, and Electric source credentials stay server-side.
143
189
 
144
190
  ### Model configuration
145
191
 
146
- Server models may be strings (table, service name, and default `uid` key) or objects:
192
+ Server models may be strings (table, service name, and default `id` key) or objects:
147
193
 
148
194
  ```js
149
195
  { name: 'todo', table: 'todos', primaryKey: 'id' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.1.1",
3
+ "version": "5.0.0",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/client": "^1.5.24",
38
+ "@electric-sql/pglite": "^0.5.8",
38
39
  "rxjs": "^7.8.2",
39
40
  "vue": "^3.5.0"
40
41
  },
@@ -42,6 +43,9 @@
42
43
  "@electric-sql/client": {
43
44
  "optional": true
44
45
  },
46
+ "@electric-sql/pglite": {
47
+ "optional": true
48
+ },
45
49
  "rxjs": {
46
50
  "optional": true
47
51
  },
@@ -51,6 +55,7 @@
51
55
  },
52
56
  "devDependencies": {
53
57
  "@electric-sql/client": "^1.5.24",
58
+ "@electric-sql/pglite": "^0.5.8",
54
59
  "rxjs": "^7.8.2",
55
60
  "vue": "^3.5.0"
56
61
  }
@@ -1,7 +1,7 @@
1
1
  import { Shape, ShapeStream } from '@electric-sql/client'
2
- import { Observable } from 'rxjs'
3
- // import { firstValueFrom, Subject, takeUntil } from 'rxjs'
4
- // import { getCurrentScope, onScopeDispose } from 'vue'
2
+ import { Observable, firstValueFrom, Subject, takeUntil } from 'rxjs'
3
+ import { getCurrentScope, onScopeDispose } from 'vue'
4
+ import { createOfflineElectricModel } from './electric-offline-model.mjs'
5
5
 
6
6
  /**
7
7
  * Add Electric-backed reactive models to an Express-X client.
@@ -23,11 +23,22 @@ export function electricClientPlugin(app, options = {}) {
23
23
  quoteIdentifier(modelName)
24
24
  const service = app.service(modelName)
25
25
  const url = modelOptions.url ?? modelPath(shapePath, modelName)
26
+ if (modelOptions.localDb) return createOfflineElectricModel({
27
+ db: modelOptions.localDb, service, modelName, url,
28
+ primaryKey: modelOptions.primaryKey ?? 'id',
29
+ ShapeStreamClass, ShapeClass,
30
+ ownsSync: modelOptions.ownsSync ?? true,
31
+ channel: modelOptions.channel,
32
+ network: modelOptions.network ?? globalThis.navigator,
33
+ retryMs: modelOptions.retryMs ?? 5000,
34
+ })
26
35
  const streamOptions = modelOptions.streamOptions ?? {}
27
- // const idGeneration = modelOptions.idGeneration ?? 'client'
28
- // if (!['client', 'server'].includes(idGeneration)) {
29
- // throw new TypeError("idGeneration must be 'client' or 'server'")
30
- // }
36
+ const idGeneration = modelOptions.idGeneration ?? 'client'
37
+ const primaryKey = modelOptions.primaryKey ?? 'id'
38
+ quoteIdentifier(primaryKey)
39
+ if (!['client', 'server'].includes(idGeneration)) {
40
+ throw new TypeError("idGeneration must be 'client' or 'server'")
41
+ }
31
42
 
32
43
  function getObservable(where = {}) {
33
44
  // Validate eagerly
@@ -53,35 +64,31 @@ export function electricClientPlugin(app, options = {}) {
53
64
  })
54
65
  }
55
66
 
56
- // function findMany(where = {}) {
57
- // const observable = getObservable(where)
58
- // if (!getCurrentScope()) return firstValueFrom(observable)
59
-
60
- // const scopeDisposed = new Subject()
61
- // onScopeDispose(() => {
62
- // scopeDisposed.next()
63
- // scopeDisposed.complete()
64
- // })
65
- // return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
66
- // }
67
-
68
- // async function create(data) {
69
- // assertPlainObject(data, 'mutation data')
70
- // if (idGeneration === 'server') return service.create(data)
71
- // const uid = globalThis.crypto?.randomUUID?.()
72
- // if (!uid) throw new Error('crypto.randomUUID() is required')
73
- // return service.create(uid, data)
74
- // }
67
+ function findMany(where = {}) {
68
+ const observable = getObservable(where)
69
+ if (!getCurrentScope()) return firstValueFrom(observable)
70
+ const scopeDisposed = new Subject()
71
+ onScopeDispose(() => {
72
+ scopeDisposed.next()
73
+ scopeDisposed.complete()
74
+ })
75
+ return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
76
+ }
77
+
78
+ async function findUnique(where) {
79
+ const rows = await findMany(where)
80
+ return rows[0] ?? null
81
+ }
75
82
 
76
83
  async function create(uidOrData, data) {
77
84
  const hasUid = data !== undefined
78
85
  const mutationData = hasUid ? data : uidOrData
79
86
  assertPlainObject(mutationData, 'mutation data')
80
- if (hasUid) {
81
- return service.create(uidOrData, data)
82
- } else {
83
- return service.create(data)
84
- }
87
+ if (hasUid) return service.create(uidOrData, data)
88
+ if (idGeneration === 'server') return service.create(mutationData)
89
+ const uid = globalThis.crypto?.randomUUID?.()
90
+ if (!uid) throw new Error('crypto.randomUUID() is required')
91
+ return service.create(uid, data)
85
92
  }
86
93
 
87
94
  async function update(id, data) {
@@ -92,7 +99,7 @@ export function electricClientPlugin(app, options = {}) {
92
99
  return service.delete(id)
93
100
  }
94
101
 
95
- return { getObservable, /*findMany,*/ create, update, remove }
102
+ return { getObservable, findMany, findUnique, create, update, remove }
96
103
  }
97
104
 
98
105
  return Object.assign(app, { createElectricModel })
@@ -0,0 +1,311 @@
1
+ import { Observable } from 'rxjs'
2
+
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 }) {
5
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(primaryKey)) throw new TypeError('primaryKey must be a simple SQL identifier')
6
+ let snapshot = null
7
+ let operation = Promise.resolve()
8
+ let flushing = false
9
+ let started = false
10
+ let connected = false
11
+ let timer
12
+ let stream
13
+ const listeners = new Set()
14
+
15
+ async function prepare() {
16
+ await db.exec(`
17
+ CREATE SEQUENCE IF NOT EXISTS electric_mutation_revision_seq;
18
+ CREATE TABLE IF NOT EXISTS electric_sync_client (
19
+ singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
20
+ id UUID NOT NULL DEFAULT gen_random_uuid()
21
+ );
22
+ INSERT INTO electric_sync_client (singleton) VALUES (true) ON CONFLICT DO NOTHING;
23
+ CREATE TABLE IF NOT EXISTS electric_local_rows (
24
+ model_name TEXT NOT NULL, row_id TEXT NOT NULL, data JSONB NOT NULL,
25
+ PRIMARY KEY (model_name, row_id)
26
+ );
27
+ CREATE TABLE IF NOT EXISTS electric_mutation_queue (
28
+ model_name TEXT NOT NULL, row_id TEXT NOT NULL,
29
+ action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
30
+ data JSONB, revision BIGINT NOT NULL DEFAULT nextval('electric_mutation_revision_seq'),
31
+ acknowledged_version NUMERIC,
32
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'failed')),
33
+ failure_reason TEXT,
34
+ PRIMARY KEY (model_name, row_id)
35
+ );
36
+ `)
37
+ }
38
+
39
+ function notify() {
40
+ channel?.postMessage({ modelName })
41
+ for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
42
+ }
43
+
44
+ function subscribe(listener) {
45
+ listeners.add(listener)
46
+ return () => listeners.delete(listener)
47
+ }
48
+
49
+ 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])
51
+ return rows.map(row => row.data).filter(row => matchesWhere(row, where))
52
+ }
53
+
54
+ async function findUnique(where) {
55
+ return (await findMany(where))[0] ?? null
56
+ }
57
+
58
+ function getObservable(where = {}) {
59
+ return new Observable(subscriber => {
60
+ let active = true
61
+ const emit = () => { void findMany(where).then(rows => { if (active) subscriber.next(rows) }, error => subscriber.error(error)) }
62
+ const unsubscribe = subscribe(emit)
63
+ emit()
64
+ return () => { active = false; unsubscribe() }
65
+ })
66
+ }
67
+
68
+ async function create(data) {
69
+ assertData(data)
70
+ if ('version' in data || 'deleted' in data) throw new TypeError('version and deleted are managed by sync')
71
+ const id = data[primaryKey] ?? globalThis.crypto?.randomUUID?.()
72
+ if (!id) throw new Error('crypto.randomUUID() is required')
73
+ assertId(id)
74
+ const row = { ...data, [primaryKey]: id }
75
+ 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)])
77
+ await queueMutation(tx, id, 'create', row)
78
+ })
79
+ notify()
80
+ if (started) void flushQueue()
81
+ return row
82
+ }
83
+
84
+ async function update(id, data) {
85
+ assertId(id)
86
+ assertData(data)
87
+ if (primaryKey in data || 'version' in data || 'deleted' in data) throw new TypeError('sync-managed columns cannot be updated')
88
+ let row
89
+ await db.transaction(async tx => {
90
+ const current = await tx.query('SELECT data FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
91
+ if (!current.rows[0]) throw new Error('row not found')
92
+ 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)])
94
+ 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
+ if (queued.rows[0]?.action === 'delete') throw new Error('row has a pending delete')
96
+ const action = queued.rows[0]?.action === 'create' && queued.rows[0].acknowledged_version == null ? 'create' : 'update'
97
+ await queueMutation(tx, id, action, row)
98
+ })
99
+ notify()
100
+ if (started) void flushQueue()
101
+ return row
102
+ }
103
+
104
+ async function remove(id) {
105
+ assertId(id)
106
+ await db.transaction(async tx => {
107
+ await tx.query('DELETE FROM electric_local_rows WHERE model_name = $1 AND row_id = $2', [modelName, String(id)])
108
+ await queueMutation(tx, id, 'delete', null)
109
+ })
110
+ notify()
111
+ if (started) void flushQueue()
112
+ }
113
+
114
+ async function queueMutation(tx, id, action, data) {
115
+ await tx.query(`INSERT INTO electric_mutation_queue (model_name, row_id, action, data)
116
+ VALUES ($1, $2, $3, $4::jsonb)
117
+ ON CONFLICT (model_name, row_id) DO UPDATE SET
118
+ action = EXCLUDED.action, data = EXCLUDED.data,
119
+ revision = nextval('electric_mutation_revision_seq'),
120
+ acknowledged_version = NULL, status = 'pending', failure_reason = NULL`,
121
+ [modelName, String(id), action, data == null ? null : JSON.stringify(data)])
122
+ }
123
+
124
+ function ordered(task) {
125
+ const current = operation.then(task)
126
+ operation = current.catch(() => {})
127
+ return current
128
+ }
129
+
130
+ function applySnapshot(rows) {
131
+ return ordered(async () => {
132
+ snapshot = rows
133
+ await reconcileSnapshot(rows)
134
+ })
135
+ }
136
+
137
+ function reconcile() {
138
+ return ordered(async () => {
139
+ if (snapshot !== null) await reconcileSnapshot(snapshot)
140
+ })
141
+ }
142
+
143
+ function resetSnapshot() {
144
+ return ordered(() => { snapshot = null })
145
+ }
146
+
147
+ async function reconcileSnapshot(rows) {
148
+ await db.transaction(async tx => {
149
+ for (const row of rows) {
150
+ const id = String(row[primaryKey])
151
+ if (row.version != null) {
152
+ await tx.query(`DELETE FROM electric_mutation_queue
153
+ WHERE model_name = $1 AND row_id = $2 AND acknowledged_version <= $3::numeric`,
154
+ [modelName, id, String(row.version)])
155
+ }
156
+ if (row.deleted) continue
157
+ await tx.query(`INSERT INTO electric_local_rows (model_name, row_id, data)
158
+ SELECT $1, $2, $3::jsonb WHERE NOT EXISTS
159
+ (SELECT 1 FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2)
160
+ ON CONFLICT (model_name, row_id) DO UPDATE SET data = EXCLUDED.data`,
161
+ [modelName, id, JSON.stringify(row)])
162
+ }
163
+ const activeIds = rows.filter(row => !row.deleted).map(row => String(row[primaryKey]))
164
+ await tx.query(`DELETE FROM electric_local_rows AS local
165
+ WHERE local.model_name = $1 AND NOT (local.row_id = ANY($2::text[]))
166
+ AND NOT EXISTS (SELECT 1 FROM electric_mutation_queue AS pending
167
+ WHERE pending.model_name = local.model_name AND pending.row_id = local.row_id)`,
168
+ [modelName, activeIds])
169
+ })
170
+ notify()
171
+ }
172
+
173
+ async function flushQueue() {
174
+ if (!ownsSync || flushing || network?.onLine === false) return
175
+ flushing = true
176
+ try {
177
+ while (true) {
178
+ const { rows } = await db.query(`SELECT * FROM electric_mutation_queue
179
+ WHERE model_name = $1 AND status = 'pending' AND acknowledged_version IS NULL
180
+ ORDER BY revision LIMIT 1`, [modelName])
181
+ const mutation = rows[0]
182
+ if (!mutation) break
183
+ try {
184
+ await sendMutation(mutation)
185
+ } catch (error) {
186
+ if (!isPermanent(error)) break
187
+ await db.query(`UPDATE electric_mutation_queue SET status = 'failed', failure_reason = $3
188
+ WHERE model_name = $1 AND row_id = $2 AND revision = $4`,
189
+ [modelName, mutation.row_id, error.message, mutation.revision])
190
+ notify()
191
+ }
192
+ }
193
+ } finally {
194
+ flushing = false
195
+ }
196
+ }
197
+
198
+ async function sendMutation(mutation) {
199
+ const client = await db.query('SELECT id FROM electric_sync_client WHERE singleton = true')
200
+ const metadata = { clientId: client.rows[0].id, revision: String(mutation.revision) }
201
+ const { row_id: id, data, action } = mutation
202
+ const result = action === 'create'
203
+ ? await service.create(id, withoutKey(data), metadata)
204
+ : action === 'update'
205
+ ? await service.update(id, withoutKey(data), metadata)
206
+ : await service.delete(id, metadata)
207
+ if (result?.version == null) throw new Error('sync mutation did not return a version')
208
+ await db.transaction(async tx => {
209
+ const current = await tx.query('SELECT action, revision, data FROM electric_mutation_queue WHERE model_name = $1 AND row_id = $2', [modelName, id])
210
+ const queued = current.rows[0]
211
+ if (!queued) return
212
+ if (String(queued.revision) === String(mutation.revision) && queued.action === action) {
213
+ await tx.query(`UPDATE electric_mutation_queue SET acknowledged_version = $3::numeric
214
+ WHERE model_name = $1 AND row_id = $2`, [modelName, id, String(result.version)])
215
+ } else if (action === 'create' && queued.action === 'create') {
216
+ // The create was sent, then edited locally. Its next request is an update.
217
+ await tx.query(`UPDATE electric_mutation_queue SET action = 'update',
218
+ revision = nextval('electric_mutation_revision_seq') WHERE model_name = $1 AND row_id = $2`, [modelName, id])
219
+ }
220
+ })
221
+ await reconcile()
222
+ notify()
223
+ }
224
+
225
+ function withoutKey(data) {
226
+ const copy = { ...data }
227
+ delete copy[primaryKey]
228
+ delete copy.version
229
+ delete copy.deleted
230
+ return copy
231
+ }
232
+
233
+ function start() {
234
+ if (started) return
235
+ started = true
236
+ channel?.addEventListener('message', sharedChange)
237
+ globalThis.addEventListener?.('online', online)
238
+ if (!ownsSync) return
239
+ stream = new ShapeStreamClass({ url })
240
+ stream.subscribe(messages => {
241
+ if (messages.some(message => message.headers?.control === 'must-refetch')) void resetSnapshot()
242
+ connected = stream.isConnected?.() ?? true
243
+ notify()
244
+ }, () => { connected = false; notify() })
245
+ const shape = new ShapeClass(stream)
246
+ shape.subscribe(({ rows }) => { connected = true; void applySnapshot([...rows]) })
247
+ timer = setInterval(() => { void flushQueue() }, retryMs)
248
+ void flushQueue()
249
+ }
250
+
251
+ function stop() {
252
+ if (!started) return
253
+ started = false
254
+ clearInterval(timer)
255
+ stream?.unsubscribeAll()
256
+ channel?.removeEventListener('message', sharedChange)
257
+ globalThis.removeEventListener?.('online', online)
258
+ connected = false
259
+ }
260
+
261
+ function online() { void flushQueue() }
262
+ function sharedChange(event) {
263
+ if (event.data?.modelName !== modelName) return
264
+ for (const listener of listeners) queueMicrotask(() => { try { listener() } catch (error) { console.error(error) } })
265
+ if (ownsSync) void flushQueue()
266
+ }
267
+
268
+ async function getStatus() {
269
+ const { rows } = await db.query(`SELECT
270
+ count(*) FILTER (WHERE status = 'pending')::int AS pending,
271
+ 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 }
274
+ }
275
+
276
+ return { prepare, start, stop, subscribe, findMany, findUnique, getObservable,
277
+ create, update, remove, flushQueue, getStatus }
278
+ }
279
+
280
+ function assertData(data) {
281
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new TypeError('mutation data must be a plain object')
282
+ }
283
+
284
+ function assertId(id) {
285
+ if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
286
+ throw new TypeError('sync id must be a UUID')
287
+ }
288
+ }
289
+
290
+ function matchesWhere(row, where) {
291
+ return Object.entries(where).every(([key, constraint]) => {
292
+ if (constraint === undefined) return true
293
+ const value = row[key]
294
+ if (constraint && typeof constraint === 'object' && !(constraint instanceof Date)) {
295
+ return Object.entries(constraint).every(([operator, limit]) => {
296
+ if (operator === 'gt') return value > limit
297
+ if (operator === 'gte') return value >= limit
298
+ if (operator === 'lt') return value < limit
299
+ if (operator === 'lte') return value <= limit
300
+ throw new TypeError(`unsupported where constraint for '${key}'`)
301
+ })
302
+ }
303
+ return value === constraint
304
+ })
305
+ }
306
+
307
+ function isPermanent(error) {
308
+ const status = error?.status ?? error?.response?.status
309
+ return error?.code === 'forbidden' || error instanceof TypeError ||
310
+ status >= 400 && status < 500 && ![408, 425, 429].includes(status)
311
+ }
@@ -12,6 +12,8 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
12
12
  // throw new TypeError('electricOfflinePlugin requires an authorize(context, operation) policy')
13
13
  // }
14
14
  const configuredModels = normalizeModels(models)
15
+ const sync = options.sync === true
16
+ if (sync && typeof db.connect !== 'function') throw new TypeError('sync requires a pg Pool with connect()')
15
17
  const electricUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
16
18
  const shapePath = options.shapePath ?? '/electric/v1/shape/:model'
17
19
  const fetchImpl = options.fetch ?? globalThis.fetch
@@ -55,15 +57,21 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
55
57
  },
56
58
 
57
59
  // create(data): the primary key is server-generated
58
- // create(uid, data): uid (the primary key) is provided by the client
59
- create: async function(uidOrData, data) {
60
+ // create(id, data): id (the primary key) is provided by the client
61
+ create: async function(idOrData, data, mutation) {
60
62
  const hasClientId = data !== undefined
61
- const mutationData = hasClientId ? data : uidOrData
62
- await authorize(this, model.name, 'create', hasClientId ? [uidOrData, mutationData] : [mutationData])
63
+ const mutationData = hasClientId ? data : idOrData
64
+ await authorize(this, model.name, 'create', hasClientId
65
+ ? (sync ? [idOrData, mutationData, mutation] : [idOrData, mutationData])
66
+ : [mutationData])
63
67
  assertPlainObject(mutationData, 'mutation data')
64
68
  const safeData = hasClientId
65
- ? { ...mutationData, [model.primaryKey]: uidOrData }
69
+ ? { ...mutationData, [model.primaryKey]: idOrData }
66
70
  : { ...mutationData }
71
+ if (sync) {
72
+ if (!hasClientId) throw new TypeError('sync mutations require a client-generated id')
73
+ return syncMutation(db, model, 'create', idOrData, safeData, mutation)
74
+ }
67
75
  const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
68
76
  const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
69
77
  const values = entries.map(([, value]) => value)
@@ -91,8 +99,9 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
91
99
  })
92
100
  },
93
101
 
94
- update: async function(id, data) {
95
- await authorize(this, model.name, 'update', [id, data])
102
+ update: async function(id, data, mutation) {
103
+ await authorize(this, model.name, 'update', sync ? [id, data, mutation] : [id, data])
104
+ if (sync) return syncMutation(db, model, 'update', id, data, mutation)
96
105
  const set = buildSet(data, model.primaryKey)
97
106
  return withTransaction(db, async client => {
98
107
  const result = await client.query(
@@ -103,8 +112,9 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
103
112
  })
104
113
  },
105
114
 
106
- delete: async function(id) {
107
- await authorize(this, model.name, 'delete', [id])
115
+ delete: async function(id, mutation) {
116
+ await authorize(this, model.name, 'delete', sync ? [id, mutation] : [id])
117
+ if (sync) return syncMutation(db, model, 'delete', id, null, mutation)
108
118
  return withTransaction(db, async client => {
109
119
  const result = await client.query(
110
120
  `DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
@@ -139,11 +149,81 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
139
149
  }
140
150
  })
141
151
 
142
- return { shapePath, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
152
+ return { shapePath, sync, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
153
+ }
154
+
155
+ /** Run once before accepting sync mutations. Existing rows receive a version. */
156
+ export async function prepareElectricSyncSchema(db, models) {
157
+ const configuredModels = normalizeModels(models)
158
+ await db.query('CREATE SEQUENCE IF NOT EXISTS electric_sync_version_seq')
159
+ await db.query(`CREATE TABLE IF NOT EXISTS electric_mutation_cursor (
160
+ table_name TEXT NOT NULL, client_id UUID NOT NULL, row_id TEXT NOT NULL,
161
+ revision BIGINT NOT NULL DEFAULT 0, result JSONB,
162
+ PRIMARY KEY (table_name, client_id, row_id)
163
+ )`)
164
+ for (const model of configuredModels) {
165
+ await db.query(`ALTER TABLE ${model.quotedTable} ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT nextval('electric_sync_version_seq')`)
166
+ await db.query(`ALTER TABLE ${model.quotedTable} ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT false`)
167
+ }
168
+ }
169
+
170
+ async function syncMutation(db, model, action, id, data, mutation) {
171
+ if (!mutation || typeof mutation !== 'object') throw new TypeError('sync mutation metadata is required')
172
+ const { clientId, revision } = mutation
173
+ if (typeof clientId !== 'string' || !UUID.test(clientId)) throw new TypeError('clientId must be a UUID')
174
+ if (!/^[1-9][0-9]*$/.test(String(revision)) || BigInt(revision) > 9223372036854775807n) {
175
+ throw new TypeError('revision must be a positive bigint')
176
+ }
177
+ if (typeof id !== 'string' || !UUID.test(id)) throw new TypeError('sync id must be a UUID')
178
+ if (action !== 'delete') assertPlainObject(data, 'mutation data')
179
+ if (data && ('version' in data || 'deleted' in data)) throw new TypeError('version and deleted are managed by sync')
180
+ return withTransaction(db, async tx => {
181
+ const key = [model.table, clientId, String(id)]
182
+ await tx.query(`INSERT INTO electric_mutation_cursor (table_name, client_id, row_id)
183
+ VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, key)
184
+ const { rows } = await tx.query(`SELECT revision, result FROM electric_mutation_cursor
185
+ WHERE table_name = $1 AND client_id = $2 AND row_id = $3 FOR UPDATE`, key)
186
+ const cursor = rows[0]
187
+ if (BigInt(revision) <= BigInt(cursor.revision)) return cursor.result
188
+ let result
189
+ if (action === 'create') {
190
+ const entries = Object.entries({ ...data, [model.primaryKey]: id }).filter(([, value]) => value !== undefined)
191
+ const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
192
+ const values = entries.map(([, value]) => value)
193
+ const parameters = values.map((_, index) => `$${index + 1}`)
194
+ const inserted = await tx.query(`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})
195
+ ON CONFLICT (${model.quotedPrimaryKey}) DO UPDATE SET ${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}
196
+ RETURNING *`, values)
197
+ result = inserted.rows[0]
198
+ } else if (action === 'update') {
199
+ const set = buildSet(data, model.primaryKey)
200
+ const updated = await tx.query(`UPDATE ${model.quotedTable} SET ${set.sql}, version = nextval('electric_sync_version_seq')
201
+ WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} AND NOT deleted RETURNING *`, [...set.values, id])
202
+ result = updated.rows[0] ?? await writeTombstone(tx, model, id)
203
+ } else {
204
+ result = await writeTombstone(tx, model, id)
205
+ }
206
+ await tx.query(`UPDATE electric_mutation_cursor SET revision = $4, result = $5::jsonb
207
+ WHERE table_name = $1 AND client_id = $2 AND row_id = $3`, [...key, String(revision), JSON.stringify(result ?? null)])
208
+ return result
209
+ })
210
+ }
211
+
212
+ async function writeTombstone(tx, model, id) {
213
+ const tombstone = Object.entries(model.tombstoneData)
214
+ const columns = [model.quotedPrimaryKey, ...tombstone.map(([column]) => quoteIdentifier(column, 'tombstone column')), 'deleted']
215
+ const values = [id, ...tombstone.map(([, value]) => value)]
216
+ const parameters = values.map((_, index) => `$${index + 1}`)
217
+ const clearFields = tombstone.map(([column]) => `${quoteIdentifier(column, 'tombstone column')} = EXCLUDED.${quoteIdentifier(column, 'tombstone column')}`)
218
+ const { rows } = await tx.query(`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')}, true)
219
+ ON CONFLICT (${model.quotedPrimaryKey}) DO UPDATE SET ${clearFields.length ? clearFields.join(', ') + ', ' : ''}deleted = true, version = nextval('electric_sync_version_seq')
220
+ RETURNING *`, values)
221
+ return rows[0]
143
222
  }
144
223
 
145
224
 
146
225
  const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
226
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
147
227
  const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
148
228
 
149
229
  function quoteIdentifier(value, label) {
@@ -164,12 +244,19 @@ function normalizeModels(models) {
164
244
  }
165
245
  const name = config.name
166
246
  const table = config.table ?? name
167
- const primaryKey = config.primaryKey ?? 'uid'
247
+ const primaryKey = config.primaryKey ?? 'id'
168
248
  quoteIdentifier(name, 'model name')
249
+ 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
+ }
169
255
  return {
170
256
  name,
171
257
  table,
172
258
  primaryKey,
259
+ tombstoneData,
173
260
  quotedTable: quoteIdentifier(table, `table for '${name}'`),
174
261
  quotedPrimaryKey: quoteIdentifier(primaryKey, `primary key for '${name}'`),
175
262
  }
@@ -0,0 +1,68 @@
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 Stream {
7
+ subscribe() { return () => {} }
8
+ isConnected() { return true }
9
+ unsubscribeAll() {}
10
+ }
11
+
12
+ class Shape {
13
+ static current
14
+ constructor() { Shape.current = this }
15
+ subscribe(callback) { this.emit = callback; return () => {} }
16
+ }
17
+
18
+ test('offline writes stay local until the acknowledged Electric version arrives', async () => {
19
+ const db = new PGlite()
20
+ const calls = []
21
+ const service = {
22
+ async create(id, data, metadata) {
23
+ calls.push({ id, data, metadata })
24
+ return { id, ...data, version: '42' }
25
+ },
26
+ async delete(id, metadata) {
27
+ calls.push({ id, metadata, action: 'delete' })
28
+ return { id, deleted: true, version: '43' }
29
+ },
30
+ }
31
+ const app = { service: () => service }
32
+ electricClientPlugin(app, { ShapeStream: Stream, Shape })
33
+ const model = app.createElectricModel('todos', { localDb: db, network: { onLine: false }, retryMs: 60_000 })
34
+ try {
35
+ await model.prepare()
36
+ const local = await model.create({ title: 'offline' })
37
+ assert.match(local.id, /^[0-9a-f-]{36}$/)
38
+ assert.equal((await model.findMany())[0].title, 'offline')
39
+ assert.equal((await model.getStatus()).pending, 1)
40
+ assert.equal(calls.length, 0)
41
+
42
+ // Simulate reconnect, then an old Shape snapshot followed by confirmation.
43
+ const onlineModel = app.createElectricModel('todos', { localDb: db, network: { onLine: true }, retryMs: 60_000 })
44
+ await onlineModel.flushQueue()
45
+ assert.equal(calls.length, 1)
46
+ assert.equal(calls[0].metadata.revision, '1')
47
+ assert.equal((await onlineModel.getStatus()).pending, 1)
48
+ onlineModel.start()
49
+ Shape.current.emit({ rows: [{ id: local.id, title: 'older', version: '41', deleted: false }] })
50
+ await new Promise(resolve => setTimeout(resolve, 10))
51
+ assert.equal((await onlineModel.findMany())[0].title, 'offline')
52
+ Shape.current.emit({ rows: [{ id: local.id, title: 'offline', version: '42', deleted: false }] })
53
+ await new Promise(resolve => setTimeout(resolve, 10))
54
+ assert.equal((await onlineModel.getStatus()).pending, 0)
55
+ await onlineModel.remove(local.id)
56
+ await onlineModel.flushQueue()
57
+ assert.equal((await onlineModel.findMany()).length, 0)
58
+ Shape.current.emit({ rows: [{ id: local.id, title: 'offline', version: '42', deleted: false }] })
59
+ await new Promise(resolve => setTimeout(resolve, 10))
60
+ assert.equal((await onlineModel.findMany()).length, 0)
61
+ Shape.current.emit({ rows: [{ id: local.id, title: '', version: '43', deleted: true }] })
62
+ await new Promise(resolve => setTimeout(resolve, 10))
63
+ assert.equal((await onlineModel.getStatus()).pending, 0)
64
+ onlineModel.stop()
65
+ } finally {
66
+ await db.close()
67
+ }
68
+ })
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
 
4
- import { electricOfflinePlugin } from '../src/electric-server-plugin.mjs'
4
+ import { electricOfflinePlugin, prepareElectricSyncSchema } from '../src/electric-server-plugin.mjs'
5
5
 
6
6
  function fixture({ authorize = async () => true, fetch } = {}) {
7
7
  const services = new Map()
@@ -10,7 +10,7 @@ function fixture({ authorize = async () => true, fetch } = {}) {
10
10
  const db = {
11
11
  async query(sql, values = []) {
12
12
  queries.push({ sql, values })
13
- return { rows: [{ uid: values.at(-1) ?? 'one', label: 'row' }] }
13
+ return { rows: [{ id: values.at(-1) ?? 'one', label: 'row' }] }
14
14
  },
15
15
  }
16
16
  const app = {
@@ -25,14 +25,18 @@ test('registers the mutation API', async () => {
25
25
  const { services, queries } = fixture()
26
26
  const service = services.get('todos')
27
27
  assert.deepEqual(Object.keys(service), [
28
- 'create', 'update', 'delete',
28
+ 'findUnique', 'findMany', 'create', 'update', 'delete',
29
29
  ])
30
30
 
31
31
  const value = await service.create.call({}, 'one', { label: 'new' })
32
- assert.equal(value.uid, 'one')
32
+ assert.equal(value.id, 'one')
33
33
 
34
34
  await service.create.call({}, 'only-id', {})
35
- assert.match(queries[1].sql, /DO UPDATE SET "uid" = EXCLUDED\."uid"/)
35
+ assert.match(queries[1].sql, /DO UPDATE SET "id" = EXCLUDED\."id"/)
36
+ assert.equal(queries[1].values[0], 'only-id')
37
+ assert.deepEqual(fixture().registration.models, [
38
+ { name: 'todos', table: 'todos', primaryKey: 'id' },
39
+ ])
36
40
  await assert.rejects(service.create.call({}, 'bad', []), /plain object/)
37
41
  })
38
42
 
@@ -150,3 +154,72 @@ test('rejects unsafe SQL identifiers', () => {
150
154
  /simple SQL identifier/,
151
155
  )
152
156
  })
157
+
158
+ test('sync mode commits a versioned write and rejects replayed or stale revisions', async () => {
159
+ const queries = []
160
+ let cursor = { revision: '0', result: null }
161
+ let inserts = 0
162
+ const client = {
163
+ async query(sql, values = []) {
164
+ queries.push({ sql, values })
165
+ if (sql.includes('SELECT revision, result FROM electric_mutation_cursor')) return { rows: [cursor] }
166
+ if (sql.includes('INSERT INTO "todos"')) {
167
+ inserts++
168
+ return { rows: [{ id: values.at(-1), version: '42', deleted: false }] }
169
+ }
170
+ if (sql.includes('UPDATE electric_mutation_cursor')) cursor = { revision: values[3], result: JSON.parse(values[4]) }
171
+ return { rows: [] }
172
+ },
173
+ release() {},
174
+ }
175
+ const db = { query: client.query.bind(client), async connect() { return client } }
176
+ const services = new Map()
177
+ electricOfflinePlugin({ createService(name, methods) { services.set(name, methods) }, get() {} }, db,
178
+ [{ name: 'todos', tombstoneData: { title: '' } }], { sync: true })
179
+ const service = services.get('todos')
180
+ const id = '64d76168-775f-481e-8974-18d31d835d9e'
181
+ const clientId = '5e199e1c-23a4-473e-9989-14d4a1fb857e'
182
+ const metadata = { clientId, revision: '2' }
183
+
184
+ const result = await service.create.call({}, id, { title: 'first' }, metadata)
185
+ assert.equal(result.version, '42')
186
+ assert.equal((await service.create.call({}, id, { title: 'first' }, metadata)).version, '42')
187
+ assert.equal((await service.create.call({}, id, { title: 'stale' }, { clientId, revision: '1' })).version, '42')
188
+ assert.equal(inserts, 1)
189
+ assert.equal(queries.filter(({ sql }) => sql === 'COMMIT').length, 3)
190
+ await assert.rejects(service.create.call({}, id, { title: 'bad' }), /metadata is required/)
191
+ })
192
+
193
+ test('sync schema prepares version and tombstone columns', async () => {
194
+ const queries = []
195
+ await prepareElectricSyncSchema({ query: async sql => { queries.push(sql) } }, ['todos'])
196
+ assert.equal(queries.length, 4)
197
+ assert.match(queries[2], /ADD COLUMN IF NOT EXISTS version BIGINT/)
198
+ assert.match(queries[3], /ADD COLUMN IF NOT EXISTS deleted BOOLEAN/)
199
+ })
200
+
201
+ test('sync delete writes a versioned tombstone and clears configured fields', async () => {
202
+ const queries = []
203
+ const id = '64d76168-775f-481e-8974-18d31d835d9e'
204
+ const client = {
205
+ async query(sql, values = []) {
206
+ queries.push({ sql, values })
207
+ if (sql.includes('SELECT revision, result FROM electric_mutation_cursor')) return { rows: [{ revision: '0', result: null }] }
208
+ if (sql.includes('INSERT INTO "todos"')) return { rows: [{ id, title: '', deleted: true, version: '7' }] }
209
+ return { rows: [] }
210
+ },
211
+ release() {},
212
+ }
213
+ const db = { query: client.query.bind(client), async connect() { return client } }
214
+ const services = new Map()
215
+ electricOfflinePlugin({ createService(name, methods) { services.set(name, methods) }, get() {} }, db,
216
+ [{ name: 'todos', tombstoneData: { title: '' } }], { sync: true })
217
+ const result = await services.get('todos').delete.call({}, id, {
218
+ clientId: '5e199e1c-23a4-473e-9989-14d4a1fb857e', revision: '1',
219
+ })
220
+ assert.equal(result.deleted, true)
221
+ const tombstone = queries.find(({ sql }) => sql.includes('INSERT INTO "todos"'))
222
+ assert.match(tombstone.sql, /"title" = EXCLUDED\."title"/)
223
+ assert.match(tombstone.sql, /version = nextval/)
224
+ assert.deepEqual(tombstone.values, [id, ''])
225
+ })