@jcbuisson/express-x-plugins 4.0.4 → 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
@@ -1,23 +1,43 @@
1
- # express-x-electric
1
+ # express-x-plugins
2
+
3
+ Currently includes:
4
+
5
+ - a plugin which preserves room membership and socket data across page reloads
6
+ - a plugin integrating ElectricSQL sync engine into express-x, which greatly simplifies relational database
7
+ access and provides powerful local-first features
2
8
 
3
- The smallest useful ElectricSQL integration for Express-X. Express-X handles
4
- authorized PostgreSQL mutations; Electric's Shape API streams those changes to
5
- clients: Electric is the sync engine.
6
9
 
7
10
  ## Install
8
11
 
9
12
  ```sh
10
- npm install @jcbuisson/express-x-electric pg
13
+ npm install @jcbuisson/express-x-plugins
11
14
  ```
12
15
 
13
- This server-only installation does not install the browser Electric client or RxJS.
14
16
 
15
- ## Server
17
+ ## Reload plugin
18
+
19
+ ### Server
20
+
21
+ ```js
22
+ import { reloadPlugin } from '@jcbuisson/express-x-plugins/reload-server'
23
+ ```
24
+
25
+
26
+ ## Local-first Postgres plugin
27
+
28
+ The smallest useful ElectricSQL integration for Express-X. Express-X handles authorized PostgreSQL mutations;
29
+ Electric's Shape API streams those changes to clients: Electric is the sync engine.
30
+
31
+ ### Server
32
+
33
+ ```sh
34
+ npm install pg
35
+ ```
16
36
 
17
37
  ```js
18
38
  import { Pool } from 'pg'
19
- import { expressX } from '@jcbuisson/express-x'
20
- import { electricOfflinePlugin } from '@jcbuisson/express-x-electric'
39
+ import { expressX } from '@jcbuisson/express-x/server'
40
+ import { electricOfflinePlugin } from '@jcbuisson/express-x-plugins/electric-server'
21
41
 
22
42
  const app = expressX()
23
43
  const db = new Pool({ connectionString: process.env.DATABASE_URL })
@@ -38,27 +58,25 @@ app.configure(electricOfflinePlugin, db, [
38
58
 
39
59
  This registers one Express-X service per model with the familiar API:
40
60
 
41
- - `createWithMeta(uid, data, createdAt)`
42
- - `updateWithMeta(uid, data, updatedAt)`
43
- - `deleteWithMeta(uid, deletedAt)`
61
+ - `create(uid, data)`
62
+ - `update(uid, data)`
63
+ - `delete(uid)`
44
64
 
45
65
  Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
46
66
  one-shot server reads would bypass Electric and are intentionally omitted.
47
67
 
48
- Mutation results remain `[value, meta]` tuples for compatibility. `meta.txid`
49
- contains `pg_current_xact_id()` and can be passed to an Electric-aware client to
50
- wait for the matching transaction in its Shape stream.
68
+ Mutation methods return the created, updated, or deleted row directly.
51
69
 
52
- ## Client Shape
70
+ ### Client
53
71
 
54
72
  Install the optional client dependencies in the browser application:
55
73
 
56
74
  ```sh
57
- npm install @jcbuisson/express-x-electric @electric-sql/client rxjs vue
75
+ npm install @electric-sql/client rxjs
58
76
  ```
59
77
 
60
78
  ```js
61
- import { electricClientPlugin } from '@jcbuisson/express-x-electric/client'
79
+ import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
62
80
 
63
81
  app.configure(electricClientPlugin, {
64
82
  shapePath: '/electric/v1/shape',
@@ -93,7 +111,7 @@ Object filters use parameterized Electric Shape predicates. Exact values,
93
111
  All Electric cursor parameters are forwarded. The client cannot override the
94
112
  configured table, and Electric source credentials stay server-side.
95
113
 
96
- ## Model configuration
114
+ ### Model configuration
97
115
 
98
116
  Models may be strings (table, service name, and default `uid` key) or objects:
99
117
 
@@ -108,7 +126,7 @@ Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
108
126
  `query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
109
127
  transaction ID are captured in the same transaction.
110
128
 
111
- ## Run Electric from Docker
129
+ ### Run Electric from Docker
112
130
 
113
131
  A local install of the Electric sync engine requires Elixir and Erlang; it is simpler to use a pre-built Docker image.
114
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.4",
3
+ "version": "4.0.6",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -10,6 +10,7 @@ import { getCurrentScope, onScopeDispose, ref } from 'vue'
10
10
  * electricClientPlugin(app)
11
11
  * const todo = app.createElectricModel('todos')
12
12
  * todo.getObservable({ completed: false }).subscribe(...)
13
+ * const completedTodos = await todo.findMany({ completed: false })
13
14
  */
14
15
  export function electricClientPlugin(app, options = {}) {
15
16
  const shapePath = options.shapePath ?? '/electric/v1/shape'
@@ -47,13 +48,6 @@ export function electricClientPlugin(app, options = {}) {
47
48
  })
48
49
  }
49
50
 
50
- function getVueRef(where = {}) {
51
- const value = ref([])
52
- const subscription = getObservable(where).subscribe(rows => { value.value = rows })
53
- if (getCurrentScope()) onScopeDispose(() => subscription.unsubscribe())
54
- return value
55
- }
56
-
57
51
  function findMany(where = {}) {
58
52
  const observable = getObservable(where)
59
53
  if (!getCurrentScope()) return firstValueFrom(observable)
@@ -66,30 +60,22 @@ export function electricClientPlugin(app, options = {}) {
66
60
  return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
67
61
  }
68
62
 
69
- function findUnique(where = {}) {
70
- return findMany(where).then(rows => rows[0] ?? null)
71
- }
72
-
73
63
  async function create(data) {
74
64
  assertPlainObject(data, 'mutation data')
75
65
  const uid = globalThis.crypto?.randomUUID?.()
76
66
  if (!uid) throw new Error('crypto.randomUUID() is required')
77
- const now = new Date().toISOString()
78
- const [value] = await service.createWithMeta(uid, data, now)
79
- return value
67
+ return service.create(uid, data)
80
68
  }
81
69
 
82
70
  async function update(uid, data) {
83
- const [value] = await service.updateWithMeta(uid, data, new Date().toISOString())
84
- return value
71
+ return service.update(uid, data)
85
72
  }
86
73
 
87
74
  async function remove(uid) {
88
- const [value] = await service.deleteWithMeta(uid, new Date().toISOString())
89
- return value
75
+ return service.delete(uid)
90
76
  }
91
77
 
92
- return { getObservable, getVueRef, findMany, findUnique, create, update, remove }
78
+ return { getObservable, findMany, create, update, remove }
93
79
  }
94
80
 
95
81
  return Object.assign(app, { createElectricModel })
@@ -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
  })