@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 +12 -5
- package/package.json +1 -1
- package/src/electric-client-plugin.mjs +20 -4
- package/test/electric-client-plugin.test.mjs +66 -0
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,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
|
|
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 = {
|