@jcbuisson/express-x-plugins 4.0.3 → 4.0.5

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 })
@@ -42,32 +62,33 @@ This registers one Express-X service per model with the familiar API:
42
62
  - `updateWithMeta(uid, data, updatedAt)`
43
63
  - `deleteWithMeta(uid, deletedAt)`
44
64
 
45
- Synchronized reads are provided client-side by `getObservable(where)` below;
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
68
  Mutation results remain `[value, meta]` tuples for compatibility. `meta.txid`
49
69
  contains `pg_current_xact_id()` and can be passed to an Electric-aware client to
50
70
  wait for the matching transaction in its Shape stream.
51
71
 
52
- ## Client Shape
72
+ ### Client
53
73
 
54
74
  Install the optional client dependencies in the browser application:
55
75
 
56
76
  ```sh
57
- npm install @jcbuisson/express-x-electric @electric-sql/client rxjs
77
+ npm install @electric-sql/client rxjs
58
78
  ```
59
79
 
60
- Configure the client plugin and use the same `getObservable(where)` style as
61
- `express-x-client`'s offline model:
62
-
63
80
  ```js
64
- import { electricClientPlugin } from '@jcbuisson/express-x-electric/client'
81
+ import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
65
82
 
66
83
  app.configure(electricClientPlugin, {
67
84
  shapePath: '/electric/v1/shape',
68
85
  })
69
86
 
70
87
  const todo = app.createElectricModel('todos')
88
+
89
+ const incompleteTodos = await todo.findMany({ completed: false })
90
+ const selectedTodo = await todo.findUnique({ uid })
91
+
71
92
  const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
72
93
  console.log(rows)
73
94
  })
@@ -80,13 +101,19 @@ await todo.remove(uid)
80
101
  subscription.unsubscribe()
81
102
  ```
82
103
 
104
+ `findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
105
+
106
+ `findUnique(where)` resolves with the first matching row, or `null` when there is no match.
107
+ Both unsubscribe after their first emission; if called within a Vue scope, they also unsubscribe
108
+ if that scope is disposed before a result arrives.
109
+
83
110
  Object filters use parameterized Electric Shape predicates. Exact values,
84
111
  `null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
85
112
 
86
113
  All Electric cursor parameters are forwarded. The client cannot override the
87
114
  configured table, and Electric source credentials stay server-side.
88
115
 
89
- ## Model configuration
116
+ ### Model configuration
90
117
 
91
118
  Models may be strings (table, service name, and default `uid` key) or objects:
92
119
 
