@jcbuisson/express-x-plugins 4.0.4 → 4.0.6
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
|
@@ -1,23 +1,43 @@
|
|
|
1
|
-
# express-x-
|
|
1
|
+
# express-x-plugins
|
|
2
|
+
|
|
3
|
+
Currently includes:
|
|
4
|
+
|
|
5
|
+
- a plugin which preserves room membership and socket data across page reloads
|
|
6
|
+
- a plugin integrating ElectricSQL sync engine into express-x, which greatly simplifies relational database
|
|
7
|
+
access and provides powerful local-first features
|
|
2
8
|
|
|
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: Electric is the sync engine.
|
|
6
9
|
|
|
7
10
|
## Install
|
|
8
11
|
|
|
9
12
|
```sh
|
|
10
|
-
npm install @jcbuisson/express-x-
|
|
13
|
+
npm install @jcbuisson/express-x-plugins
|
|
11
14
|
```
|
|
12
15
|
|
|
13
|
-
This server-only installation does not install the browser Electric client or RxJS.
|
|
14
16
|
|
|
15
|
-
##
|
|
17
|
+
## Reload plugin
|
|
18
|
+
|
|
19
|
+
### Server
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## Local-first Postgres plugin
|
|
27
|
+
|
|
28
|
+
The smallest useful ElectricSQL integration for Express-X. Express-X handles authorized PostgreSQL mutations;
|
|
29
|
+
Electric's Shape API streams those changes to clients: Electric is the sync engine.
|
|
30
|
+
|
|
31
|
+
### Server
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm install pg
|
|
35
|
+
```
|
|
16
36
|
|
|
17
37
|
```js
|
|
18
38
|
import { Pool } from 'pg'
|
|
19
|
-
import { expressX } from '@jcbuisson/express-x'
|
|
20
|
-
import { electricOfflinePlugin } from '@jcbuisson/express-x-electric'
|
|
39
|
+
import { expressX } from '@jcbuisson/express-x/server'
|
|
40
|
+
import { electricOfflinePlugin } from '@jcbuisson/express-x-plugins/electric-server'
|
|
21
41
|
|
|
22
42
|
const app = expressX()
|
|
23
43
|
const db = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
@@ -38,27 +58,25 @@ app.configure(electricOfflinePlugin, db, [
|
|
|
38
58
|
|
|
39
59
|
This registers one Express-X service per model with the familiar API:
|
|
40
60
|
|
|
41
|
-
- `
|
|
42
|
-
- `
|
|
43
|
-
- `
|
|
61
|
+
- `create(uid, data)`
|
|
62
|
+
- `update(uid, data)`
|
|
63
|
+
- `delete(uid)`
|
|
44
64
|
|
|
45
65
|
Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
|
|
46
66
|
one-shot server reads would bypass Electric and are intentionally omitted.
|
|
47
67
|
|
|
48
|
-
Mutation
|
|
49
|
-
contains `pg_current_xact_id()` and can be passed to an Electric-aware client to
|
|
50
|
-
wait for the matching transaction in its Shape stream.
|
|
68
|
+
Mutation methods return the created, updated, or deleted row directly.
|
|
51
69
|
|
|
52
|
-
|
|
70
|
+
### Client
|
|
53
71
|
|
|
54
72
|
Install the optional client dependencies in the browser application:
|
|
55
73
|
|
|
56
74
|
```sh
|
|
57
|
-
npm install @
|
|
75
|
+
npm install @electric-sql/client rxjs
|
|
58
76
|
```
|
|
59
77
|
|
|
60
78
|
```js
|
|
61
|
-
import { electricClientPlugin } from '@jcbuisson/express-x-electric
|
|
79
|
+
import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
|
|
62
80
|
|
|
63
81
|
app.configure(electricClientPlugin, {
|
|
64
82
|
shapePath: '/electric/v1/shape',
|
|
@@ -93,7 +111,7 @@ Object filters use parameterized Electric Shape predicates. Exact values,
|
|
|
93
111
|
All Electric cursor parameters are forwarded. The client cannot override the
|
|
94
112
|
configured table, and Electric source credentials stay server-side.
|
|
95
113
|
|
|
96
|
-
|
|
114
|
+
### Model configuration
|
|
97
115
|
|
|
98
116
|
Models may be strings (table, service name, and default `uid` key) or objects:
|
|
99
117
|
|
|
@@ -108,7 +126,7 @@ Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
|
|
|
108
126
|
`query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
|
|
109
127
|
transaction ID are captured in the same transaction.
|
|
110
128
|
|
|
111
|
-
|
|
129
|
+
### Run Electric from Docker
|
|
112
130
|
|
|
113
131
|
A local install of the Electric sync engine requires Elixir and Erlang; it is simpler to use a pre-built Docker image.
|
|
114
132
|
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { getCurrentScope, onScopeDispose, ref } from 'vue'
|
|
|
10
10
|
* electricClientPlugin(app)
|
|
11
11
|
* const todo = app.createElectricModel('todos')
|
|
12
12
|
* todo.getObservable({ completed: false }).subscribe(...)
|
|
13
|
+
* const completedTodos = await todo.findMany({ completed: false })
|
|
13
14
|
*/
|
|
14
15
|
export function electricClientPlugin(app, options = {}) {
|
|
15
16
|
const shapePath = options.shapePath ?? '/electric/v1/shape'
|
|
@@ -47,13 +48,6 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
47
48
|
})
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function getVueRef(where = {}) {
|
|
51
|
-
const value = ref([])
|
|
52
|
-
const subscription = getObservable(where).subscribe(rows => { value.value = rows })
|
|
53
|
-
if (getCurrentScope()) onScopeDispose(() => subscription.unsubscribe())
|
|
54
|
-
return value
|
|
55
|
-
}
|
|
56
|
-
|
|
57
51
|
function findMany(where = {}) {
|
|
58
52
|
const observable = getObservable(where)
|
|
59
53
|
if (!getCurrentScope()) return firstValueFrom(observable)
|
|
@@ -66,30 +60,22 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
66
60
|
return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
|
|
67
61
|
}
|
|
68
62
|
|
|
69
|
-
function findUnique(where = {}) {
|
|
70
|
-
return findMany(where).then(rows => rows[0] ?? null)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
63
|
async function create(data) {
|
|
74
64
|
assertPlainObject(data, 'mutation data')
|
|
75
65
|
const uid = globalThis.crypto?.randomUUID?.()
|
|
76
66
|
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
77
|
-
|
|
78
|
-
const [value] = await service.createWithMeta(uid, data, now)
|
|
79
|
-
return value
|
|
67
|
+
return service.create(uid, data)
|
|
80
68
|
}
|
|
81
69
|
|
|
82
70
|
async function update(uid, data) {
|
|
83
|
-
|
|
84
|
-
return value
|
|
71
|
+
return service.update(uid, data)
|
|
85
72
|
}
|
|
86
73
|
|
|
87
74
|
async function remove(uid) {
|
|
88
|
-
|
|
89
|
-
return value
|
|
75
|
+
return service.delete(uid)
|
|
90
76
|
}
|
|
91
77
|
|
|
92
|
-
return { getObservable,
|
|
78
|
+
return { getObservable, findMany, create, update, remove }
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
return Object.assign(app, { createElectricModel })
|
|
@@ -50,10 +50,9 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
50
50
|
// return (await db.query(sql, values)).rows
|
|
51
51
|
// },
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
await authorize(this, model.name, '
|
|
53
|
+
create: async function(uid, data) {
|
|
54
|
+
await authorize(this, model.name, 'create', [uid, data])
|
|
55
55
|
assertPlainObject(data, 'mutation data')
|
|
56
|
-
const timestamp = normalizeTimestamp(createdAt, 'created_at')
|
|
57
56
|
const safeData = { ...data, [model.primaryKey]: uid }
|
|
58
57
|
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
59
58
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
@@ -71,34 +70,29 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
71
70
|
+ ' RETURNING *',
|
|
72
71
|
values,
|
|
73
72
|
)
|
|
74
|
-
|
|
75
|
-
return [result.rows[0], mutationMeta(uid, 'created_at', timestamp, txid)]
|
|
73
|
+
return result.rows[0]
|
|
76
74
|
})
|
|
77
75
|
},
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
await authorize(this, model.name, '
|
|
81
|
-
const timestamp = normalizeTimestamp(updatedAt, 'updated_at')
|
|
77
|
+
update: async function(uid, data) {
|
|
78
|
+
await authorize(this, model.name, 'update', [uid, data])
|
|
82
79
|
const set = buildSet(data)
|
|
83
80
|
return withTransaction(db, async client => {
|
|
84
81
|
const result = await client.query(
|
|
85
82
|
`UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
|
|
86
83
|
[...set.values, uid],
|
|
87
84
|
)
|
|
88
|
-
|
|
89
|
-
return [result.rows[0], mutationMeta(uid, 'updated_at', timestamp, txid)]
|
|
85
|
+
return result.rows[0]
|
|
90
86
|
})
|
|
91
87
|
},
|
|
92
88
|
|
|
93
|
-
|
|
94
|
-
await authorize(this, model.name, '
|
|
95
|
-
const timestamp = normalizeTimestamp(deletedAt, 'deleted_at')
|
|
89
|
+
delete: async function(uid) {
|
|
90
|
+
await authorize(this, model.name, 'delete', [uid])
|
|
96
91
|
return withTransaction(db, async client => {
|
|
97
92
|
const result = await client.query(
|
|
98
93
|
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [uid],
|
|
99
94
|
)
|
|
100
|
-
|
|
101
|
-
return [result.rows[0], mutationMeta(uid, 'deleted_at', timestamp, txid)]
|
|
95
|
+
return result.rows[0]
|
|
102
96
|
})
|
|
103
97
|
},
|
|
104
98
|
})
|
|
@@ -226,21 +220,6 @@ async function withTransaction(db, operation) {
|
|
|
226
220
|
}
|
|
227
221
|
}
|
|
228
222
|
|
|
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
223
|
function copyResponseHeaders(source, target) {
|
|
245
224
|
source.headers.forEach((value, key) => {
|
|
246
225
|
if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) {
|
|
@@ -130,9 +130,9 @@ test('findUnique resolves with the first matching row or null', async () => {
|
|
|
130
130
|
test('model mutations retain the simple Express-X API', async () => {
|
|
131
131
|
const calls = []
|
|
132
132
|
const service = {
|
|
133
|
-
async
|
|
134
|
-
async
|
|
135
|
-
async
|
|
133
|
+
async create(...args) { calls.push(['create', ...args]); return { uid: args[0], ...args[1] } },
|
|
134
|
+
async update(...args) { calls.push(['update', ...args]); return { uid: args[0], ...args[1] } },
|
|
135
|
+
async delete(...args) { calls.push(['remove', ...args]); return { uid: args[0] } },
|
|
136
136
|
}
|
|
137
137
|
const app = { service: () => service }
|
|
138
138
|
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
@@ -10,7 +10,6 @@ function fixture({ authorize = async () => true, fetch } = {}) {
|
|
|
10
10
|
const db = {
|
|
11
11
|
async query(sql, values = []) {
|
|
12
12
|
queries.push({ sql, values })
|
|
13
|
-
if (sql.startsWith('SELECT pg_current')) return { rows: [{ txid: '42' }] }
|
|
14
13
|
return { rows: [{ uid: values.at(-1) ?? 'one', label: 'row' }] }
|
|
15
14
|
},
|
|
16
15
|
}
|
|
@@ -22,27 +21,25 @@ function fixture({ authorize = async () => true, fetch } = {}) {
|
|
|
22
21
|
return { app, db, services, routes, queries, registration }
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
test('registers the
|
|
24
|
+
test('registers the mutation API', async () => {
|
|
26
25
|
const { services, queries } = fixture()
|
|
27
26
|
const service = services.get('todos')
|
|
28
27
|
assert.deepEqual(Object.keys(service), [
|
|
29
|
-
'
|
|
28
|
+
'create', 'update', 'delete',
|
|
30
29
|
])
|
|
31
30
|
|
|
32
|
-
const
|
|
33
|
-
assert.equal(
|
|
34
|
-
assert.equal(meta.txid, '42')
|
|
35
|
-
assert.equal(meta.created_at, '2026-01-01T00:00:00.000Z')
|
|
31
|
+
const value = await service.create.call({}, 'one', { label: 'new' })
|
|
32
|
+
assert.equal(value.uid, 'one')
|
|
36
33
|
|
|
37
|
-
await service.
|
|
38
|
-
assert.match(queries[
|
|
39
|
-
await assert.rejects(service.
|
|
34
|
+
await service.create.call({}, 'only-id', {})
|
|
35
|
+
assert.match(queries[1].sql, /DO UPDATE SET "uid" = EXCLUDED\."uid"/)
|
|
36
|
+
await assert.rejects(service.create.call({}, 'bad', []), /plain object/)
|
|
40
37
|
})
|
|
41
38
|
|
|
42
39
|
test('requires authorization and reports forbidden calls', async () => {
|
|
43
40
|
const { services } = fixture({ authorize: async () => false })
|
|
44
41
|
await assert.rejects(
|
|
45
|
-
services.get('todos').
|
|
42
|
+
services.get('todos').create.call({}, 'one', { label: 'no' }),
|
|
46
43
|
error => error.code === 'forbidden',
|
|
47
44
|
)
|
|
48
45
|
})
|