@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
|
|
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
|
+
})
|