@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 +45 -18
- package/package.json +1 -1
- package/src/electric-client-plugin.mjs +14 -12
- package/test/electric-client-plugin.test.mjs +53 -2
package/README.md
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
1
|
-
# express-x-
|
|
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-
|
|
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
|
-
##
|
|
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
|
-
|
|
72
|
+
### Client
|
|
53
73
|
|
|
54
74
|
Install the optional client dependencies in the browser application:
|
|
55
75
|
|
|
56
76
|
```sh
|
|
57
|
-
npm install @
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
|
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
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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,
|
|
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('
|
|
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.
|
|
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 = {
|