@jcbuisson/express-x-plugins 5.0.8 → 5.1.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
@@ -489,3 +489,68 @@ Restored from the last committed custom implementations, dated July 8, 2026:
489
489
  **944c398** (client), and `express-x-drizzle` **ddf6804** (server). The algorithm
490
490
  was removed from Express-X in **47c2293** on July 19. Only module imports and
491
491
  plugin export aliases were adapted; the synchronization logic is preserved.
492
+
493
+ ## Minimal SQL-oriented Electric plugin
494
+
495
+ A separate plugin provides two client methods: `sql.watch` for live table rows
496
+ and `sql.query` for named, parameterized PostgreSQL statements. It needs no models,
497
+ ORM, local database, Vue, RxJS, schema changes, or sync metadata.
498
+
499
+ ```js
500
+ // Server (pg Pool or Client)
501
+ import { electricSqlServerPlugin } from '@jcbuisson/express-x-plugins/electric-sql-server'
502
+
503
+ app.configure(electricSqlServerPlugin, pool, {
504
+ tables: ['todos'],
505
+ statements: {
506
+ add: 'INSERT INTO todos (label) VALUES ($1) RETURNING *',
507
+ complete: 'UPDATE todos SET done = $1 WHERE id = $2 RETURNING *',
508
+ remove: 'DELETE FROM todos WHERE id = $1 RETURNING *',
509
+ },
510
+ electricUrl: 'http://localhost:3000/v1/shape',
511
+ authorize(context, operation) {
512
+ // Supply your application's authorization policy. Check statement name and
513
+ // params for query calls; table and SQL filter params for shape calls.
514
+ return Boolean(context.request?.user)
515
+ },
516
+ })
517
+ ```
518
+
519
+ ```js
520
+ // Browser (install @electric-sql/client)
521
+ import { electricSqlClientPlugin } from '@jcbuisson/express-x-plugins/electric-sql-client'
522
+
523
+ app.configure(electricSqlClientPlugin)
524
+ const stop = app.sql.watch('todos', {
525
+ where: 'done = $1',
526
+ params: [false],
527
+ }, rows => console.log(rows))
528
+
529
+ const [todo] = await app.sql.query('add', ['Buy milk'])
530
+ await app.sql.query('complete', [true, todo.id])
531
+ await app.sql.query('remove', [todo.id])
532
+ stop() // unsubscribe and abort this stream
533
+ app.sql.dispose() // stop every active watch
534
+ ```
535
+
536
+ `watch(table, callback)` watches the whole table. Filters use Electric-supported
537
+ PostgreSQL WHERE expressions with `$1`, `$2`, etc.; null comparisons use `IS NULL`.
538
+ Callbacks receive the initial synchronized rows and later committed changes.
539
+ Electric Shapes are table subscriptions, not arbitrary SELECTs or joins. Named
540
+ statements can use full PostgreSQL SQL, including joins and `RETURNING`; `query`
541
+ returns their rows. SQL text stays on the server, and values are bound through pg.
542
+
543
+ Authorization is required for both statements and Shapes. A table allowlist alone
544
+ is not row authorization: enforce tenant restrictions in server SQL and validate
545
+ Shape filters in your policy. The proxy ignores client-supplied source credentials.
546
+ Server options include `sourceId`, `sourceSecret`, `serviceName` (default `sql`),
547
+ and `shapePath` (default `/electric-sql/v1/shape/:table`). Match a custom service
548
+ name and Shape base path on the client. Client `headers` and `onError` are passed
549
+ to Electric for authentication and stream error handling.
550
+
551
+ This plugin performs online writes with server-generated keys and physical deletes.
552
+ A successful write resolves before its Electric echo necessarily arrives. It does
553
+ not queue offline writes or apply optimistic updates. Electric reconnects and
554
+ resumes live reads; the existing managed Electric plugin remains available for
555
+ persistent offline storage and writes. Use separate service names and paths when
556
+ installing both plugins in one application.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "5.0.8",
3
+ "version": "5.1.0",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -12,7 +12,9 @@
12
12
  "./electric-worker": "./src/electric-pglite-worker.mjs",
13
13
  "./sync-client": "./src/sync-client-plugin.mjs",
14
14
  "./sync-server": "./src/sync-server-plugin.mjs",
15
- "./sync-algorithm": "./src/sync-algorithm.mjs"
15
+ "./sync-algorithm": "./src/sync-algorithm.mjs",
16
+ "./electric-sql-server": "./src/electric-sql-server-plugin.mjs",
17
+ "./electric-sql-client": "./src/electric-sql-client-plugin.mjs"
16
18
  },
17
19
  "private": false,
