@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
- - `createWithMeta(uid, data, createdAt)`
62
- - `updateWithMeta(uid, data, updatedAt)`
63
- - `deleteWithMeta(uid, deletedAt)`
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 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.
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
- Models may be strings (table, service name, and default `uid` key) or objects:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.5",
3
+ "version": "4.0.7",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -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
- const now = new Date().toISOString()
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(uid, data) {
73
- const [value] = await service.updateWithMeta(uid, data, new Date().toISOString())
74
- return value
75
+ async function update(id, data) {
76
+ return service.update(id, data)
75
77
  }
76
78
 
77
- async function remove(uid) {
78
- const [value] = await service.deleteWithMeta(uid, new Date().toISOString())
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
- // 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
- createWithMeta: async function(uid, data, createdAt = new Date()) {
54
- await authorize(this, model.name, 'createWithMeta', [uid, data, createdAt])
55
- assertPlainObject(data, 'mutation data')
56
- const timestamp = normalizeTimestamp(createdAt, 'created_at')
57
- const safeData = { ...data, [model.primaryKey]: uid }
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
- + `ON CONFLICT (${model.quotedPrimaryKey}) ${conflictAction}`
71
- + ' RETURNING *',
81
+ `INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})`
82
+ + conflictAction + ' RETURNING *',
72
83
  values,
73
84
  )
74
- const txid = await transactionId(client)
75
- return [result.rows[0], mutationMeta(uid, 'created_at', timestamp, txid)]
85
+ return result.rows[0]
76
86
  })
77
87
  },
78
88
 
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')
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, uid],
95
+ [...set.values, id],
87
96
  )
88
- const txid = await transactionId(client)
89
- return [result.rows[0], mutationMeta(uid, 'updated_at', timestamp, txid)]
97
+ return result.rows[0]
90
98
  })
91
99
  },
92
100
 
93
- deleteWithMeta: async function(uid, deletedAt = new Date()) {
94
- await authorize(this, model.name, 'deleteWithMeta', [uid, deletedAt])
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 *`, [uid],
105
+ `DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
99
106
  )
100
- const txid = await transactionId(client)
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 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 })
@@ -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 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/)
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').createWithMeta.call({}, 'one', { label: 'no' }, new Date()),
54
+ services.get('todos').create.call({}, 'one', { label: 'no' }),
46
55
  error => error.code === 'forbidden',
47
56
  )
48
57
  })