@jcbuisson/express-x-plugins 3.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 ADDED
@@ -0,0 +1,105 @@
1
+ # express-x-electric
2
+
3
+ The smallest useful ElectricSQL integration for Express-X. Express-X handles
4
+ authorized PostgreSQL mutations; Electric's Shape API streams those changes to
5
+ clients. It is analogous to `express-x-drizzle`, but deliberately has no
6
+ metadata table and no custom `sync.go`: Electric is the sync engine.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install @jcbuisson/express-x-electric pg
12
+ ```
13
+
14
+ This server-only installation does not install the browser Electric client or
15
+ RxJS.
16
+
17
+ ## Server
18
+
19
+ ```js
20
+ import { Pool } from 'pg'
21
+ import { expressX } from '@jcbuisson/express-x'
22
+ import { electricOfflinePlugin } from '@jcbuisson/express-x-electric'
23
+
24
+ const app = expressX()
25
+ const db = new Pool({ connectionString: process.env.DATABASE_URL })
26
+
27
+ app.configure(electricOfflinePlugin, db, [
28
+ 'todos',
29
+ { name: 'projects', table: 'projects', primaryKey: 'uid' },
30
+ ], {
31
+ electricUrl: process.env.ELECTRIC_URL, // ElectricSQL sync service, e.g. http://localhost:3000/v1/shape
32
+ sourceId: process.env.ELECTRIC_SOURCE_ID,
33
+ sourceSecret: process.env.ELECTRIC_SOURCE_SECRET,
34
+ authorize: async (context, { modelName, action }) => {
35
+ // `context.transport` is "http" for Shapes and "ws" for Express-X calls.
36
+ return Boolean(context.request?.user || context.socket?.data?.user)
37
+ },
38
+ })
39
+ ```
40
+
41
+ This registers one Express-X service per model with the familiar API:
42
+
43
+ - `createWithMeta(uid, data, createdAt)`
44
+ - `updateWithMeta(uid, data, updatedAt)`
45
+ - `deleteWithMeta(uid, deletedAt)`
46
+
47
+ Synchronized reads are provided client-side by `getObservable(where)` below;
48
+ one-shot server reads would bypass Electric and are intentionally omitted.
49
+
50
+ Mutation results remain `[value, meta]` tuples for compatibility. `meta.txid`
51
+ contains `pg_current_xact_id()` and can be passed to an Electric-aware client to
52
+ wait for the matching transaction in its Shape stream.
53
+
54
+ ## Client Shape
55
+
56
+ Install the optional client dependencies in the browser application:
57
+
58
+ ```sh
59
+ npm install @jcbuisson/express-x-electric @electric-sql/client rxjs
60
+ ```
61
+
62
+ Configure the client plugin and use the same `getObservable(where)` style as
63
+ `express-x-client`'s offline model:
64
+
65
+ ```js
66
+ import { electricClientPlugin } from '@jcbuisson/express-x-electric/client'
67
+
68
+ app.configure(electricClientPlugin, {
69
+ shapePath: '/electric/v1/shape',
70
+ })
71
+
72
+ const todo = app.createElectricModel('todos')
73
+ const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
74
+ console.log(rows)
75
+ })
76
+
77
+ // Mutations use the matching Express-X service and are reflected by Electric.
78
+ await todo.create({ title: 'Learn Shapes', completed: false })
79
+ await todo.update(uid, { completed: true })
80
+ await todo.remove(uid)
81
+
82
+ subscription.unsubscribe()
83
+ ```
84
+
85
+ Object filters use parameterized Electric Shape predicates. Exact values,
86
+ `null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
87
+
88
+ All Electric cursor parameters are forwarded. The client cannot override the
89
+ configured table, and Electric source credentials stay server-side.
90
+
91
+ ## Model configuration
92
+
93
+ Models may be strings (table, service name, and default `uid` key) or objects:
94
+
95
+ ```js
96
+ { name: 'todo', table: 'todos', primaryKey: 'id' }
97
+ ```
98
+
99
+ Names are restricted to simple PostgreSQL identifiers. Values are always sent
100
+ as query parameters; range filters support `gt`, `gte`, `lt`, and `lte`.
101
+
102
+ Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
103
+ `query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
104
+ transaction ID are captured in the same transaction.
105
+ # express-x-electric
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@jcbuisson/express-x-plugins",
3
+ "version": "3.0.0",
4
+ "description": "Plugins for express-x",
5
+ "type": "module",
6
+ "main": "./src/electric-server-plugin.mjs",
7
+ "exports": {
8
+ "./server/electric": "./src/electric-server-plugin.mjs",
9
+ "./client/electric": "./src/electric-client-plugin.mjs",
10
+ "./server/reload": "./src/reload-server-plugin.mjs",
11
+ "./client/reload": "./src/reload-client-plugin.mjs"
12
+ },
13
+ "private": false,
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": {
18
+ "node": ">=18.0.0",
19
+ "npm": ">=8.0.0"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+ssh://git@github.com/jcbuisson/express-x-plugins.git"
24
+ },
25
+ "author": "Jean-Christophe Buisson <buisson@enseeiht.fr> (jcbuisson.dev)",
26
+ "license": "MIT",
27
+ "keywords": [
28
+ "express-x",
29
+ "electric-sql",
30
+ "sync",
31
+ "local-first"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test"
35
+ },
36
+ "peerDependencies": {
37
+ "@electric-sql/client": "^1.5.24",
38
+ "rxjs": "^7.8.2"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@electric-sql/client": {
42
+ "optional": true
43
+ },
44
+ "rxjs": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@electric-sql/client": "^1.5.24",
50
+ "rxjs": "^7.8.2"
51
+ }
52
+ }
@@ -0,0 +1,141 @@
1
+ import { Shape, ShapeStream } from '@electric-sql/client'
2
+ import { Observable } from 'rxjs'
3
+
4
+
5
+ /**
6
+ * Add Electric-backed reactive models to an Express-X client.
7
+ *
8
+ * Usage:
9
+ * electricClientPlugin(app)
10
+ * const todo = app.createElectricModel('todos')
11
+ * todo.getObservable({ completed: false }).subscribe(...)
12
+ */
13
+ export function electricClientPlugin(app, options = {}) {
14
+ const shapePath = options.shapePath ?? '/electric/v1/shape'
15
+ const ShapeStreamClass = options.ShapeStream ?? ShapeStream
16
+ const ShapeClass = options.Shape ?? Shape
17
+ const ObservableClass = options.Observable ?? Observable
18
+
19
+ function createElectricModel(modelName, modelOptions = {}) {
20
+ quoteIdentifier(modelName)
21
+ const service = app.service(modelName)
22
+ const url = modelOptions.url ?? modelPath(shapePath, modelName)
23
+ const streamOptions = modelOptions.streamOptions ?? {}
24
+
25
+ function getObservable(where = {}) {
26
+ // Validate eagerly, as getObservable in express-x-client does.
27
+ const filterParams = whereToElectricParams(where)
28
+ return new ObservableClass(subscriber => {
29
+ const stream = new ShapeStreamClass({
30
+ ...streamOptions,
31
+ url,
32
+ params: { ...streamOptions.params, ...filterParams },
33
+ })
34
+ const shape = new ShapeClass(stream)
35
+ let previous
36
+ const unsubscribe = shape.subscribe(({ rows }) => {
37
+ const current = [...rows]
38
+ const serialized = JSON.stringify(current)
39
+ if (serialized === previous) return
40
+ previous = serialized
41
+ subscriber.next(current)
42
+ })
43
+ // Shape exposes errors as state. ShapeStream retries transient failures;
44
+ // callers keep one observable subscription across reconnects.
45
+ return () => unsubscribe()
46
+ })
47
+ }
48
+
49
+ async function create(data) {
50
+ assertPlainObject(data, 'mutation data')
51
+ const uid = globalThis.crypto?.randomUUID?.()
52
+ if (!uid) throw new Error('crypto.randomUUID() is required')
53
+ const now = new Date().toISOString()
54
+ const [value] = await service.createWithMeta(uid, data, now)
55
+ return value
56
+ }
57
+
58
+ async function update(uid, data) {
59
+ const [value] = await service.updateWithMeta(uid, data, new Date().toISOString())
60
+ return value
61
+ }
62
+
63
+ async function remove(uid) {
64
+ const [value] = await service.deleteWithMeta(uid, new Date().toISOString())
65
+ return value
66
+ }
67
+
68
+ return { getObservable, create, update, remove }
69
+ }
70
+
71
+ return Object.assign(app, { createElectricModel })
72
+ }
73
+
74
+
75
+ ////////////////////// UTILITIES //////////////////////
76
+
77
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
78
+ const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
79
+
80
+ function quoteIdentifier(value) {
81
+ if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
82
+ throw new TypeError(`'${value}' must be a simple SQL identifier`)
83
+ }
84
+ return `"${value}"`
85
+ }
86
+
87
+ function assertPlainObject(value, label) {
88
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
89
+ throw new TypeError(`${label} must be a plain object`)
90
+ }
91
+ }
92
+
93
+ function serializeValue(value, path) {
94
+ if (value instanceof Date) {
95
+ if (Number.isNaN(value.getTime())) throw new TypeError(`${path} contains an invalid Date`)
96
+ return value.toISOString()
97
+ }
98
+ if (['string', 'number', 'boolean'].includes(typeof value) && value !== undefined) {
99
+ if (typeof value === 'number' && !Number.isFinite(value)) {
100
+ throw new TypeError(`${path} contains a non-finite number`)
101
+ }
102
+ return String(value)
103
+ }
104
+ throw new TypeError(`${path} contains an unsupported value`)
105
+ }
106
+
107
+ /** Convert the Express-X object filter into Electric's parameterized SQL filter. */
108
+ function whereToElectricParams(where = {}) {
109
+ assertPlainObject(where, 'where')
110
+ const clauses = []
111
+ const params = []
112
+
113
+ for (const [column, constraint] of Object.entries(where)) {
114
+ const quotedColumn = quoteIdentifier(column)
115
+ if (constraint === undefined) continue
116
+ if (constraint === null) {
117
+ clauses.push(`${quotedColumn} IS NULL`)
118
+ continue
119
+ }
120
+ if (constraint && typeof constraint === 'object' && !Array.isArray(constraint) && !(constraint instanceof Date)) {
121
+ const entries = Object.entries(constraint)
122
+ if (entries.length === 0 || entries.some(([operator]) => !RANGE_OPERATORS[operator])) {
123
+ throw new TypeError(`unsupported where constraint for '${column}'`)
124
+ }
125
+ for (const [operator, value] of entries) {
126
+ params.push(serializeValue(value, `where.${column}.${operator}`))
127
+ clauses.push(`${quotedColumn} ${RANGE_OPERATORS[operator]} $${params.length}`)
128
+ }
129
+ continue
130
+ }
131
+ params.push(serializeValue(constraint, `where.${column}`))
132
+ clauses.push(`${quotedColumn} = $${params.length}`)
133
+ }
134
+
135
+ return clauses.length ? { where: clauses.join(' AND '), params } : {}
136
+ }
137
+
138
+ function modelPath(shapePath, modelName) {
139
+ const base = shapePath.replace(/\/$/, '')
140
+ return `${base}/${encodeURIComponent(modelName)}`
141
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Register Express-X mutation services and an Electric Shape proxy.
3
+ *
4
+ * @param {object} app Express-X/Express application
5
+ * @param {object} db pg-compatible Pool or Client exposing query()
6
+ * @param {(string|{name:string,table?:string,primaryKey?:string})[]} models
7
+ * @param {object} options
8
+ */
9
+ export function electricOfflinePlugin(app, db, models, options = {}) {
10
+ if (!db || typeof db.query !== 'function') throw new TypeError('db must expose query(sql, values)')
11
+ if (typeof options.authorize !== 'function') {
12
+ throw new TypeError('electricOfflinePlugin requires an authorize(context, operation) policy')
13
+ }
14
+ const configuredModels = normalizeModels(models)
15
+ const electricUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
16
+ const shapePath = options.shapePath ?? '/electric/v1/shape/:model'
17
+ const fetchImpl = options.fetch ?? globalThis.fetch
18
+ if (typeof fetchImpl !== 'function') throw new TypeError('a fetch implementation is required')
19
+
20
+ async function authorize(context, modelName, action, args) {
21
+ const allowed = await options.authorize(context, { modelName, action, args })
22
+ if (!allowed) {
23
+ const error = new Error(`not authorized to ${action} '${modelName}'`)
24
+ error.code = 'forbidden'
25
+ throw error
26
+ }
27
+ }
28
+
29
+ for (const model of configuredModels) {
30
+ app.createService(model.name, {
31
+ // findUnique: async function(where) {
32
+ // await authorize(this, model.name, 'findUnique', [where])
33
+ // const filter = buildWhere(where)
34
+ // const result = await db.query(`SELECT * FROM ${model.quotedTable} WHERE ${filter.sql} LIMIT 1`, filter.values)
35
+ // return result.rows[0] ?? null
36
+ // },
37
+
38
+ // findMany: async function(where, queryOptions = {}) {
39
+ // await authorize(this, model.name, 'findMany', [where, queryOptions])
40
+ // const filter = buildWhere(where)
41
+ // let sql = `SELECT * FROM ${model.quotedTable} WHERE ${filter.sql}`
42
+ // const values = [...filter.values]
43
+ // if (queryOptions.limit != null) {
44
+ // if (!Number.isInteger(queryOptions.limit) || queryOptions.limit < 1) {
45
+ // throw new TypeError('limit must be a positive integer')
46
+ // }
47
+ // values.push(queryOptions.limit)
48
+ // sql += ` LIMIT $${values.length}`
49
+ // }
50
+ // return (await db.query(sql, values)).rows
51
+ // },
52
+
53
+ createWithMeta: async function(uid, data, createdAt = new Date()) {
54
+ await authorize(this, model.name, 'createWithMeta', [uid, data, createdAt])
55
+ assertPlainObject(data, 'mutation data')
56
+ const timestamp = normalizeTimestamp(createdAt, 'created_at')
57
+ const safeData = { ...data, [model.primaryKey]: uid }
58
+ const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
59
+ const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
60
+ const values = entries.map(([, value]) => value)
61
+ const parameters = values.map((_, index) => `$${index + 1}`)
62
+ const updateEntries = entries.filter(([column]) => column !== model.primaryKey)
63
+ const conflictAction = updateEntries.length
64
+ ? 'DO UPDATE SET ' + updateEntries
65
+ .map(([column]) => `${quoteIdentifier(column, 'data column')} = EXCLUDED.${quoteIdentifier(column, 'data column')}`).join(', ')
66
+ : 'DO UPDATE SET ' + `${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}`
67
+ return withTransaction(db, async client => {
68
+ const result = await client.query(
69
+ `INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')}) `
70
+ + `ON CONFLICT (${model.quotedPrimaryKey}) ${conflictAction}`
71
+ + ' RETURNING *',
72
+ values,
73
+ )
74
+ const txid = await transactionId(client)
75
+ return [result.rows[0], mutationMeta(uid, 'created_at', timestamp, txid)]
76
+ })
77
+ },
78
+
79
+ updateWithMeta: async function(uid, data, updatedAt = new Date()) {
80
+ await authorize(this, model.name, 'updateWithMeta', [uid, data, updatedAt])
81
+ const timestamp = normalizeTimestamp(updatedAt, 'updated_at')
82
+ const set = buildSet(data)
83
+ return withTransaction(db, async client => {
84
+ const result = await client.query(
85
+ `UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
86
+ [...set.values, uid],
87
+ )
88
+ const txid = await transactionId(client)
89
+ return [result.rows[0], mutationMeta(uid, 'updated_at', timestamp, txid)]
90
+ })
91
+ },
92
+
93
+ deleteWithMeta: async function(uid, deletedAt = new Date()) {
94
+ await authorize(this, model.name, 'deleteWithMeta', [uid, deletedAt])
95
+ const timestamp = normalizeTimestamp(deletedAt, 'deleted_at')
96
+ return withTransaction(db, async client => {
97
+ const result = await client.query(
98
+ `DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [uid],
99
+ )
100
+ const txid = await transactionId(client)
101
+ return [result.rows[0], mutationMeta(uid, 'deleted_at', timestamp, txid)]
102
+ })
103
+ },
104
+ })
105
+ }
106
+
107
+ // uses long-polling request waiting for changes
108
+ app.get(shapePath, async (request, response, next) => {
109
+ try {
110
+ const model = configuredModels.find(candidate => candidate.name === request.params.model)
111
+ if (!model) return response.status(404).json({ error: 'unknown model' })
112
+ await authorize({ app, request, response, transport: 'http' }, model.name, 'shape', [request.query])
113
+ const target = new URL(electricUrl)
114
+ for (const [key, value] of Object.entries(request.query)) {
115
+ if (key === 'table') continue
116
+ if (Array.isArray(value)) value.forEach(entry => target.searchParams.append(key, entry))
117
+ else if (value != null) target.searchParams.set(key, value)
118
+ }
119
+ target.searchParams.set('table', model.table)
120
+ if (options.sourceId) target.searchParams.set('source_id', options.sourceId)
121
+ if (options.sourceSecret) target.searchParams.set('secret', options.sourceSecret)
122
+ const upstream = await fetchImpl(target, { headers: { accept: request.get('accept') ?? '*/*' } })
123
+ response.status(upstream.status)
124
+ copyResponseHeaders(upstream, response)
125
+ response.send(Buffer.from(await upstream.arrayBuffer()))
126
+ } catch (error) {
127
+ next(error)
128
+ }
129
+ })
130
+
131
+ return { shapePath, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
132
+ }
133
+
134
+
135
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
136
+ const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
137
+
138
+ function quoteIdentifier(value, label) {
139
+ if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
140
+ throw new TypeError(`${label} must be a simple SQL identifier`)
141
+ }
142
+ return `"${value}"`
143
+ }
144
+
145
+ function normalizeModels(models) {
146
+ if (!Array.isArray(models) || models.length === 0) {
147
+ throw new TypeError('models must be a non-empty array')
148
+ }
149
+ return models.map(model => {
150
+ const config = typeof model === 'string' ? { name: model } : model
151
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
152
+ throw new TypeError('each model must be a name or configuration object')
153
+ }
154
+ const name = config.name
155
+ const table = config.table ?? name
156
+ const primaryKey = config.primaryKey ?? 'uid'
157
+ quoteIdentifier(name, 'model name')
158
+ return {
159
+ name,
160
+ table,
161
+ primaryKey,
162
+ quotedTable: quoteIdentifier(table, `table for '${name}'`),
163
+ quotedPrimaryKey: quoteIdentifier(primaryKey, `primary key for '${name}'`),
164
+ }
165
+ })
166
+ }
167
+
168
+ function assertPlainObject(value, label) {
169
+ if (!value || typeof value !== 'object' || Array.isArray(value)
170
+ || Object.prototype.toString.call(value) !== '[object Object]') {
171
+ throw new TypeError(`${label} must be a plain object`)
172
+ }
173
+ }
174
+
175
+ function buildWhere(where, startIndex = 1) {
176
+ assertPlainObject(where, 'where')
177
+ const clauses = []
178
+ const values = []
179
+ for (const [column, constraint] of Object.entries(where)) {
180
+ const quotedColumn = quoteIdentifier(column, 'where column')
181
+ if (constraint === undefined) continue
182
+ if (constraint === null) {
183
+ clauses.push(`${quotedColumn} IS NULL`)
184
+ continue
185
+ }
186
+ if (constraint && typeof constraint === 'object' && !Array.isArray(constraint)) {
187
+ const entries = Object.entries(constraint)
188
+ if (entries.length === 0 || entries.some(([operator]) => !RANGE_OPERATORS[operator])) {
189
+ throw new TypeError(`unsupported where constraint for '${column}'`)
190
+ }
191
+ for (const [operator, value] of entries) {
192
+ values.push(value)
193
+ clauses.push(`${quotedColumn} ${RANGE_OPERATORS[operator]} $${startIndex + values.length - 1}`)
194
+ }
195
+ continue
196
+ }
197
+ values.push(constraint)
198
+ clauses.push(`${quotedColumn} = $${startIndex + values.length - 1}`)
199
+ }
200
+ return { sql: clauses.length ? clauses.join(' AND ') : 'TRUE', values }
201
+ }
202
+
203
+ function buildSet(data, startIndex = 1) {
204
+ assertPlainObject(data, 'mutation data')
205
+ const entries = Object.entries(data).filter(([key, value]) => key !== 'uid' && value !== undefined)
206
+ if (entries.length === 0) throw new TypeError('mutation data must contain at least one field')
207
+ return {
208
+ sql: entries.map(([column], index) => `${quoteIdentifier(column, 'data column')} = $${startIndex + index}`).join(', '),
209
+ values: entries.map(([, value]) => value),
210
+ }
211
+ }
212
+
213
+ async function withTransaction(db, operation) {
214
+ if (typeof db?.connect !== 'function') return operation(db)
215
+ const client = await db.connect()
216
+ try {
217
+ await client.query('BEGIN')
218
+ const result = await operation(client)
219
+ await client.query('COMMIT')
220
+ return result
221
+ } catch (error) {
222
+ await client.query('ROLLBACK')
223
+ throw error
224
+ } finally {
225
+ client.release()
226
+ }
227
+ }
228
+
229
+ async function transactionId(client) {
230
+ const result = await client.query('SELECT pg_current_xact_id()::text AS txid')
231
+ return result.rows[0]?.txid
232
+ }
233
+
234
+ function mutationMeta(uid, field, timestamp, txid) {
235
+ return { uid, created_at: null, updated_at: null, deleted_at: null, [field]: timestamp, txid }
236
+ }
237
+
238
+ function normalizeTimestamp(value, field) {
239
+ const timestamp = new Date(value)
240
+ if (Number.isNaN(timestamp.getTime())) throw new TypeError(`${field} must be a valid timestamp`)
241
+ return timestamp.toISOString()
242
+ }
243
+
244
+ function copyResponseHeaders(source, target) {
245
+ source.headers.forEach((value, key) => {
246
+ if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) {
247
+ target.setHeader(key, value)
248
+ }
249
+ })
250
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Enrich `app` with listeners handling socket data transfer on page reload
3
+ *
4
+ * Usage:
5
+ * reloadPlugin(app)
6
+ */
7
+ export async function reloadPlugin(app) {
8
+
9
+ const cnxid = useSessionStorage('cnxid', '')
10
+ const cnxtoken = useSessionStorage('cnxtoken', '')
11
+ const handleTransferToken = token => {
12
+ if (typeof token === 'string') cnxtoken.value = token
13
+ }
14
+
15
+ app.addConnectListener(async (socket) => {
16
+ const socketId = socket.id
17
+ console.log('connect', socketId)
18
+ const prevSocketId = cnxid.value
19
+ const prevTransferToken = cnxtoken.value
20
+ socket.off('cnx-transfer-token', handleTransferToken)
21
+ socket.on('cnx-transfer-token', handleTransferToken)
22
+ cnxid.value = socketId
23
+ if (prevSocketId && prevTransferToken) {
24
+ console.log('cnx-transfer', prevSocketId, 'to', socketId)
25
+ let timeout
26
+ const cleanup = () => {
27
+ clearTimeout(timeout)
28
+ socket.off('cnx-transfer-ack', handleAck)
29
+ socket.off('cnx-transfer-error', handleError)
30
+ }
31
+ const handleAck = (fromSocketId, toSocketId) => {
32
+ console.log('ACK ACK!!!', fromSocketId, toSocketId)
33
+ cleanup()
34
+ }
35
+ const handleError = (fromSocketId, toSocketId) => {
36
+ console.log('ERR ERR!!!', fromSocketId, toSocketId)
37
+ cleanup()
38
+ }
39
+ socket.once('cnx-transfer-ack', handleAck)
40
+ socket.once('cnx-transfer-error', handleError)
41
+ timeout = setTimeout(cleanup, 5000)
42
+ socket.emit('cnx-transfer', prevSocketId, socketId, prevTransferToken)
43
+ }
44
+ })
45
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Register Express-X reload plugin
3
+ *
4
+ * @param {object} app Express-X/Express application
5
+ * @param {object} options
6
+ */
7
+
8
+ export const roomCache = new WeakMap()
9
+ export const dataCache = new WeakMap()
10
+
11
+
12
+ export async function reloadPlugin(app, options = {}) {
13
+
14
+ const io = app.get('io')
15
+ const transferTtlMs = app.get('config')?.reloadTransferTtlMs ?? 2 * 60 * 1000
16
+ const rooms = Object.create(null)
17
+ const data = Object.create(null)
18
+ const transferTokens = Object.create(null)
19
+ const transferExpiryTimers = Object.create(null)
20
+ roomCache.set(app, rooms)
21
+ dataCache.set(app, data)
22
+
23
+ app.addDisconnectingListener((socket, reason) => {
24
+ console.log('onSocketDisconnecting', socket.id, reason)
25
+ // save socket data & rooms in caches
26
+ const alreadySavedData = data[socket.id]
27
+ const alreadySavedRooms = rooms[socket.id]
28
+
29
+ // Current socket.data takes precedence over stale cached data so that any
30
+ // updates made between disconnections are not overwritten.
31
+ data[socket.id] = Object.assign({}, alreadySavedData, socket.data)
32
+ rooms[socket.id] = new Set(socket.rooms)
33
+ if (alreadySavedRooms) for (const room of alreadySavedRooms) rooms[socket.id].add(room)
34
+ transferTokens[socket.id] = socket.data.__cnxTransferToken
35
+ clearTimeout(transferExpiryTimers[socket.id])
36
+ transferExpiryTimers[socket.id] = setTimeout(() => {
37
+ delete rooms[socket.id]
38
+ delete data[socket.id]
39
+ delete transferTokens[socket.id]
40
+ delete transferExpiryTimers[socket.id]
41
+ }, transferTtlMs)
42
+ transferExpiryTimers[socket.id].unref?.()
43
+ })
44
+
45
+ app.addConnectListener((socket) => {
46
+ console.log('onSocketConnect', socket.id)
47
+ const transferToken = randomUUID()
48
+ socket.data.__cnxTransferToken = transferToken
49
+ socket.emit('cnx-transfer-token', transferToken)
50
+
51
+ // when client ask for transfer from fromSocketId to toSocketId
52
+ socket.on('cnx-transfer', async (fromSocketId, toSocketId, claimedToken) => {
53
+ app.log('verbose', `cnx-transfer from ${fromSocketId} to ${toSocketId}`)
54
+ // A socket may only claim its own ID as the destination — prevent session hijacking
55
+ if (toSocketId !== socket.id || typeof claimedToken !== 'string'
56
+ || transferTokens[fromSocketId] !== claimedToken) {
57
+ app.log('verbose', `cnx-transfer rejected: toSocketId ${toSocketId} !== socket.id ${socket.id}`)
58
+ socket.emit('cnx-transfer-error', fromSocketId, toSocketId)
59
+ return
60
+ }
61
+ // copy connection room & data from 'fromSocketId' to 'toSocketId'
62
+ const toSocket = io.sockets.sockets.get(toSocketId)
63
+ // data & rooms of fromSocketId are taken from dataCache and roomCache, since socket no longer exists
64
+ const fromSocketRooms = rooms[fromSocketId]
65
+ if (toSocket && fromSocketRooms) {
66
+ // copy rooms
67
+ for (const room of fromSocketRooms) {
68
+ if (room === fromSocketId) continue // do not include room associated to socket#id
69
+ const allowed = typeof options.authorizeRoomRestore === 'function'
70
+ && await options.authorizeRoomRestore({ app, socket: toSocket, room })
71
+ if (allowed) toSocket.join(room)
72
+ }
73
+ // copy data
74
+ toSocket.data = {
75
+ ...data[fromSocketId],
76
+ ...toSocket.data,
77
+ __cnxTransferToken: transferToken,
78
+ }
79
+ // console.log('cnx-transfer data', toSocket.data)
80
+ // console.log('cnx-transfer rooms', toSocket.rooms)
81
+ // remove 'from' cache data
82
+ delete rooms[fromSocketId]
83
+ delete data[fromSocketId]
84
+ delete transferTokens[fromSocketId]
85
+ clearTimeout(transferExpiryTimers[fromSocketId])
86
+ delete transferExpiryTimers[fromSocketId]
87
+ // send acknowlegment to toSocket
88
+ toSocket.emit('cnx-transfer-ack', fromSocketId, toSocketId)
89
+ } else {
90
+ console.log(`*** CNX TRANSFER ERROR, ${fromSocketId} -> ${toSocketId}`)
91
+ if (toSocket) toSocket.emit('cnx-transfer-error', fromSocketId, toSocketId)
92
+ }
93
+ })
94
+ })
95
+ }
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { electricClientPlugin, whereToElectricParams } from '../src/electric-client-plugin.mjs'
5
+
6
+ class FakeStream {
7
+ static instances = []
8
+ constructor(options) {
9
+ this.options = options
10
+ FakeStream.instances.push(this)
11
+ }
12
+ }
13
+
14
+ class FakeShape {
15
+ constructor(stream) { this.stream = stream }
16
+ subscribe(callback) {
17
+ callback({ rows: [{ uid: 'one', completed: false }] })
18
+ return () => { this.unsubscribed = true }
19
+ }
20
+ }
21
+
22
+ test('translates where objects into parameterized Electric filters', () => {
23
+ assert.deepEqual(whereToElectricParams({ completed: false, priority: { gte: 2, lt: 5 }, owner: null }), {
24
+ where: '"completed" = $1 AND "priority" >= $2 AND "priority" < $3 AND "owner" IS NULL',
25
+ params: ['false', '2', '5'],
26
+ })
27
+ assert.deepEqual(whereToElectricParams({}), {})
28
+ assert.throws(() => whereToElectricParams({ 'bad;drop': 1 }), /simple SQL identifier/)
29
+ })
30
+
31
+ test('getObservable emits Shape rows and cleans up its subscription', () => {
32
+ const app = { service: () => ({}) }
33
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
34
+ const todo = app.createElectricModel('todos')
35
+ let rows
36
+ const subscription = todo.getObservable({ completed: false }).subscribe(value => { rows = value })
37
+
38
+ assert.deepEqual(rows, [{ uid: 'one', completed: false }])
39
+ assert.equal(FakeStream.instances.at(-1).options.url, '/electric/v1/shape/todos')
40
+ assert.deepEqual(FakeStream.instances.at(-1).options.params, {
41
+ where: '"completed" = $1', params: ['false'],
42
+ })
43
+ subscription.unsubscribe()
44
+ })
45
+
46
+ test('model mutations retain the simple Express-X API', async () => {
47
+ const calls = []
48
+ const service = {
49
+ async createWithMeta(...args) { calls.push(['create', ...args]); return [{ uid: args[0], ...args[1] }, {}] },
50
+ async updateWithMeta(...args) { calls.push(['update', ...args]); return [{ uid: args[0], ...args[1] }, {}] },
51
+ async deleteWithMeta(...args) { calls.push(['remove', ...args]); return [{ uid: args[0] }, {}] },
52
+ }
53
+ const app = { service: () => service }
54
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
55
+ const todo = app.createElectricModel('todos')
56
+
57
+ const created = await todo.create({ title: 'Test' })
58
+ await todo.update(created.uid, { completed: true })
59
+ await todo.remove(created.uid)
60
+ assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
61
+ })
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { electricOfflinePlugin } from '../src/electric-server-plugin.mjs'
5
+
6
+ function fixture({ authorize = async () => true, fetch } = {}) {
7
+ const services = new Map()
8
+ const routes = new Map()
9
+ const queries = []
10
+ const db = {
11
+ async query(sql, values = []) {
12
+ queries.push({ sql, values })
13
+ if (sql.startsWith('SELECT pg_current')) return { rows: [{ txid: '42' }] }
14
+ return { rows: [{ uid: values.at(-1) ?? 'one', label: 'row' }] }
15
+ },
16
+ }
17
+ const app = {
18
+ createService(name, methods) { services.set(name, methods) },
19
+ get(path, handler) { routes.set(path, handler) },
20
+ }
21
+ const registration = electricOfflinePlugin(app, db, ['todos'], { authorize, fetch: fetch ?? globalThis.fetch })
22
+ return { app, db, services, routes, queries, registration }
23
+ }
24
+
25
+ test('registers the familiar mutation API', async () => {
26
+ const { services, queries } = fixture()
27
+ const service = services.get('todos')
28
+ assert.deepEqual(Object.keys(service), [
29
+ 'createWithMeta', 'updateWithMeta', 'deleteWithMeta',
30
+ ])
31
+
32
+ const [, meta] = await service.createWithMeta.call({}, 'one', { label: 'new' }, '2026-01-01T00:00:00Z')
33
+ assert.equal(meta.uid, 'one')
34
+ assert.equal(meta.txid, '42')
35
+ assert.equal(meta.created_at, '2026-01-01T00:00:00.000Z')
36
+
37
+ await service.createWithMeta.call({}, 'only-id', {}, '2026-01-01T00:00:00Z')
38
+ assert.match(queries[2].sql, /DO UPDATE SET "uid" = EXCLUDED\."uid"/)
39
+ await assert.rejects(service.createWithMeta.call({}, 'bad', [], new Date()), /plain object/)
40
+ })
41
+
42
+ test('requires authorization and reports forbidden calls', async () => {
43
+ const { services } = fixture({ authorize: async () => false })
44
+ await assert.rejects(
45
+ services.get('todos').createWithMeta.call({}, 'one', { label: 'no' }, new Date()),
46
+ error => error.code === 'forbidden',
47
+ )
48
+ })
49
+
50
+ test('shape proxy pins the configured table and keeps credentials server-side', async () => {
51
+ let fetched
52
+ const fetch = async (url) => {
53
+ fetched = new URL(url)
54
+ return new Response('[{"value":{"uid":"one"}}]', {
55
+ status: 200,
56
+ headers: { 'content-type': 'application/json', 'electric-handle': 'abc' },
57
+ })
58
+ }
59
+ const services = new Map()
60
+ const routes = new Map()
61
+ const db = { query: async () => ({ rows: [] }) }
62
+ const app = {
63
+ createService(name, methods) { services.set(name, methods) },
64
+ get(path, handler) { routes.set(path, handler) },
65
+ }
66
+ electricOfflinePlugin(app, db, [{ name: 'todo', table: 'todos' }], {
67
+ authorize: async () => true,
68
+ electricUrl: 'https://electric.example/v1/shape',
69
+ sourceId: 'source',
70
+ sourceSecret: 'secret',
71
+ fetch,
72
+ })
73
+ const response = {
74
+ headers: {},
75
+ status(value) { this.statusCode = value; return this },
76
+ setHeader(key, value) { this.headers[key] = value },
77
+ send(value) { this.body = value },
78
+ json(value) { this.body = value },
79
+ }
80
+ await routes.get('/electric/v1/shape/:model')({
81
+ params: { model: 'todo' },
82
+ query: { table: 'secrets', offset: '10' },
83
+ get: () => 'application/json',
84
+ }, response, error => { throw error })
85
+
86
+ assert.equal(fetched.searchParams.get('table'), 'todos')
87
+ assert.equal(fetched.searchParams.get('offset'), '10')
88
+ assert.equal(fetched.searchParams.get('source_id'), 'source')
89
+ assert.equal(fetched.searchParams.get('secret'), 'secret')
90
+ assert.equal(response.statusCode, 200)
91
+ assert.equal(response.headers['electric-handle'], 'abc')
92
+ })
93
+
94
+ test('rejects unsafe SQL identifiers', () => {
95
+ assert.throws(
96
+ () => electricOfflinePlugin(
97
+ { createService() {}, get() {} },
98
+ { query() {} },
99
+ ['todos; DROP TABLE users'],
100
+ { authorize: async () => true, fetch: async () => {} },
101
+ ),
102
+ /simple SQL identifier/,
103
+ )
104
+ })