18
20
  "publishConfig": {
@@ -0,0 +1,52 @@
1
+ import { Shape, ShapeStream } from '@electric-sql/client'
2
+
3
+ /** Install app.sql.query(name, params) and app.sql.watch(table, filter, callback). */
4
+ export function electricSqlClientPlugin(app, options = {}) {
5
+ const service = app.service(options.serviceName ?? 'sql')
6
+ const Stream = options.ShapeStream ?? ShapeStream
7
+ const LiveShape = options.Shape ?? Shape
8
+ const active = new Set()
9
+ function watch(table, filter, callback) {
10
+ if (typeof filter === 'function') { callback = filter; filter = {} }
11
+ const { where, params = [] } = filter ?? {}
12
+ if (typeof table !== 'string' || !table) throw new TypeError('table is required')
13
+ if (typeof callback !== 'function') throw new TypeError('callback is required')
14
+ if (where !== undefined && typeof where !== 'string') throw new TypeError('where must be SQL text')
15
+ if (!Array.isArray(params)) throw new TypeError('params must be an array')
16
+ const values = params.map(value => {
17
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString()
18
+ if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'bigint'
19
+ || (typeof value === 'number' && Number.isFinite(value))) return String(value)
20
+ throw new TypeError('shape params must be strings, booleans, finite numbers, bigints or Dates; use IS NULL for null')
21
+ })
22
+ const controller = new AbortController()
23
+ const stream = new Stream({
24
+ url: `${(options.shapePath ?? '/electric-sql/v1/shape').replace(/\/$/, '')}/${encodeURIComponent(table)}`,
25
+ headers: options.headers,
26
+ onError: options.onError,
27
+ signal: controller.signal,
28
+ params: { ...(where === undefined ? {} : { where }), params: values },
29
+ })
30
+ const shape = new LiveShape(stream)
31
+ const unsubscribe = shape.subscribe(({ rows }) => callback([...rows]))
32
+ let stopped = false
33
+ function stop() {
34
+ if (stopped) return
35
+ stopped = true
36
+ unsubscribe()
37
+ stream.unsubscribeAll()
38
+ controller.abort()
39
+ active.delete(stop)
40
+ }
41
+ active.add(stop)
42
+ return stop
43
+ }
44
+ app.sql = {
45
+ query: (name, params = []) => service.query(name, params),
46
+ watch,
47
+ dispose() { for (const stop of active) stop() },
48
+ }
49
+ return app
50
+ }
51
+
52
+ export default electricSqlClientPlugin
@@ -0,0 +1,69 @@
1
+ /** SQL statements execute on PostgreSQL; Electric supplies live table reads. */
2
+ export function electricSqlServerPlugin(app, db, options = {}) {
3
+ const { tables, statements = {}, authorize } = options
4
+ if (!db || typeof db.query !== 'function') throw new TypeError('db must expose query(sql, params)')
5
+ if (!Array.isArray(tables) || !tables.length || tables.some(table => typeof table !== 'string' || !table)) {
6
+ throw new TypeError('tables must be a non-empty array of table names')
7
+ }
8
+ if (typeof authorize !== 'function') throw new TypeError('authorize(context, operation) is required')
9
+ const sql = new Map(Object.entries(statements))
10
+ for (const statement of sql.values()) {
11
+ if (typeof statement !== 'string' || !statement.trim()) throw new TypeError('statements must contain SQL strings')
12
+ }
13
+ const fetchImpl = options.fetch ?? globalThis.fetch
14
+ const upstreamUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
15
+ const shapePath = options.shapePath ?? '/electric-sql/v1/shape/:table'
16
+ async function check(context, operation) {
17
+ if (!await authorize(context, operation)) {
18
+ const error = new Error('forbidden')
19
+ error.code = 'forbidden'
20
+ throw error
21
+ }
22
+ }
23
+ app.createService(options.serviceName ?? 'sql', {
24
+ async query(name, params = []) {
25
+ if (!sql.has(name)) throw new TypeError('unknown SQL statement')
26
+ if (!Array.isArray(params)) throw new TypeError('params must be an array')
27
+ await check(this, { action: 'query', name, params })
28
+ return (await db.query(sql.get(name), params)).rows
29
+ },
30
+ })
31
+ app.get(shapePath, async (request, response, next) => {
32
+ try {
33
+ const table = request.params.table
34
+ if (!tables.includes(table)) return response.status(404).json({ error: 'unknown table' })
35
+ const target = new URL(upstreamUrl)
36
+ // Only Electric protocol and SQL filter parameters cross the proxy.
37
+ for (const [key, value] of Object.entries(request.query)) {
38
+ if (!['offset', 'handle', 'live', 'cursor', 'where', 'columns', 'replica'].includes(key)
39
+ && !/^params\[[1-9][0-9]*\]$/.test(key)) continue
40
+ if (typeof value !== 'string') throw new TypeError('shape parameters must be strings')
41
+ target.searchParams.set(key, value)
42
+ }
43
+ target.searchParams.set('table', table)
44
+ await check({ app, request, response, transport: 'http' }, {
45
+ action: 'shape', table, params: Object.fromEntries(target.searchParams),
46
+ })
47
+ if (options.sourceId) target.searchParams.set('source_id', options.sourceId)
48
+ if (options.sourceSecret) target.searchParams.set('secret', options.sourceSecret)
49
+ const controller = new AbortController()
50
+ const abort = () => controller.abort()
51
+ response.on?.('close', abort)
52
+ try {
53
+ const upstream = await fetchImpl(target, { signal: controller.signal })
54
+ response.status(upstream.status)
55
+ upstream.headers.forEach((value, key) => {
56
+ if (!['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'set-cookie'].includes(key.toLowerCase())) {
57
+ response.setHeader(key, value)
58
+ }
59
+ })
60
+ response.send(Buffer.from(await upstream.arrayBuffer()))
61
+ } finally {
62
+ response.off?.('close', abort)
63
+ }
64
+ } catch (error) { next(error) }
65
+ })
66
+ return { shapePath }
67
+ }
68
+
69
+ export default electricSqlServerPlugin
@@ -0,0 +1,74 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { electricSqlServerPlugin } from '../src/electric-sql-server-plugin.mjs'
4
+ import { electricSqlClientPlugin } from '../src/electric-sql-client-plugin.mjs'
5
+
6
+ function server(authorize = () => true) {
7
+ const state = { queries: [] }
8
+ const app = {
9
+ createService(name, methods) { state.service = methods },
10
+ get(path, handler) { state.route = handler },
11
+ }
12
+ electricSqlServerPlugin(app, { async query(...args) { state.queries.push(args); return { rows: [{ id: 1 }] } } }, {
13
+ tables: ['todos'], statements: { add: 'INSERT INTO todos (label) VALUES ($1) RETURNING *' },
14
+ authorize, sourceSecret: 'server-secret',
15
+ async fetch(url) { state.target = url; return new Response('[]', { headers: { 'electric-handle': 'abc' } }) },
16
+ })
17
+ return state
18
+ }
19
+
20
+ test('executes only registered SQL with bound values and caller context', async () => {
21
+ const context = { user: 1 }
22
+ const state = server((caller, operation) => caller === context && operation.name === 'add')
23
+ const value = "'); DROP TABLE todos; --"
24
+ assert.deepEqual(await state.service.query.call(context, 'add', [value]), [{ id: 1 }])
25
+ assert.deepEqual(state.queries[0], ['INSERT INTO todos (label) VALUES ($1) RETURNING *', [value]])
26
+ await assert.rejects(state.service.query.call(context, 'toString'), /unknown/)
27
+ await assert.rejects(state.service.query.call({}, 'add', ['blocked']), { code: 'forbidden' })
28
+ assert.equal(state.queries.length, 1)
29
+ })
30
+
31
+ test('shape proxy pins table and credentials and preserves SQL parameters', async () => {
32
+ const state = server()
33
+ const response = { status() { return this }, setHeader() {}, send(body) { this.body = body } }
34
+ await state.route({ params: { table: 'todos' }, query: {
35
+ table: 'private', secret: 'attacker', source_id: 'attacker', offset: '-1', where: 'id = $1', 'params[1]': '123',
36
+ } }, response, error => { throw error })
37
+ assert.equal(state.target.searchParams.get('table'), 'todos')
38
+ assert.equal(state.target.searchParams.get('secret'), 'server-secret')
39
+ assert.equal(state.target.searchParams.get('source_id'), null)
40
+ assert.equal(state.target.searchParams.get('params[1]'), '123')
41
+ assert.equal(response.body.toString(), '[]')
42
+ const denied = server(() => false)
43
+ let failure
44
+ await denied.route({ params: { table: 'todos' }, query: {} }, response, error => { failure = error })
45
+ assert.equal(failure.code, 'forbidden')
46
+ assert.equal(denied.target, undefined)
47
+ })
48
+
49
+ test('client binds SQL filters and stops network work on unsubscribe or disposal', async () => {
50
+ const streams = []
51
+ class Stream {
52
+ constructor(options) { this.options = options; streams.push(this) }
53
+ unsubscribeAll() { this.stopped = true }
54
+ }
55
+ class Shape {
56
+ constructor(stream) { this.stream = stream }
57
+ subscribe(callback) { this.stream.emit = callback; return () => { this.stream.unsubscribed = true } }
58
+ }
59
+ const app = { service: () => ({ query: async (...args) => args }) }
60
+ electricSqlClientPlugin(app, { ShapeStream: Stream, Shape })
61
+ assert.deepEqual(await app.sql.query('add', ['hello']), ['add', ['hello']])
62
+ let rows
63
+ const stop = app.sql.watch('todos', { where: 'done = $1', params: [false] }, value => { rows = value })
64
+ assert.deepEqual(streams[0].options.params, { where: 'done = $1', params: ['false'] })
65
+ streams[0].emit({ rows: [{ id: 1 }] })
66
+ assert.deepEqual(rows, [{ id: 1 }])
67
+ stop()
68
+ stop()
69
+ assert.ok(streams[0].stopped && streams[0].unsubscribed && streams[0].options.signal.aborted)
70
+ app.sql.watch('todos', () => {})
71
+ app.sql.dispose()
72
+ assert.ok(streams[1].options.signal.aborted)
73
+ assert.throws(() => app.sql.watch('todos', { params: [null] }, () => {}), /IS NULL/)
74
+ })