@jcbuisson/express-x-plugins 4.0.6 → 4.0.8

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,23 @@
1
+
1
2
  # express-x-plugins
2
3
 
4
+
5
+ IMPORTANT
6
+
7
+ # set lc_messages=C for the PostgreSQL role so Electric receives recognizable English errors
8
+ ALTER ROLE chris SET lc_messages = 'C';
9
+
10
+ # ALLOW POSTGRES LOGICAL REPLICATION
11
+ ALTER SYSTEM SET wal_level = 'logical';
12
+ ALTER SYSTEM SET max_replication_slots = 10;
13
+ ALTER SYSTEM SET max_wal_senders = 10;
14
+ ALTER ROLE chris WITH REPLICATION;
15
+
16
+ ## restart postgres
17
+ sudo systemctl restart postgresql
18
+
19
+
20
+
3
21
  Currently includes:
4
22
 
5
23
  - a plugin which preserves room membership and socket data across page reloads
@@ -58,12 +76,16 @@ app.configure(electricOfflinePlugin, db, [
58
76
 
59
77
  This registers one Express-X service per model with the familiar API:
60
78
 
61
- - `create(uid, data)`
62
- - `update(uid, data)`
63
- - `delete(uid)`
79
+ - `findUnique(where)`
80
+ - `findMany(where, queryOptions)`
81
+ - `create(id, data)` for a client-generated primary key
82
+ - `create(data)` for a database-generated primary key
83
+ - `update(id, data)`
84
+ - `delete(id)`
64
85
 
65
- Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
66
- one-shot server reads would bypass Electric and are intentionally omitted.
86
+ The client model provides synchronized reads through `findMany(where)` and
87
+ `getObservable(where)`. The service also exposes direct one-shot server reads
88
+ when synchronization is not required.
67
89
 
68
90
  Mutation methods return the created, updated, or deleted row directly.
69
91
 
@@ -82,10 +104,15 @@ app.configure(electricClientPlugin, {
82
104
  shapePath: '/electric/v1/shape',
83
105
  })
84
106
 
107
+ // Client-generated UUID stored in the default `uid` primary key:
85
108
  const todo = app.createElectricModel('todos')
86
109
 
110
+ // Or, for a database-generated primary key such as SERIAL/IDENTITY:
111
+ const numberedTodo = app.createElectricModel('numberedTodos', {
112
+ idGeneration: 'server',
113
+ })
114
+
87
115
  const incompleteTodos = await todo.findMany({ completed: false })
88
- const selectedTodo = await todo.findUnique({ uid })
89
116
 
90
117
  const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
91
118
  console.log(rows)
@@ -96,14 +123,16 @@ await todo.create({ title: 'Learn Shapes', completed: false })
96
123
  await todo.update(uid, { completed: true })
97
124
  await todo.remove(uid)
98
125
 
126
+ const created = await numberedTodo.create({ title: 'Assigned by PostgreSQL' })
127
+ console.log(created.id)
128
+
99
129
  subscription.unsubscribe()
100
130
  ```
101
131
 
102
132
  `findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
103
133
 
104
- `findUnique(where)` resolves with the first matching row, or `null` when there is no match.
105
- Both unsubscribe after their first emission; if called within a Vue scope, they also unsubscribe
106
- if that scope is disposed before a result arrives.
134
+ It unsubscribes after the first emission; if called within a Vue scope, it also
135
+ unsubscribes if that scope is disposed before a result arrives.
107
136
 
108
137
  Object filters use parameterized Electric Shape predicates. Exact values,
109
138
  `null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
@@ -113,7 +142,7 @@ configured table, and Electric source credentials stay server-side.
113
142
 
114
143
  ### Model configuration
115
144
 
116
- Models may be strings (table, service name, and default `uid` key) or objects:
145
+ Server models may be strings (table, service name, and default `uid` key) or objects:
117
146
 
118
147
  ```js
119
148
  { name: 'todo', table: 'todos', primaryKey: 'id' }
@@ -122,6 +151,10 @@ Models may be strings (table, service name, and default `uid` key) or objects:
122
151
  Names are restricted to simple PostgreSQL identifiers. Values are always sent
123
152
  as query parameters; range filters support `gt`, `gte`, `lt`, and `lte`.
124
153
 
154
+ Client models default to `idGeneration: 'client'`, which generates a UUID and calls
155
+ `create(id, data)`. Set `idGeneration: 'server'` to call `create(data)` and let a
156
+ PostgreSQL default, sequence, or identity column generate the primary key.
157
+
125
158
  Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
126
159
  `query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
127
160
  transaction ID are captured in the same transaction.
@@ -137,8 +170,16 @@ services:
137
170
  environment:
138
171
  DATABASE_URL: postgresql://user:password@host.docker.internal:5432/mydb
139
172
  ELECTRIC_INSECURE: "true"
173
+ ELECTRIC_STORAGE: FAST_FILE
174
+ ELECTRIC_PERSISTENT_STATE: FILE
175
+ ELECTRIC_STORAGE_DIR: /var/lib/electric
140
176
  ports:
141
177
  - "3001:3000"
178
+ volumes:
179
+ - electric_mydb_data:/var/lib/electric
180
+
181
+ volumes:
182
+ electric_mydb_data:
142
183
  ```
143
184
 
144
185
  ```
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.8",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -2,20 +2,31 @@ import { Shape, ShapeStream } from '@electric-sql/client'
2
2
  import { firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'
3
3
  import { getCurrentScope, onScopeDispose, ref } from 'vue'
4
4
 
5
+ export class DisposableShape extends Shape {
6
+ subscribe(callback) {
7
+ const unsubscribe = super.subscribe(callback)
8
+ return () => {
9
+ unsubscribe()
10
+ if (this.numSubscribers === 0) this.stream.unsubscribeAll()
11
+ }
12
+ }
13
+ }
14
+
5
15
 
6
16
  /**
7
17
  * Add Electric-backed reactive models to an Express-X client.
8
18
  *
9
19
  * Usage:
10
20
  * electricClientPlugin(app)
11
- * const todo = app.createElectricModel('todos')
21
+ * const todo = app.createElectricModel('todos') // primary key is provided by client
22
+ * const todo = app.createElectricModel('todos', { idGeneration: 'server' }) // primary key is server-generated
12
23
  * todo.getObservable({ completed: false }).subscribe(...)
13
24
  * const completedTodos = await todo.findMany({ completed: false })
14
25
  */
15
26
  export function electricClientPlugin(app, options = {}) {
16
27
  const shapePath = options.shapePath ?? '/electric/v1/shape'
17
28
  const ShapeStreamClass = options.ShapeStream ?? ShapeStream
18
- const ShapeClass = options.Shape ?? Shape
29
+ const ShapeClass = options.Shape ?? DisposableShape
19
30
  const ObservableClass = options.Observable ?? Observable
20
31
 
21
32
  function createElectricModel(modelName, modelOptions = {}) {
@@ -23,6 +34,10 @@ export function electricClientPlugin(app, options = {}) {
23
34
  const service = app.service(modelName)
24
35
  const url = modelOptions.url ?? modelPath(shapePath, modelName)
25
36
  const streamOptions = modelOptions.streamOptions ?? {}
37
+ const idGeneration = modelOptions.idGeneration ?? 'client'
38
+ if (!['client', 'server'].includes(idGeneration)) {
39
+ throw new TypeError("idGeneration must be 'client' or 'server'")
40
+ }
26
41
 
27
42
  function getObservable(where = {}) {
28
43
  // Validate eagerly
@@ -62,17 +77,18 @@ export function electricClientPlugin(app, options = {}) {
62
77
 
63
78
  async function create(data) {
64
79
  assertPlainObject(data, 'mutation data')
80
+ if (idGeneration === 'server') return service.create(data)
65
81
  const uid = globalThis.crypto?.randomUUID?.()
66
82
  if (!uid) throw new Error('crypto.randomUUID() is required')
67
83
  return service.create(uid, data)
68
84
  }
69
85
 
70
- async function update(uid, data) {
71
- return service.update(uid, data)
86
+ async function update(id, data) {
87
+ return service.update(id, data)
72
88
  }
73
89
 
74
- async function remove(uid) {
75
- return service.delete(uid)
90
+ async function remove(id) {
91
+ return service.delete(id)
76
92
  }
77
93
 
78
94
  return { getObservable, findMany, create, update, remove }
@@ -26,71 +26,86 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
26
26
  }
27
27
  }
28
28
 
29
+ // for each model 'name', there is a service 'name' with methods 'findUnique', 'findMany', 'create', 'update', 'delete'
29
30
  for (const model of configuredModels) {
30
31
  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 }
32
+
33
+ findUnique: async function(where) {
34
+ await authorize(this, model.name, 'findUnique', [where])
35
+ const filter = buildWhere(where)
36
+ const result = await db.query(`SELECT * FROM ${model.quotedTable} WHERE ${filter.sql} LIMIT 1`, filter.values)
37
+ return result.rows[0] ?? null
38
+ },
39
+
40
+ findMany: async function(where, queryOptions = {}) {
41
+ await authorize(this, model.name, 'findMany', [where, queryOptions])
42
+ const filter = buildWhere(where)
43
+ let sql = `SELECT * FROM ${model.quotedTable} WHERE ${filter.sql}`
44
+ const values = [...filter.values]
45
+ if (queryOptions.limit != null) {
46
+ if (!Number.isInteger(queryOptions.limit) || queryOptions.limit < 1) {
47
+ throw new TypeError('limit must be a positive integer')
48
+ }
49
+ values.push(queryOptions.limit)
50
+ sql += ` LIMIT $${values.length}`
51
+ }
52
+ return (await db.query(sql, values)).rows
53
+ },
54
+
55
+ // create(data): the primary key is server-generated
56
+ // create(uid, data): uid (the primary key) is provided by the client
57
+ create: async function(idOrData, data) {
58
+ const hasClientId = data !== undefined
59
+ const mutationData = hasClientId ? data : idOrData
60
+ await authorize(this, model.name, 'create', hasClientId ? [idOrData, mutationData] : [mutationData])
61
+ assertPlainObject(mutationData, 'mutation data')
62
+ const safeData = hasClientId
63
+ ? { ...mutationData, [model.primaryKey]: idOrData }
64
+ : { ...mutationData }
57
65
  const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
58
66
  const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
59
67
  const values = entries.map(([, value]) => value)
60
68
  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
69
  return withTransaction(db, async client => {
70
+ if (entries.length === 0) {
71
+ const result = await client.query(`INSERT INTO ${model.quotedTable} DEFAULT VALUES RETURNING *`)
72
+ return result.rows[0]
73
+ }
74
+ const conflictAction = hasClientId
75
+ ? ' ON CONFLICT (' + model.quotedPrimaryKey + ') DO UPDATE SET '
76
+ + (entries.some(([column]) => column !== model.primaryKey)
77
+ ? entries
78
+ .filter(([column]) => column !== model.primaryKey)
79
+ .map(([column]) => `${quoteIdentifier(column, 'data column')} = EXCLUDED.${quoteIdentifier(column, 'data column')}`)
80
+ .join(', ')
81
+ : `${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}`)
82
+ : ''
67
83
  const result = await client.query(
68
- `INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')}) `
69
- + `ON CONFLICT (${model.quotedPrimaryKey}) ${conflictAction}`
70
- + ' RETURNING *',
84
+ `INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')})`
85
+ + conflictAction + ' RETURNING *',
71
86
  values,
72
87
  )
73
88
  return result.rows[0]
74
89
  })
