@jcbuisson/express-x-plugins 4.0.7 → 4.0.9

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
@@ -2,7 +2,21 @@
2
2
  # express-x-plugins
3
3
 
4
4
 
5
- IMPORTANT: set lc_messages=C for the PostgreSQL chris role so Electric receives recognizable English errors.
5
+ IMPORTANT FOR ELECTRIC
6
+
7
+ # set lc_messages=C for the PostgreSQL role so Electric receives recognizable English errors
8
+ ALTER ROLE chris SET lc_messages = 'C';
9
+
10
+ # ALLOW POSTGRES LOGICAL REPLICATION
11
+ ALTER SYSTEM SET wal_level = 'logical';
12
+ ALTER SYSTEM SET max_replication_slots = 10;
13
+ ALTER SYSTEM SET max_wal_senders = 10;
14
+ ALTER ROLE chris WITH REPLICATION;
15
+
16
+ (restart postgres: `sudo systemctl restart postgresql`)
17
+
18
+ ## Use HTTP2
19
+ nginx: `listen 443 ssl http2;`
6
20
 
7
21
 
8
22
  Currently includes:
@@ -63,13 +77,16 @@ app.configure(electricOfflinePlugin, db, [
63
77
 
64
78
  This registers one Express-X service per model with the familiar API:
65
79
 
80
+ - `findUnique(where)`
81
+ - `findMany(where, queryOptions)`
66
82
  - `create(id, data)` for a client-generated primary key
67
83
  - `create(data)` for a database-generated primary key
68
84
  - `update(id, data)`
69
85
  - `delete(id)`
70
86
 
71
- Synchronized reads are provided client-side by `findMany(where)`, `findUnique(where)`, and `getObservable(where)` below;
72
- one-shot server reads would bypass Electric and are intentionally omitted.
87
+ The client model provides synchronized reads through `findMany(where)` and
88
+ `getObservable(where)`. The service also exposes direct one-shot server reads
89
+ when synchronization is not required.
73
90
 
74
91
  Mutation methods return the created, updated, or deleted row directly.
75
92
 
@@ -97,7 +114,6 @@ const numberedTodo = app.createElectricModel('numberedTodos', {
97
114
  })
98
115
 
99
116
  const incompleteTodos = await todo.findMany({ completed: false })
100
- const selectedTodo = await todo.findUnique({ uid })
101
117
 
102
118
  const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
103
119
  console.log(rows)
@@ -116,9 +132,8 @@ subscription.unsubscribe()
116
132
 
117
133
  `findMany(where)` resolves with all matching rows from the first synchronized Shape emission.
118
134
 
119
- `findUnique(where)` resolves with the first matching row, or `null` when there is no match.
120
- Both unsubscribe after their first emission; if called within a Vue scope, they also unsubscribe
121
- if that scope is disposed before a result arrives.
135
+ It unsubscribes after the first emission; if called within a Vue scope, it also
136
+ unsubscribes if that scope is disposed before a result arrives.
122
137
 
123
138
  Object filters use parameterized Electric Shape predicates. Exact values,
124
139
  `null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
@@ -156,8 +171,16 @@ services:
156
171
  environment:
157
172
  DATABASE_URL: postgresql://user:password@host.docker.internal:5432/mydb
158
173
  ELECTRIC_INSECURE: "true"
174
+ ELECTRIC_STORAGE: FAST_FILE
175
+ ELECTRIC_PERSISTENT_STATE: FILE
176
+ ELECTRIC_STORAGE_DIR: /var/lib/electric
159
177
  ports:
160
178
  - "3001:3000"
179
+ volumes:
180
+ - electric_mydb_data:/var/lib/electric
181
+
182
+ volumes:
183
+ electric_mydb_data:
161
184
  ```
162
185
 
163
186
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.7",
3
+ "version": "4.0.9",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -2,20 +2,20 @@ import { Shape, ShapeStream } from '@electric-sql/client'
2
2
  import { firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'
3
3
  import { getCurrentScope, onScopeDispose, ref } from 'vue'
4
4
 
5
-
6
5
  /**
7
6
  * Add Electric-backed reactive models to an Express-X client.
8
7
  *
9
8
  * Usage:
10
9
  * electricClientPlugin(app)
11
- * const todo = app.createElectricModel('todos')
10
+ * const todo = app.createElectricModel('todos') // primary key is provided by client
11
+ * const todo = app.createElectricModel('todos', { idGeneration: 'server' }) // primary key is server-generated
12
12
  * todo.getObservable({ completed: false }).subscribe(...)
13
13
  * const completedTodos = await todo.findMany({ completed: false })
14
14
  */
15
15
  export function electricClientPlugin(app, options = {}) {
16
16
  const shapePath = options.shapePath ?? '/electric/v1/shape'
17
17
  const ShapeStreamClass = options.ShapeStream ?? ShapeStream
18
- const ShapeClass = options.Shape ?? Shape
18
+ const ShapeClass = options.Shape ?? DisposableShape
19
19
  const ObservableClass = options.Observable ?? Observable
20
20
 
21
21
  function createElectricModel(modelName, modelOptions = {}) {
@@ -89,6 +89,16 @@ export function electricClientPlugin(app, options = {}) {
89
89
 
90
90
  ////////////////////// UTILITIES //////////////////////
91
91
 
92
+ export class DisposableShape extends Shape {
93
+ subscribe(callback) {
94
+ const unsubscribe = super.subscribe(callback)
95
+ return () => {
96
+ unsubscribe()
97
+ if (this.numSubscribers === 0) this.stream.unsubscribeAll()
98
+ }
99
+ }
100
+ }
101
+
92
102
  const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
93
103
  const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
94
104
 
@@ -26,8 +26,10 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
26
26
  }
27
27
  }
28
28
 
29
+ // for each model 'name', there is a service 'name' with methods 'findUnique', 'findMany', 'create', 'update', 'delete'
29
30
  for (const model of configuredModels) {
30
31
  app.createService(model.name, {
32
+
31
33
  findUnique: async function(where) {
32
34
  await authorize(this, model.name, 'findUnique', [where])
33
35
  const filter = buildWhere(where)
@@ -50,7 +52,8 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
50
52
  return (await db.query(sql, values)).rows
51
53
  },
52
54
 
53
- // create(data) (primary key is server-generated) or create(uid, data) (uid is the primary key, client-generated)
55
+ // create(data): the primary key is server-generated
56
+ // create(uid, data): uid (the primary key) is provided by the client
54
57
  create: async function(idOrData, data) {
55
58
  const hasClientId = data !== undefined
56
59
  const mutationData = hasClientId ? data : idOrData
@@ -1,3 +1,5 @@
1
+ import { useSessionStorage } from '@vueuse/core'
2
+
1
3
  /**
2
4
  * Enrich `app` with listeners handling socket data transfer on page reload
3
5
  *
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { effectScope, isRef } from 'vue'
4
4
 
5
- import { electricClientPlugin, whereToElectricParams } from '../src/electric-client-plugin.mjs'
5
+ import {
6
+ DisposableShape,
7
+ electricClientPlugin,
8
+ whereToElectricParams,
9
+ } from '../src/electric-client-plugin.mjs'
6
10
 
7
11
  class FakeStream {
8
12
  static instances = []
@@ -36,6 +40,19 @@ class EmptyShape {
36
40
  }
37
41
  }
38
42
 
43
+ test('DisposableShape tears down its stream with its last subscriber', () => {
44
+ const stream = {
45
+ subscribe() { return () => {} },
46
+ unsubscribeAll() { this.unsubscribed = true },
47
+ }
48
+ const shape = new DisposableShape(stream)
49
+ const unsubscribe = shape.subscribe(() => {})
50
+
51
+ unsubscribe()
52
+
53
+ assert.equal(stream.unsubscribed, true)
54
+ })
55
+
39
56
  test('translates where objects into parameterized Electric filters', () => {
40
57
  assert.deepEqual(whereToElectricParams({ completed: false, priority: { gte: 2, lt: 5 }, owner: null }), {
41
58
  where: '"completed" = $1 AND "priority" >= $2 AND "priority" < $3 AND "owner" IS NULL',