@jcbuisson/express-x-plugins 4.0.2 → 4.0.4

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
@@ -42,7 +42,7 @@ This registers one Express-X service per model with the familiar API:
42
42
  - `updateWithMeta(uid, data, updatedAt)`
43
43
  - `deleteWithMeta(uid, deletedAt)`
44
44
 
45
- Synchronized reads are provided client-side by `getObservable(where)` below;
45
+ Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
46
46
  one-shot server reads would bypass Electric and are intentionally omitted.
47
47
 
48
48
  Mutation results remain `[value, meta]` tuples for compatibility. `meta.txid`
@@ -54,12 +54,9 @@ wait for the matching transaction in its Shape stream.
54
54
  Install the optional client dependencies in the browser application:
55
55
 
56
56
  ```sh
57
- npm install @jcbuisson/express-x-electric @electric-sql/client rxjs
57
+ npm install @jcbuisson/express-x-electric @electric-sql/client rxjs vue
58
58
  ```
59
59
 
60
- Configure the client plugin and use the same `getObservable(where)` style as
61
- `express-x-client`'s offline model:
62
-
63
60
  ```js
64
61
  import { electricClientPlugin } from '@jcbuisson/express-x-electric/client'
65
62
 
@@ -68,6 +65,10 @@ app.configure(electricClientPlugin, {
68
65
  })
69
66
 
70
67
  const todo = app.createElectricModel('todos')
68
+
69
+ const incompleteTodos = await todo.findMany({ completed: false })
70
+ const selectedTodo = await todo.findUnique({ uid })
71
+
71
72
  const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
72
73
  console.log(rows)
73
74
  })
@@ -80,6 +81,12 @@ await todo.remove(uid)
80
81
  subscription.unsubscribe()
81
82
  ```
82
83
 
84
+ `findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
85
+
86
+ `findUnique(where)` resolves with the first matching row, or `null` when there is no match.
87
+ Both unsubscribe after their first emission; if called within a Vue scope, they also unsubscribe
88
+ if that scope is disposed before a result arrives.
89
+
83
90
  Object filters use parameterized Electric Shape predicates. Exact values,
84
91
  `null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
85
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
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 { Observable } from 'rxjs'
2
+ import { firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'
3
3
  import { getCurrentScope, onScopeDispose, ref } from 'vue'
4
4
 
5
5
 
@@ -24,7 +24,7 @@ export function electricClientPlugin(app, options = {}) {
24
24
  const streamOptions = modelOptions.streamOptions ?? {}
25
25
 
26
26
  function getObservable(where = {}) {
27
- // Validate eagerly, as getObservable in express-x-client does.
27
+ // Validate eagerly
28
28
  const filterParams = whereToElectricParams(where)
29
29
  return new ObservableClass(subscriber => {
30
30
  const stream = new ShapeStreamClass({
@@ -42,7 +42,7 @@ export function electricClientPlugin(app, options = {}) {
42
42
  subscriber.next(current)
43
43
  })
44
44
  // Shape exposes errors as state. ShapeStream retries transient failures;
45
- // callers keep one observable subscription across reconnects.
45
+ // callers keep one observable subscription across reconnects
46
46
  return () => unsubscribe()
47
47
  })
48
48
  }
@@ -54,6 +54,22 @@ export function electricClientPlugin(app, options = {}) {
54
54
  return value
55
55
  }
56
56
 
57
+ function findMany(where = {}) {
58
+ const observable = getObservable(where)
59
+ if (!getCurrentScope()) return firstValueFrom(observable)
60
+
61
+ const scopeDisposed = new Subject()
62
+ onScopeDispose(() => {
63
+ scopeDisposed.next()
64
+ scopeDisposed.complete()
65
+ })
66
+ return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
67
+ }
68
+
69
+ function findUnique(where = {}) {
70
+ return findMany(where).then(rows => rows[0] ?? null)
71
+ }
72
+
57
73
  async function create(data) {
58
74
  assertPlainObject(data, 'mutation data')
59
75
  const uid = globalThis.crypto?.randomUUID?.()
@@ -73,7 +89,7 @@ export function electricClientPlugin(app, options = {}) {
73
89
  return value
74
90
  }
75
91
 
76
- return { getObservable, getVueRef, create, update, remove }
92
+ return { getObservable, getVueRef, findMany, findUnique, create, update, remove }
77
93
  }
78
94
 
79
95
  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,6 +77,56 @@ 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
 
80
+ test('findMany resolves with the first Shape rows and cleans up its subscription', async () => {
81
+ const app = { service: () => ({}) }
82
+ electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
83
+ const todo = app.createElectricModel('todos')
84
+
85
+ const rows = await todo.findMany({ completed: false })
86
+
87
+ assert.deepEqual(rows, [{ uid: 'one', completed: false }])
88
+ const stream = FakeStream.instances.at(-1)
89
+ assert.deepEqual(stream.options.params, {
90
+ where: '"completed" = $1', params: ['false'],
91
+ })
92
+ assert.equal(stream.shape.unsubscribed, true)
93
+ })
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
+
64
130
  test('model mutations retain the simple Express-X API', async () => {
65
131
  const calls = []
66
132
  const service = {