@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
- - `createWithMeta(uid, data, createdAt)`
62
- - `updateWithMeta(uid, data, updatedAt)`
63
- - `deleteWithMeta(uid, deletedAt)`
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 results remain `[value, meta]` tuples for compatibility. `meta.txid`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.5",
3
+ "version": "4.0.6",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -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
- const now = new Date().toISOString()
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
- const [value] = await service.updateWithMeta(uid, data, new Date().toISOString())
74
- return value
71
+ return service.update(uid, data)
75
72
  }
76
73
 
77
74
  async function remove(uid) {
78
- const [value] = await service.deleteWithMeta(uid, new Date().toISOString())
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
- createWithMeta: async function(uid, data, createdAt = new Date()) {
54
- await authorize(this, model.name, 'createWithMeta', [uid, data, createdAt])
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
- const txid = await transactionId(client)
75
- return [result.rows[0], mutationMeta(uid, 'created_at', timestamp, txid)]
73
+ return result.rows[0]
76
74
  })
77
75
  },
78
76
 
79
- updateWithMeta: async function(uid, data, updatedAt = new Date()) {
80
- await authorize(this, model.name, 'updateWithMeta', [uid, data, updatedAt])
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
- const txid = await transactionId(client)
89
- return [result.rows[0], mutationMeta(uid, 'updated_at', timestamp, txid)]
85
+ return result.rows[0]
90
86
  })
91
87
  },
92
88
 
93
- deleteWithMeta: async function(uid, deletedAt = new Date()) {
94
- await authorize(this, model.name, 'deleteWithMeta', [uid, deletedAt])
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
- const txid = await transactionId(client)
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 createWithMeta(...args) { calls.push(['create', ...args]); return [{ uid: args[0], ...args[1] }, {}] },
134
- async updateWithMeta(...args) { calls.push(['update', ...args]); return [{ uid: args[0], ...args[1] }, {}] },
135
- async deleteWithMeta(...args) { calls.push(['remove', ...args]); return [{ uid: args[0] }, {}] },
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 familiar mutation API', async () => {
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
- 'createWithMeta', 'updateWithMeta', 'deleteWithMeta',
28
+ 'create', 'update', 'delete',
30
29
  ])
31
30
 
32
- const [, meta] = await service.createWithMeta.call({}, 'one', { label: 'new' }, '2026-01-01T00:00:00Z')
33
- assert.equal(meta.uid, 'one')
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.createWithMeta.call({}, 'only-id', {}, '2026-01-01T00:00:00Z')
38
- assert.match(queries[2].sql, /DO UPDATE SET "uid" = EXCLUDED\."uid"/)
39
- await assert.rejects(service.createWithMeta.call({}, 'bad', [], new Date()), /plain object/)
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').createWithMeta.call({}, 'one', { label: 'no' }, new Date()),
42
+ services.get('todos').create.call({}, 'one', { label: 'no' }),
46
43
  error => error.code === 'forbidden',
47
44
  )
48
45
  })