@jcbuisson/express-x-plugins 4.0.6 → 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,9 +63,10 @@ app.configure(electricOfflinePlugin, db, [
|
|
|
58
63
|
|
|
59
64
|
This registers one Express-X service per model with the familiar API:
|
|
60
65
|
|
|
61
|
-
- `create(
|
|
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.
|
|
@@ -82,8 +88,14 @@ app.configure(electricClientPlugin, {
|
|
|
82
88
|
shapePath: '/electric/v1/shape',
|
|
83
89
|
})
|
|
84
90
|
|
|
91
|
+
// Client-generated UUID stored in the default `uid` primary key:
|
|
85
92
|
const todo = app.createElectricModel('todos')
|
|
86
93
|
|
|
94
|
+
// Or, for a database-generated primary key such as SERIAL/IDENTITY:
|
|
95
|
+
const numberedTodo = app.createElectricModel('numberedTodos', {
|
|
96
|
+
idGeneration: 'server',
|
|
97
|
+
})
|
|
98
|
+
|
|
87
99
|
const incompleteTodos = await todo.findMany({ completed: false })
|
|
88
100
|
const selectedTodo = await todo.findUnique({ uid })
|
|
89
101
|
|
|
@@ -96,6 +108,9 @@ await todo.create({ title: 'Learn Shapes', completed: false })
|
|
|
96
108
|
await todo.update(uid, { completed: true })
|
|
97
109
|
await todo.remove(uid)
|
|
98
110
|
|
|
111
|
+
const created = await numberedTodo.create({ title: 'Assigned by PostgreSQL' })
|
|
112
|
+
console.log(created.id)
|
|
113
|
+
|
|
99
114
|
subscription.unsubscribe()
|
|
100
115
|
```
|
|
101
116
|
|
|
@@ -113,7 +128,7 @@ configured table, and Electric source credentials stay server-side.
|
|
|
113
128
|
|
|
114
129
|
### Model configuration
|
|
115
130
|
|
|
116
|
-
|
|
131
|
+
Server models may be strings (table, service name, and default `uid` key) or objects:
|
|
117
132
|
|
|
118
133
|
```js
|
|
119
134
|
{ name: 'todo', table: 'todos', primaryKey: 'id' }
|
|
@@ -122,6 +137,10 @@ Models may be strings (table, service name, and default `uid` key) or objects:
|
|
|
122
137
|
Names are restricted to simple PostgreSQL identifiers. Values are always sent
|
|
123
138
|
as query parameters; range filters support `gt`, `gte`, `lt`, and `lte`.
|
|
124
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
|
+
|
|
125
144
|
Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
|
|
126
145
|
`query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
|
|
127
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,17 +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
72
|
return service.create(uid, data)
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
async function update(
|
|
71
|
-
return service.update(
|
|
75
|
+
async function update(id, data) {
|
|
76
|
+
return service.update(id, data)
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
async function remove(
|
|
75
|
-
return service.delete(
|
|
79
|
+
async function remove(id) {
|
|
80
|
+
return service.delete(id)
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
return { getObservable, findMany, create, update, remove }
|
|
@@ -28,69 +28,81 @@ 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
|
-
create
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
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 }
|
|
57
62
|
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
58
63
|
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
59
64
|
const values = entries.map(([, value]) => value)
|
|
60
65
|
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
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
|
+
: ''
|
|
67
80
|
const result = await client.query(
|
|
68
|
-
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})
|
|
69
|
-
+
|
|
70
|
-
+ ' RETURNING *',
|
|
81
|
+
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})`
|
|
82
|
+
+ conflictAction + ' RETURNING *',
|
|
71
83
|
values,
|
|
72
84
|
)
|
|
73
85
|
return result.rows[0]
|
|
74
86
|
})
|
|
75
87
|
},
|
|
76
88
|
|
|
77
|
-
update: async function(
|
|
78
|
-
await authorize(this, model.name, 'update', [
|
|
89
|
+
update: async function(id, data) {
|
|
90
|
+
await authorize(this, model.name, 'update', [id, data])
|
|
79
91
|
const set = buildSet(data)
|
|
80
92
|
return withTransaction(db, async client => {
|
|
81
93
|
const result = await client.query(
|
|
82
94
|
`UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
|
|
83
|
-
[...set.values,
|
|
95
|
+
[...set.values, id],
|
|
84
96
|
)
|
|
85
97
|
return result.rows[0]
|
|
86
98
|
})
|
|
87
99
|
},
|
|
88
100
|
|
|
89
|
-
delete: async function(
|
|
90
|
-
await authorize(this, model.name, 'delete', [
|
|
101
|
+
delete: async function(id) {
|
|
102
|
+
await authorize(this, model.name, 'delete', [id])
|
|
91
103
|
return withTransaction(db, async client => {
|
|
92
104
|
const result = await client.query(
|
|
93
|
-
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [
|
|
105
|
+
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
|
|
94
106
|
)
|
|
95
107
|
return result.rows[0]
|
|
96
108
|
})
|
|
@@ -160,8 +172,7 @@ function normalizeModels(models) {
|
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
function assertPlainObject(value, label) {
|
|
163
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
164
|
-
|| Object.prototype.toString.call(value) !== '[object Object]') {
|
|
175
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
|
|
165
176
|
throw new TypeError(`${label} must be a plain object`)
|
|
166
177
|
}
|
|
167
178
|
}
|
|
@@ -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
|
+
})
|
|
@@ -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(
|