@jcbuisson/express-x-plugins 4.0.5 → 4.0.7
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,10 @@
|
|
|
1
|
+
|
|
1
2
|
# express-x-plugins
|
|
2
3
|
|
|
4
|
+
|
|
5
|
+
IMPORTANT: set lc_messages=C for the PostgreSQL chris role so Electric receives recognizable English errors.
|
|
6
|
+
|
|
7
|
+
|
|
3
8
|
Currently includes:
|
|
4
9
|
|
|
5
10
|
- a plugin which preserves room membership and socket data across page reloads
|
|
@@ -58,16 +63,15 @@ app.configure(electricOfflinePlugin, db, [
|
|
|
58
63
|
|
|
59
64
|
This registers one Express-X service per model with the familiar API:
|
|
60
65
|
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
63
|
-
- `
|
|
66
|
+
- `create(id, data)` for a client-generated primary key
|
|
67
|
+
- `create(data)` for a database-generated primary key
|
|
68
|
+
- `update(id, data)`
|
|
69
|
+
- `delete(id)`
|
|
64
70
|
|
|
65
71
|
Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
|
|
66
72
|
one-shot server reads would bypass Electric and are intentionally omitted.
|
|
67
73
|
|
|
68
|
-
Mutation
|
|
69
|
-
contains `pg_current_xact_id()` and can be passed to an Electric-aware client to
|
|
70
|
-
wait for the matching transaction in its Shape stream.
|
|
74
|
+
Mutation methods return the created, updated, or deleted row directly.
|
|
71
75
|
|
|
72
76
|
### Client
|
|
73
77
|
|
|
@@ -84,8 +88,14 @@ app.configure(electricClientPlugin, {
|
|
|
84
88
|
shapePath: '/electric/v1/shape',
|
|
85
89
|
})
|
|
86
90
|
|
|
91
|
+
// Client-generated UUID stored in the default `uid` primary key:
|
|
87
92
|
const todo = app.createElectricModel('todos')
|
|
88
93
|
|
|
94
|
+
// Or, for a database-generated primary key such as SERIAL/IDENTITY:
|
|
95
|
+
const numberedTodo = app.createElectricModel('numberedTodos', {
|
|
96
|
+
idGeneration: 'server',
|
|
97
|
+
})
|
|
98
|
+
|
|
89
99
|
const incompleteTodos = await todo.findMany({ completed: false })
|
|
90
100
|
const selectedTodo = await todo.findUnique({ uid })
|
|
91
101
|
|
|
@@ -98,6 +108,9 @@ await todo.create({ title: 'Learn Shapes', completed: false })
|
|
|
98
108
|
await todo.update(uid, { completed: true })
|
|
99
109
|
await todo.remove(uid)
|
|
100
110
|
|
|
111
|
+
const created = await numberedTodo.create({ title: 'Assigned by PostgreSQL' })
|
|
112
|
+
console.log(created.id)
|
|
113
|
+
|
|
101
114
|
subscription.unsubscribe()
|
|
102
115
|
```
|
|
103
116
|
|
|
@@ -115,7 +128,7 @@ configured table, and Electric source credentials stay server-side.
|
|
|
115
128
|
|
|
116
129
|
### Model configuration
|
|
117
130
|
|
|
118
|
-
|
|
131
|
+
Server models may be strings (table, service name, and default `uid` key) or objects:
|
|
119
132
|
|
|
120
133
|
```js
|
|
121
134
|
{ name: 'todo', table: 'todos', primaryKey: 'id' }
|
|
@@ -124,6 +137,10 @@ Models may be strings (table, service name, and default `uid` key) or objects:
|
|
|
124
137
|
Names are restricted to simple PostgreSQL identifiers. Values are always sent
|
|
125
138
|
as query parameters; range filters support `gt`, `gte`, `lt`, and `lte`.
|
|
126
139
|
|
|
140
|
+
Client models default to `idGeneration: 'client'`, which generates a UUID and calls
|
|
141
|
+
`create(id, data)`. Set `idGeneration: 'server'` to call `create(data)` and let a
|
|
142
|
+
PostgreSQL default, sequence, or identity column generate the primary key.
|
|
143
|
+
|
|
127
144
|
Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
|
|
128
145
|
`query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
|
|
129
146
|
transaction ID are captured in the same transaction.
|
package/package.json
CHANGED
|
@@ -23,6 +23,10 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
23
23
|
const service = app.service(modelName)
|
|
24
24
|
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
25
25
|
const streamOptions = modelOptions.streamOptions ?? {}
|
|
26
|
+
const idGeneration = modelOptions.idGeneration ?? 'client'
|
|
27
|
+
if (!['client', 'server'].includes(idGeneration)) {
|
|
28
|
+
throw new TypeError("idGeneration must be 'client' or 'server'")
|
|
29
|
+
}
|
|
26
30
|
|
|
27
31
|
function getObservable(where = {}) {
|
|
28
32
|
// Validate eagerly
|
|
@@ -62,21 +66,18 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
62
66
|
|
|
63
67
|
async function create(data) {
|
|
64
68
|
assertPlainObject(data, 'mutation data')
|
|
69
|
+
if (idGeneration === 'server') return service.create(data)
|
|
65
70
|
const uid = globalThis.crypto?.randomUUID?.()
|
|
66
71
|
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
67
|
-
|
|
68
|
-
const [value] = await service.createWithMeta(uid, data, now)
|
|
69
|
-
return value
|
|
72
|
+
return service.create(uid, data)
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
async function update(
|
|
73
|
-
|
|
74
|
-
return value
|
|
75
|
+
async function update(id, data) {
|
|
76
|
+
return service.update(id, data)
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
async function remove(
|
|
78
|
-
|
|
79
|
-
return value
|
|
79
|
+
async function remove(id) {
|
|
80
|
+
return service.delete(id)
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
return { getObservable, findMany, create, update, remove }
|
|
@@ -28,77 +28,83 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
|
28
28
|
|
|
29
29
|
for (const model of configuredModels) {
|
|
30
30
|
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
|
-
const
|
|
57
|
-
|
|
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
|
+
// create(data) (primary key is server-generated) or create(uid, data) (uid is the primary key, client-generated)
|
|
54
|
+
create: async function(idOrData, data) {
|
|
55
|
+
const hasClientId = data !== undefined
|
|
56
|
+
const mutationData = hasClientId ? data : idOrData
|
|
57
|
+
await authorize(this, model.name, 'create', hasClientId ? [idOrData, mutationData] : [mutationData])
|
|
58
|
+
assertPlainObject(mutationData, 'mutation data')
|
|
59
|
+
const safeData = hasClientId
|
|
60
|
+
? { ...mutationData, [model.primaryKey]: idOrData }
|
|
61
|
+
: { ...mutationData }
|
|
58
62
|
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
59
63
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
60
64
|
const values = entries.map(([, value]) => value)
|
|
61
65
|
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
66
|
return withTransaction(db, async client => {
|
|
67
|
+
if (entries.length === 0) {
|
|
68
|
+
const result = await client.query(`INSERT INTO ${model.quotedTable} DEFAULT VALUES RETURNING *`)
|
|
69
|
+
return result.rows[0]
|
|
70
|
+
}
|
|
71
|
+
const conflictAction = hasClientId
|
|
72
|
+
? ' ON CONFLICT (' + model.quotedPrimaryKey + ') DO UPDATE SET '
|
|
73
|
+
+ (entries.some(([column]) => column !== model.primaryKey)
|
|
74
|
+
? entries
|
|
75
|
+
.filter(([column]) => column !== model.primaryKey)
|
|
76
|
+
.map(([column]) => `${quoteIdentifier(column, 'data column')} = EXCLUDED.${quoteIdentifier(column, 'data column')}`)
|
|
77
|
+
.join(', ')
|
|
78
|
+
: `${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}`)
|
|
79
|
+
: ''
|
|
68
80
|
const result = await client.query(
|
|
69
|
-
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})
|
|
70
|
-
+
|
|
71
|
-
+ ' RETURNING *',
|
|
81
|
+
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})`
|
|
82
|
+
+ conflictAction + ' RETURNING *',
|
|
72
83
|
values,
|
|
73
84
|
)
|
|
74
|
-
|
|
75
|
-
return [result.rows[0], mutationMeta(uid, 'created_at', timestamp, txid)]
|
|
85
|
+
return result.rows[0]
|
|
76
86
|
})
|
|
77
87
|
},
|
|
78
88
|
|
|
79
|
-
|
|
80
|
-
await authorize(this, model.name, '
|
|
81
|
-
const timestamp = normalizeTimestamp(updatedAt, 'updated_at')
|
|
89
|
+
update: async function(id, data) {
|
|
90
|
+
await authorize(this, model.name, 'update', [id, data])
|
|
82
91
|
const set = buildSet(data)
|
|
83
92
|
return withTransaction(db, async client => {
|
|
84
93
|
const result = await client.query(
|
|
85
94
|
`UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
|
|
86
|
-
[...set.values,
|
|
95
|
+
[...set.values, id],
|
|
87
96
|
)
|
|
88
|
-
|
|
89
|
-
return [result.rows[0], mutationMeta(uid, 'updated_at', timestamp, txid)]
|
|
97
|
+
return result.rows[0]
|
|
90
98
|
})
|
|
91
99
|
},
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
await authorize(this, model.name, '
|
|
95
|
-
const timestamp = normalizeTimestamp(deletedAt, 'deleted_at')
|
|
101
|
+
delete: async function(id) {
|
|
102
|
+
await authorize(this, model.name, 'delete', [id])
|
|
96
103
|
return withTransaction(db, async client => {
|
|
97
104
|
const result = await client.query(
|
|
98
|
-
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [
|
|
105
|
+
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
|
|
99
106
|
)
|
|
100
|
-
|
|
101
|
-
return [result.rows[0], mutationMeta(uid, 'deleted_at', timestamp, txid)]
|
|
107
|
+
return result.rows[0]
|
|
102
108
|
})
|
|
103
109
|
},
|
|
104
110
|
})
|
|
@@ -166,8 +172,7 @@ function normalizeModels(models) {
|
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
function assertPlainObject(value, label) {
|
|
169
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
170
|
-
|| Object.prototype.toString.call(value) !== '[object Object]') {
|
|
175
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
|
|
171
176
|
throw new TypeError(`${label} must be a plain object`)
|
|
172
177
|
}
|
|
173
178
|
}
|
|
@@ -226,21 +231,6 @@ async function withTransaction(db, operation) {
|
|
|
226
231
|
}
|
|
227
232
|
}
|
|
228
233
|
|
|
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
234
|
function copyResponseHeaders(source, target) {
|
|
245
235
|
source.headers.forEach((value, key) => {
|
|
246
236
|
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 })
|
|
@@ -143,3 +143,25 @@ test('model mutations retain the simple Express-X API', async () => {
|
|
|
143
143
|
await todo.remove(created.uid)
|
|
144
144
|
assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
test('model creation supports server-generated IDs', async () => {
|
|
148
|
+
const calls = []
|
|
149
|
+
const service = {
|
|
150
|
+
async create(...args) { calls.push(args); return { id: 42, ...args[0] } },
|
|
151
|
+
}
|
|
152
|
+
const app = { service: () => service }
|
|
153
|
+
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
154
|
+
const todo = app.createElectricModel('todos', { idGeneration: 'server' })
|
|
155
|
+
|
|
156
|
+
assert.deepEqual(await todo.create({ title: 'Test' }), { id: 42, title: 'Test' })
|
|
157
|
+
assert.deepEqual(calls, [[{ title: 'Test' }]])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('rejects an unsupported ID generation strategy', () => {
|
|
161
|
+
const app = { service: () => ({}) }
|
|
162
|
+
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
163
|
+
assert.throws(
|
|
164
|
+
() => app.createElectricModel('todos', { idGeneration: 'database-ish' }),
|
|
165
|
+
/idGeneration/,
|
|
166
|
+
)
|
|
167
|
+
})
|
|
@@ -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,37 @@ 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/)
|
|
37
|
+
})
|
|
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 *')
|
|
40
49
|
})
|
|
41
50
|
|
|
42
51
|
test('requires authorization and reports forbidden calls', async () => {
|
|
43
52
|
const { services } = fixture({ authorize: async () => false })
|
|
44
53
|
await assert.rejects(
|
|
45
|
-
services.get('todos').
|
|
54
|
+
services.get('todos').create.call({}, 'one', { label: 'no' }),
|
|
46
55
|
error => error.code === 'forbidden',
|
|
47
56
|
)
|
|
48
57
|
})
|