@jcbuisson/express-x-plugins 4.0.6 → 4.0.8
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,5 +1,23 @@
|
|
|
1
|
+
|
|
1
2
|
# express-x-plugins
|
|
2
3
|
|
|
4
|
+
|
|
5
|
+
IMPORTANT
|
|
6
|
+
|
|
7
|
+
# set lc_messages=C for the PostgreSQL role so Electric receives recognizable English errors
|
|
8
|
+
ALTER ROLE chris SET lc_messages = 'C';
|
|
9
|
+
|
|
10
|
+
# ALLOW POSTGRES LOGICAL REPLICATION
|
|
11
|
+
ALTER SYSTEM SET wal_level = 'logical';
|
|
12
|
+
ALTER SYSTEM SET max_replication_slots = 10;
|
|
13
|
+
ALTER SYSTEM SET max_wal_senders = 10;
|
|
14
|
+
ALTER ROLE chris WITH REPLICATION;
|
|
15
|
+
|
|
16
|
+
## restart postgres
|
|
17
|
+
sudo systemctl restart postgresql
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
3
21
|
Currently includes:
|
|
4
22
|
|
|
5
23
|
- a plugin which preserves room membership and socket data across page reloads
|
|
@@ -58,12 +76,16 @@ app.configure(electricOfflinePlugin, db, [
|
|
|
58
76
|
|
|
59
77
|
This registers one Express-X service per model with the familiar API:
|
|
60
78
|
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
63
|
-
- `
|
|
79
|
+
- `findUnique(where)`
|
|
80
|
+
- `findMany(where, queryOptions)`
|
|
81
|
+
- `create(id, data)` for a client-generated primary key
|
|
82
|
+
- `create(data)` for a database-generated primary key
|
|
83
|
+
- `update(id, data)`
|
|
84
|
+
- `delete(id)`
|
|
64
85
|
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
The client model provides synchronized reads through `findMany(where)` and
|
|
87
|
+
`getObservable(where)`. The service also exposes direct one-shot server reads
|
|
88
|
+
when synchronization is not required.
|
|
67
89
|
|
|
68
90
|
Mutation methods return the created, updated, or deleted row directly.
|
|
69
91
|
|
|
@@ -82,10 +104,15 @@ app.configure(electricClientPlugin, {
|
|
|
82
104
|
shapePath: '/electric/v1/shape',
|
|
83
105
|
})
|
|
84
106
|
|
|
107
|
+
// Client-generated UUID stored in the default `uid` primary key:
|
|
85
108
|
const todo = app.createElectricModel('todos')
|
|
86
109
|
|
|
110
|
+
// Or, for a database-generated primary key such as SERIAL/IDENTITY:
|
|
111
|
+
const numberedTodo = app.createElectricModel('numberedTodos', {
|
|
112
|
+
idGeneration: 'server',
|
|
113
|
+
})
|
|
114
|
+
|
|
87
115
|
const incompleteTodos = await todo.findMany({ completed: false })
|
|
88
|
-
const selectedTodo = await todo.findUnique({ uid })
|
|
89
116
|
|
|
90
117
|
const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
|
|
91
118
|
console.log(rows)
|
|
@@ -96,14 +123,16 @@ await todo.create({ title: 'Learn Shapes', completed: false })
|
|
|
96
123
|
await todo.update(uid, { completed: true })
|
|
97
124
|
await todo.remove(uid)
|
|
98
125
|
|
|
126
|
+
const created = await numberedTodo.create({ title: 'Assigned by PostgreSQL' })
|
|
127
|
+
console.log(created.id)
|
|
128
|
+
|
|
99
129
|
subscription.unsubscribe()
|
|
100
130
|
```
|
|
101
131
|
|
|
102
132
|
`findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
|
|
103
133
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if that scope is disposed before a result arrives.
|
|
134
|
+
It unsubscribes after the first emission; if called within a Vue scope, it also
|
|
135
|
+
unsubscribes if that scope is disposed before a result arrives.
|
|
107
136
|
|
|
108
137
|
Object filters use parameterized Electric Shape predicates. Exact values,
|
|
109
138
|
`null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
|
|
@@ -113,7 +142,7 @@ configured table, and Electric source credentials stay server-side.
|
|
|
113
142
|
|
|
114
143
|
### Model configuration
|
|
115
144
|
|
|
116
|
-
|
|
145
|
+
Server models may be strings (table, service name, and default `uid` key) or objects:
|
|
117
146
|
|
|
118
147
|
```js
|
|
119
148
|
{ name: 'todo', table: 'todos', primaryKey: 'id' }
|
|
@@ -122,6 +151,10 @@ Models may be strings (table, service name, and default `uid` key) or objects:
|
|
|
122
151
|
Names are restricted to simple PostgreSQL identifiers. Values are always sent
|
|
123
152
|
as query parameters; range filters support `gt`, `gte`, `lt`, and `lte`.
|
|
124
153
|
|
|
154
|
+
Client models default to `idGeneration: 'client'`, which generates a UUID and calls
|
|
155
|
+
`create(id, data)`. Set `idGeneration: 'server'` to call `create(data)` and let a
|
|
156
|
+
PostgreSQL default, sequence, or identity column generate the primary key.
|
|
157
|
+
|
|
125
158
|
Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
|
|
126
159
|
`query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
|
|
127
160
|
transaction ID are captured in the same transaction.
|
|
@@ -137,8 +170,16 @@ services:
|
|
|
137
170
|
environment:
|
|
138
171
|
DATABASE_URL: postgresql://user:password@host.docker.internal:5432/mydb
|
|
139
172
|
ELECTRIC_INSECURE: "true"
|
|
173
|
+
ELECTRIC_STORAGE: FAST_FILE
|
|
174
|
+
ELECTRIC_PERSISTENT_STATE: FILE
|
|
175
|
+
ELECTRIC_STORAGE_DIR: /var/lib/electric
|
|
140
176
|
ports:
|
|
141
177
|
- "3001:3000"
|
|
178
|
+
volumes:
|
|
179
|
+
- electric_mydb_data:/var/lib/electric
|
|
180
|
+
|
|
181
|
+
volumes:
|
|
182
|
+
electric_mydb_data:
|
|
142
183
|
```
|
|
143
184
|
|
|
144
185
|
```
|
package/package.json
CHANGED
|
@@ -2,20 +2,31 @@ import { Shape, ShapeStream } from '@electric-sql/client'
|
|
|
2
2
|
import { firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'
|
|
3
3
|
import { getCurrentScope, onScopeDispose, ref } from 'vue'
|
|
4
4
|
|
|
5
|
+
export class DisposableShape extends Shape {
|
|
6
|
+
subscribe(callback) {
|
|
7
|
+
const unsubscribe = super.subscribe(callback)
|
|
8
|
+
return () => {
|
|
9
|
+
unsubscribe()
|
|
10
|
+
if (this.numSubscribers === 0) this.stream.unsubscribeAll()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
|
|
6
16
|
/**
|
|
7
17
|
* Add Electric-backed reactive models to an Express-X client.
|
|
8
18
|
*
|
|
9
19
|
* Usage:
|
|
10
20
|
* electricClientPlugin(app)
|
|
11
|
-
* const todo = app.createElectricModel('todos')
|
|
21
|
+
* const todo = app.createElectricModel('todos') // primary key is provided by client
|
|
22
|
+
* const todo = app.createElectricModel('todos', { idGeneration: 'server' }) // primary key is server-generated
|
|
12
23
|
* todo.getObservable({ completed: false }).subscribe(...)
|
|
13
24
|
* const completedTodos = await todo.findMany({ completed: false })
|
|
14
25
|
*/
|
|
15
26
|
export function electricClientPlugin(app, options = {}) {
|
|
16
27
|
const shapePath = options.shapePath ?? '/electric/v1/shape'
|
|
17
28
|
const ShapeStreamClass = options.ShapeStream ?? ShapeStream
|
|
18
|
-
const ShapeClass = options.Shape ??
|
|
29
|
+
const ShapeClass = options.Shape ?? DisposableShape
|
|
19
30
|
const ObservableClass = options.Observable ?? Observable
|
|
20
31
|
|
|
21
32
|
function createElectricModel(modelName, modelOptions = {}) {
|
|
@@ -23,6 +34,10 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
23
34
|
const service = app.service(modelName)
|
|
24
35
|
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
25
36
|
const streamOptions = modelOptions.streamOptions ?? {}
|
|
37
|
+
const idGeneration = modelOptions.idGeneration ?? 'client'
|
|
38
|
+
if (!['client', 'server'].includes(idGeneration)) {
|
|
39
|
+
throw new TypeError("idGeneration must be 'client' or 'server'")
|
|
40
|
+
}
|
|
26
41
|
|
|
27
42
|
function getObservable(where = {}) {
|
|
28
43
|
// Validate eagerly
|
|
@@ -62,17 +77,18 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
62
77
|
|
|
63
78
|
async function create(data) {
|
|
64
79
|
assertPlainObject(data, 'mutation data')
|
|
80
|
+
if (idGeneration === 'server') return service.create(data)
|
|
65
81
|
const uid = globalThis.crypto?.randomUUID?.()
|
|
66
82
|
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
67
83
|
return service.create(uid, data)
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
async function update(
|
|
71
|
-
return service.update(
|
|
86
|
+
async function update(id, data) {
|
|
87
|
+
return service.update(id, data)
|
|
72
88
|
}
|
|
73
89
|
|
|
74
|
-
async function remove(
|
|
75
|
-
return service.delete(
|
|
90
|
+
async function remove(id) {
|
|
91
|
+
return service.delete(id)
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
return { getObservable, findMany, create, update, remove }
|
|
@@ -26,71 +26,86 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// for each model 'name', there is a service 'name' with methods 'findUnique', 'findMany', 'create', 'update', 'delete'
|
|
29
30
|
for (const model of configuredModels) {
|
|
30
31
|
app.createService(model.name, {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
32
|
+
|
|
33
|
+
findUnique: async function(where) {
|
|
34
|
+
await authorize(this, model.name, 'findUnique', [where])
|
|
35
|
+
const filter = buildWhere(where)
|
|
36
|
+
const result = await db.query(`SELECT * FROM ${model.quotedTable} WHERE ${filter.sql} LIMIT 1`, filter.values)
|
|
37
|
+
return result.rows[0] ?? null
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
findMany: async function(where, queryOptions = {}) {
|
|
41
|
+
await authorize(this, model.name, 'findMany', [where, queryOptions])
|
|
42
|
+
const filter = buildWhere(where)
|
|
43
|
+
let sql = `SELECT * FROM ${model.quotedTable} WHERE ${filter.sql}`
|
|
44
|
+
const values = [...filter.values]
|
|
45
|
+
if (queryOptions.limit != null) {
|
|
46
|
+
if (!Number.isInteger(queryOptions.limit) || queryOptions.limit < 1) {
|
|
47
|
+
throw new TypeError('limit must be a positive integer')
|
|
48
|
+
}
|
|
49
|
+
values.push(queryOptions.limit)
|
|
50
|
+
sql += ` LIMIT $${values.length}`
|
|
51
|
+
}
|
|
52
|
+
return (await db.query(sql, values)).rows
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// create(data): the primary key is server-generated
|
|
56
|
+
// create(uid, data): uid (the primary key) is provided by the client
|
|
57
|
+
create: async function(idOrData, data) {
|
|
58
|
+
const hasClientId = data !== undefined
|
|
59
|
+
const mutationData = hasClientId ? data : idOrData
|
|
60
|
+
await authorize(this, model.name, 'create', hasClientId ? [idOrData, mutationData] : [mutationData])
|
|
61
|
+
assertPlainObject(mutationData, 'mutation data')
|
|
62
|
+
const safeData = hasClientId
|
|
63
|
+
? { ...mutationData, [model.primaryKey]: idOrData }
|
|
64
|
+
: { ...mutationData }
|
|
57
65
|
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
58
66
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
59
67
|
const values = entries.map(([, value]) => value)
|
|
60
68
|
const parameters = values.map((_, index) => `$${index + 1}`)
|
|
61
|
-
const updateEntries = entries.filter(([column]) => column !== model.primaryKey)
|
|
62
|
-
const conflictAction = updateEntries.length
|
|
63
|
-
? 'DO UPDATE SET ' + updateEntries
|
|
64
|
-
.map(([column]) => `${quoteIdentifier(column, 'data column')} = EXCLUDED.${quoteIdentifier(column, 'data column')}`).join(', ')
|
|
65
|
-
: 'DO UPDATE SET ' + `${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}`
|
|
66
69
|
return withTransaction(db, async client => {
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
const result = await client.query(`INSERT INTO ${model.quotedTable} DEFAULT VALUES RETURNING *`)
|
|
72
|
+
return result.rows[0]
|
|
73
|
+
}
|
|
74
|
+
const conflictAction = hasClientId
|
|
75
|
+
? ' ON CONFLICT (' + model.quotedPrimaryKey + ') DO UPDATE SET '
|
|
76
|
+
+ (entries.some(([column]) => column !== model.primaryKey)
|
|
77
|
+
? entries
|
|
78
|
+
.filter(([column]) => column !== model.primaryKey)
|
|
79
|
+
.map(([column]) => `${quoteIdentifier(column, 'data column')} = EXCLUDED.${quoteIdentifier(column, 'data column')}`)
|
|
80
|
+
.join(', ')
|
|
81
|
+
: `${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}`)
|
|
82
|
+
: ''
|
|
67
83
|
const result = await client.query(
|
|
68
|
-
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})
|
|
69
|
-
+
|
|
70
|
-
+ ' RETURNING *',
|
|
84
|
+
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})`
|
|
85
|
+
+ conflictAction + ' RETURNING *',
|
|
71
86
|
values,
|
|
72
87
|
)
|
|
73
88
|
return result.rows[0]
|
|
74
89
|
})
|
|
75
90
|
},
|
|
76
91
|
|
|
77
|
-
update: async function(
|
|
78
|
-
await authorize(this, model.name, 'update', [
|
|
92
|
+
update: async function(id, data) {
|
|
93
|
+
await authorize(this, model.name, 'update', [id, data])
|
|
79
94
|
const set = buildSet(data)
|
|
80
95
|
return withTransaction(db, async client => {
|
|
81
96
|
const result = await client.query(
|
|
82
97
|
`UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
|
|
83
|
-
[...set.values,
|
|
98
|
+
[...set.values, id],
|
|
84
99
|
)
|
|
85
100
|
return result.rows[0]
|
|
86
101
|
})
|
|
87
102
|
},
|
|
88
103
|
|
|
89
|
-
delete: async function(
|
|
90
|
-
await authorize(this, model.name, 'delete', [
|
|
104
|
+
delete: async function(id) {
|
|
105
|
+
await authorize(this, model.name, 'delete', [id])
|
|
91
106
|
return withTransaction(db, async client => {
|
|
92
107
|
const result = await client.query(
|
|
93
|
-
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [
|
|
108
|
+
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
|
|
94
109
|
)
|
|
95
110
|
return result.rows[0]
|
|
96
111
|
})
|
|
@@ -160,8 +175,7 @@ function normalizeModels(models) {
|
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
function assertPlainObject(value, label) {
|
|
163
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
164
|
-
|| Object.prototype.toString.call(value) !== '[object Object]') {
|
|
178
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
|
|
165
179
|
throw new TypeError(`${label} must be a plain object`)
|
|
166
180
|
}
|
|
167
181
|
}
|
|
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import { effectScope, isRef } from 'vue'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DisposableShape,
|
|
7
|
+
electricClientPlugin,
|
|
8
|
+
whereToElectricParams,
|
|
9
|
+
} from '../src/electric-client-plugin.mjs'
|
|
6
10
|
|
|
7
11
|
class FakeStream {
|
|
8
12
|
static instances = []
|
|
@@ -36,6 +40,19 @@ class EmptyShape {
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
test('DisposableShape tears down its stream with its last subscriber', () => {
|
|
44
|
+
const stream = {
|
|
45
|
+
subscribe() { return () => {} },
|
|
46
|
+
unsubscribeAll() { this.unsubscribed = true },
|
|
47
|
+
}
|
|
48
|
+
const shape = new DisposableShape(stream)
|
|
49
|
+
const unsubscribe = shape.subscribe(() => {})
|
|
50
|
+
|
|
51
|
+
unsubscribe()
|
|
52
|
+
|
|
53
|
+
assert.equal(stream.unsubscribed, true)
|
|
54
|
+
})
|
|
55
|
+
|
|
39
56
|
test('translates where objects into parameterized Electric filters', () => {
|
|
40
57
|
assert.deepEqual(whereToElectricParams({ completed: false, priority: { gte: 2, lt: 5 }, owner: null }), {
|
|
41
58
|
where: '"completed" = $1 AND "priority" >= $2 AND "priority" < $3 AND "owner" IS NULL',
|
|
@@ -143,3 +160,25 @@ test('model mutations retain the simple Express-X API', async () => {
|
|
|
143
160
|
await todo.remove(created.uid)
|
|
144
161
|
assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
|
|
145
162
|
})
|
|
163
|
+
|
|
164
|
+
test('model creation supports server-generated IDs', async () => {
|
|
165
|
+
const calls = []
|
|
166
|
+
const service = {
|
|
167
|
+
async create(...args) { calls.push(args); return { id: 42, ...args[0] } },
|
|
168
|
+
}
|
|
169
|
+
const app = { service: () => service }
|
|
170
|
+
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
171
|
+
const todo = app.createElectricModel('todos', { idGeneration: 'server' })
|
|
172
|
+
|
|
173
|
+
assert.deepEqual(await todo.create({ title: 'Test' }), { id: 42, title: 'Test' })
|
|
174
|
+
assert.deepEqual(calls, [[{ title: 'Test' }]])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('rejects an unsupported ID generation strategy', () => {
|
|
178
|
+
const app = { service: () => ({}) }
|
|
179
|
+
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
180
|
+
assert.throws(
|
|
181
|
+
() => app.createElectricModel('todos', { idGeneration: 'database-ish' }),
|
|
182
|
+
/idGeneration/,
|
|
183
|
+
)
|
|
184
|
+
})
|
|
@@ -36,6 +36,18 @@ test('registers the mutation API', async () => {
|
|
|
36
36
|
await assert.rejects(service.create.call({}, 'bad', []), /plain object/)
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
test('allows the database to generate a primary key', async () => {
|
|
40
|
+
const { services, queries } = fixture()
|
|
41
|
+
const service = services.get('todos')
|
|
42
|
+
|
|
43
|
+
await service.create.call({}, { label: 'generated id' })
|
|
44
|
+
assert.match(queries[0].sql, /^INSERT INTO "todos" \("label"\) VALUES \(\$1\) RETURNING \*$/)
|
|
45
|
+
assert.doesNotMatch(queries[0].sql, /ON CONFLICT/)
|
|
46
|
+
|
|
47
|
+
await service.create.call({}, {})
|
|
48
|
+
assert.equal(queries[1].sql, 'INSERT INTO "todos" DEFAULT VALUES RETURNING *')
|
|
49
|
+
})
|
|
50
|
+
|
|
39
51
|
test('requires authorization and reports forbidden calls', async () => {
|
|
40
52
|
const { services } = fixture({ authorize: async () => false })
|
|
41
53
|
await assert.rejects(
|