@jcbuisson/express-x-plugins 4.0.5 → 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
|
@@ -58,16 +58,14 @@ app.configure(electricOfflinePlugin, db, [
|
|
|
58
58
|
|
|
59
59
|
This registers one Express-X service per model with the familiar API:
|
|
60
60
|
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
63
|
-
- `
|
|
61
|
+
- `create(uid, data)`
|
|
62
|
+
- `update(uid, data)`
|
|
63
|
+
- `delete(uid)`
|
|
64
64
|
|
|
65
65
|
Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
|
|
66
66
|
one-shot server reads would bypass Electric and are intentionally omitted.
|
|
67
67
|
|
|
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.
|
|
68
|
+
Mutation methods return the created, updated, or deleted row directly.
|
|
71
69
|
|
|
72
70
|
### Client
|
|
73
71
|
|
package/package.json
CHANGED
|
@@ -64,19 +64,15 @@ export function electricClientPlugin(app, options = {}) {
|
|
|
64
64
|
assertPlainObject(data, 'mutation data')
|
|
65
65
|
const uid = globalThis.crypto?.randomUUID?.()
|
|
66
66
|
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
67
|
-
|
|
68
|
-
const [value] = await service.createWithMeta(uid, data, now)
|
|
69
|
-
return value
|
|
67
|
+
return service.create(uid, data)
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
async function update(uid, data) {
|
|
73
|
-
|
|
74
|
-
return value
|
|
71
|
+
return service.update(uid, data)
|
|
75
72
|
}
|
|
76
73
|
|
|
77
74
|
async function remove(uid) {
|
|
78
|
-
|
|
79
|
-
return value
|
|
75
|
+
return service.delete(uid)
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
return { getObservable, findMany, create, update, remove }
|
|
@@ -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
|
})
|