@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(uid, data)`
62
- - `update(uid, data)`
63
- - `delete(uid)`
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
- 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:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.6",
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,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(uid, data) {
71
- return service.update(uid, data)
75
+ async function update(id, data) {
76
+ return service.update(id, data)
72
77
  }
73
78
 
74
- async function remove(uid) {
75
- return service.delete(uid)
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
- // 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: async function(uid, data) {
54
- await authorize(this, model.name, 'create', [uid, data])
55
- assertPlainObject(data, 'mutation data')
56
- 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 }
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
- + `ON CONFLICT (${model.quotedPrimaryKey}) ${conflictAction}`
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(uid, data) {
78
- await authorize(this, model.name, 'update', [uid, data])
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, uid],
95
+ [...set.values, id],
84
96
  )
85
97
  return result.rows[0]
86
98
  })
87
99
  },
88
100
 
89
- delete: async function(uid) {
90
- await authorize(this, model.name, 'delete', [uid])
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 *`, [uid],
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(