@@ -101,7 +128,7 @@ Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
101
128
  `query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
102
129
  transaction ID are captured in the same transaction.
103
130
 
104
- ## Run Electric from Docker
131
+ ### Run Electric from Docker
105
132
 
106
133
  A local install of the Electric sync engine requires Elixir and Erlang; it is simpler to use a pre-built Docker image.
107
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -1,5 +1,5 @@
1
1
  import { Shape, ShapeStream } from '@electric-sql/client'
2
- import { firstValueFrom, Observable } from 'rxjs'
2
+ import { firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'
3
3
  import { getCurrentScope, onScopeDispose, ref } from 'vue'
4
4
 
5
5
 
@@ -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'
@@ -24,7 +25,7 @@ export function electricClientPlugin(app, options = {}) {
24
25
  const streamOptions = modelOptions.streamOptions ?? {}
25
26
 
26
27
  function getObservable(where = {}) {
27
- // Validate eagerly, as getObservable in express-x-client does.
28
+ // Validate eagerly
28
29
  const filterParams = whereToElectricParams(where)
29
30
  return new ObservableClass(subscriber => {
30
31
  const stream = new ShapeStreamClass({
@@ -42,20 +43,21 @@ export function electricClientPlugin(app, options = {}) {
42
43
  subscriber.next(current)
43
44
  })
44
45
  // Shape exposes errors as state. ShapeStream retries transient failures;
45
- // callers keep one observable subscription across reconnects.
46
+ // callers keep one observable subscription across reconnects
46
47
  return () => unsubscribe()
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
- }
51
+ function findMany(where = {}) {
52
+ const observable = getObservable(where)
53
+ if (!getCurrentScope()) return firstValueFrom(observable)
56
54
 
57
- function firstResult(where = {}) {
58
- return firstValueFrom(getObservable(where))
55
+ const scopeDisposed = new Subject()
56
+ onScopeDispose(() => {
57
+ scopeDisposed.next()
58
+ scopeDisposed.complete()
59
+ })
60
+ return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
59
61
  }
60
62
 
61
63
  async function create(data) {
@@ -77,7 +79,7 @@ export function electricClientPlugin(app, options = {}) {
77
79
  return value
78
80
  }
79
81
 
80
- return { getObservable, getVueRef, firstResult, create, update, remove }
82
+ return { getObservable, findMany, create, update, remove }
81
83
  }
82
84
 
83
85
  return Object.assign(app, { createElectricModel })
@@ -20,6 +20,22 @@ class FakeShape {
20
20
  }
21
21
  }
22
22
 
23
+ class DeferredShape {
24
+ constructor(stream) { this.stream = stream; stream.shape = this }
25
+ subscribe(callback) {
26
+ this.callback = callback
27
+ return () => { this.unsubscribed = true }
28
+ }
29
+ }
30
+
31
+ class EmptyShape {
32
+ constructor(stream) { this.stream = stream; stream.shape = this }
33
+ subscribe(callback) {
34
+ callback({ rows: [] })
35
+ return () => { this.unsubscribed = true }
36
+ }
37
+ }
38
+
23
39
  test('translates where objects into parameterized Electric filters', () => {
24
40
  assert.deepEqual(whereToElectricParams({ completed: false, priority: { gte: 2, lt: 5 }, owner: null }), {
25
41
  where: '"completed" = $1 AND "priority" >= $2 AND "priority" < $3 AND "owner" IS NULL',
@@ -61,12 +77,12 @@ test('getVueRef returns Shape rows in a Vue ref and cleans up with its scope', (
61
77
  assert.equal(shape.unsubscribed, true)
62
78
  })
63
79
 
64
- test('firstResult resolves with the first Shape rows and cleans up its subscription', async () => {
80
+ test('findMany resolves with the first Shape rows and cleans up its subscription', async () => {
65
81
  const app = { service: () => ({}) }
66
82
  electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
67
83
  const todo = app.createElectricModel('todos')
68
84
 
69
- const rows = await todo.firstResult({ completed: false })
85
+ const rows = await todo.findMany({ completed: false })
70
86
 
71
87
  assert.deepEqual(rows, [{ uid: 'one', completed: false }])
72
88
  const stream = FakeStream.instances.at(-1)
@@ -76,6 +92,41 @@ test('firstResult resolves with the first Shape rows and cleans up its subscript
76
92
  assert.equal(stream.shape.unsubscribed, true)
77
93
  })
78
94
 
95
+ test('findMany unsubscribes when its Vue scope is disposed before a result', async () => {
96
+ const app = { service: () => ({}) }
97
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: DeferredShape })
98
+ const todo = app.createElectricModel('todos')
99
+ const scope = effectScope()
100
+ let result
101
+
102
+ scope.run(() => { result = todo.findMany({ completed: false }) })
103
+ const rejection = assert.rejects(result, error => error.name === 'EmptyError')
104
+ const shape = FakeStream.instances.at(-1).shape
105
+ assert.equal(shape.unsubscribed, undefined)
106
+
107
+ scope.stop()
108
+
109
+ await rejection
110
+ assert.equal(shape.unsubscribed, true)
111
+ })
112
+
113
+ test('findUnique resolves with the first matching row or null', async () => {
114
+ const app = { service: () => ({}) }
115
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
116
+ const todo = app.createElectricModel('todos')
117
+
118
+ assert.deepEqual(
119
+ await todo.findUnique({ uid: 'one' }),
120
+ { uid: 'one', completed: false },
121
+ )
122
+ assert.equal(FakeStream.instances.at(-1).shape.unsubscribed, true)
123
+
124
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: EmptyShape })
125
+ const emptyTodo = app.createElectricModel('todos')
126
+ assert.equal(await emptyTodo.findUnique({ uid: 'missing' }), null)
127
+ assert.equal(FakeStream.instances.at(-1).shape.unsubscribed, true)
128
+ })
129
+
79
130
  test('model mutations retain the simple Express-X API', async () => {
80
131
  const calls = []
81
132
  const service = {