75
90
  },
76
91
 
77
- update: async function(uid, data) {
78
- await authorize(this, model.name, 'update', [uid, data])
92
+ update: async function(id, data) {
93
+ await authorize(this, model.name, 'update', [id, data])
79
94
  const set = buildSet(data)
80
95
  return withTransaction(db, async client => {
81
96
  const result = await client.query(
82
97
  `UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
83
- [...set.values, uid],
98
+ [...set.values, id],
84
99
  )
85
100
  return result.rows[0]
86
101
  })
87
102
  },
88
103
 
89
- delete: async function(uid) {
90
- await authorize(this, model.name, 'delete', [uid])
104
+ delete: async function(id) {
105
+ await authorize(this, model.name, 'delete', [id])
91
106
  return withTransaction(db, async client => {
92
107
  const result = await client.query(
93
- `DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [uid],
108
+ `DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [id],
94
109
  )
95
110
  return result.rows[0]
96
111
  })
@@ -160,8 +175,7 @@ function normalizeModels(models) {
160
175
  }
161
176
 
162
177
  function assertPlainObject(value, label) {
163
- if (!value || typeof value !== 'object' || Array.isArray(value)
164
- || Object.prototype.toString.call(value) !== '[object Object]') {
178
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
165
179
  throw new TypeError(`${label} must be a plain object`)
166
180
  }
167
181
  }
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { effectScope, isRef } from 'vue'
4
4
 
5
- import { electricClientPlugin, whereToElectricParams } from '../src/electric-client-plugin.mjs'
5
+ import {
6
+ DisposableShape,
7
+ electricClientPlugin,
8
+ whereToElectricParams,
9
+ } from '../src/electric-client-plugin.mjs'
6
10
 
7
11
  class FakeStream {
8
12
  static instances = []
@@ -36,6 +40,19 @@ class EmptyShape {
36
40
  }
37
41
  }
38
42
 
43
+ test('DisposableShape tears down its stream with its last subscriber', () => {
44
+ const stream = {
45
+ subscribe() { return () => {} },
46
+ unsubscribeAll() { this.unsubscribed = true },
47
+ }
48
+ const shape = new DisposableShape(stream)
49
+ const unsubscribe = shape.subscribe(() => {})
50
+
51
+ unsubscribe()
52
+
53
+ assert.equal(stream.unsubscribed, true)
54
+ })
55
+
39
56
  test('translates where objects into parameterized Electric filters', () => {
40
57
  assert.deepEqual(whereToElectricParams({ completed: false, priority: { gte: 2, lt: 5 }, owner: null }), {
41
58
  where: '"completed" = $1 AND "priority" >= $2 AND "priority" < $3 AND "owner" IS NULL',
@@ -143,3 +160,25 @@ test('model mutations retain the simple Express-X API', async () => {
143
160
  await todo.remove(created.uid)
144
161
  assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
145
162
  })
163
+
164
+ test('model creation supports server-generated IDs', async () => {
165
+ const calls = []
166
+ const service = {
167
+ async create(...args) { calls.push(args); return { id: 42, ...args[0] } },
168
+ }
169
+ const app = { service: () => service }
170
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
171
+ const todo = app.createElectricModel('todos', { idGeneration: 'server' })
172
+
173
+ assert.deepEqual(await todo.create({ title: 'Test' }), { id: 42, title: 'Test' })
174
+ assert.deepEqual(calls, [[{ title: 'Test' }]])
175
+ })
176
+
177
+ test('rejects an unsupported ID generation strategy', () => {
178
+ const app = { service: () => ({}) }
179
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
180
+ assert.throws(
181
+ () => app.createElectricModel('todos', { idGeneration: 'database-ish' }),
182
+ /idGeneration/,
183
+ )
184
+ })
@@ -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(