@jcbuisson/express-x-plugins 4.0.12 → 4.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-plugins",
3
- "version": "4.0.12",
3
+ "version": "4.1.0",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -1,16 +1,17 @@
1
1
  import { Shape, ShapeStream } from '@electric-sql/client'
2
- import { firstValueFrom, Observable, Subject, takeUntil } from 'rxjs'
3
- import { getCurrentScope, onScopeDispose, ref } from 'vue'
2
+ import { Observable } from 'rxjs'
3
+ // import { firstValueFrom, Subject, takeUntil } from 'rxjs'
4
+ // import { getCurrentScope, onScopeDispose } from 'vue'
4
5
 
5
6
  /**
6
7
  * Add Electric-backed reactive models to an Express-X client.
7
8
  *
8
9
  * Usage:
9
10
  * electricClientPlugin(app)
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
- * todo.getObservable({ completed: false }).subscribe(...)
13
- * const completedTodos = await todo.findMany({ completed: false })
11
+ * const todoModel = app.createElectricModel('todos') // primary key is automatically provided by server (auto-increment)
12
+ * const todoMilk = await todoModel.create({ label: "Buy milk" })
13
+ * todoMilk.update({ completed: true })
14
+ * todoModel.getObservable({ completed: false }).subscribe(...) // subscribe to an observable for todos with completed===false
14
15
  */
15
16
  export function electricClientPlugin(app, options = {}) {
16
17
  const shapePath = options.shapePath ?? '/electric/v1/shape'
@@ -23,10 +24,10 @@ export function electricClientPlugin(app, options = {}) {
23
24
  const service = app.service(modelName)
24
25
  const url = modelOptions.url ?? modelPath(shapePath, modelName)
25
26
  const streamOptions = modelOptions.streamOptions ?? {}
26
- const idGeneration = modelOptions.idGeneration ?? 'client'
27
- if (!['client', 'server'].includes(idGeneration)) {
28
- throw new TypeError("idGeneration must be 'client' or 'server'")
29
- }
27
+ // const idGeneration = modelOptions.idGeneration ?? 'client'
28
+ // if (!['client', 'server'].includes(idGeneration)) {
29
+ // throw new TypeError("idGeneration must be 'client' or 'server'")
30
+ // }
30
31
 
31
32
  function getObservable(where = {}) {
32
33
  // Validate eagerly
@@ -52,24 +53,35 @@ export function electricClientPlugin(app, options = {}) {
52
53
  })
53
54
  }
54
55
 
55
- function findMany(where = {}) {
56
- const observable = getObservable(where)
57
- if (!getCurrentScope()) return firstValueFrom(observable)
58
-
59
- const scopeDisposed = new Subject()
60
- onScopeDispose(() => {
61
- scopeDisposed.next()
62
- scopeDisposed.complete()
63
- })
64
- return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
65
- }
66
-
67
- async function create(data) {
68
- assertPlainObject(data, 'mutation data')
69
- if (idGeneration === 'server') return service.create(data)
70
- const uid = globalThis.crypto?.randomUUID?.()
71
- if (!uid) throw new Error('crypto.randomUUID() is required')
72
- return service.create(uid, data)
56
+ // function findMany(where = {}) {
57
+ // const observable = getObservable(where)
58
+ // if (!getCurrentScope()) return firstValueFrom(observable)
59
+
60
+ // const scopeDisposed = new Subject()
61
+ // onScopeDispose(() => {
62
+ // scopeDisposed.next()
63
+ // scopeDisposed.complete()
64
+ // })
65
+ // return firstValueFrom(observable.pipe(takeUntil(scopeDisposed)))
66
+ // }
67
+
68
+ // async function create(data) {
69
+ // assertPlainObject(data, 'mutation data')
70
+ // if (idGeneration === 'server') return service.create(data)
71
+ // const uid = globalThis.crypto?.randomUUID?.()
72
+ // if (!uid) throw new Error('crypto.randomUUID() is required')
73
+ // return service.create(uid, data)
74
+ // }
75
+
76
+ async function create(uidOrData, data) {
77
+ const hasUid = data !== undefined
78
+ const mutationData = hasUid ? data : uidOrData
79
+ assertPlainObject(mutationData, 'mutation data')
80
+ if (hasUid) {
81
+ return service.create(uidOrData, data)
82
+ } else {
83
+ return service.create(data)
84
+ }
73
85
  }
74
86
 
75
87
  async function update(id, data) {
@@ -80,7 +92,7 @@ export function electricClientPlugin(app, options = {}) {
80
92
  return service.delete(id)
81
93
  }
82
94
 
83
- return { getObservable, findMany, create, update, remove }
95
+ return { getObservable, /*findMany,*/ create, update, remove }
84
96
  }
85
97
 
86
98
  return Object.assign(app, { createElectricModel })
@@ -16,9 +16,11 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
16
16
  const shapePath = options.shapePath ?? '/electric/v1/shape/:model'
17
17
  const fetchImpl = options.fetch ?? globalThis.fetch
18
18
  if (typeof fetchImpl !== 'function') throw new TypeError('a fetch implementation is required')
19
+ const authorizeFunc = options.authorize || (() => true)
20
+ if (typeof authorizeFunc !== 'function') throw new TypeError('an authorize function is required')
19
21
 
20
22
  async function authorize(context, modelName, action, args) {
21
- const allowed = await options.authorize(context, { modelName, action, args })
23
+ const allowed = await authorizeFunc(context, { modelName, action, args })
22
24
  if (!allowed) {
23
25
  const error = new Error(`not authorized to ${action} '${modelName}'`)
24
26
  error.code = 'forbidden'
@@ -54,13 +56,13 @@ export function electricOfflinePlugin(app, db, models, options = {}) {
54
56
 
55
57
  // create(data): the primary key is server-generated
56
58
  // create(uid, data): uid (the primary key) is provided by the client
57
- create: async function(idOrData, data) {
59
+ create: async function(uidOrData, data) {
58
60
  const hasClientId = data !== undefined
59
- const mutationData = hasClientId ? data : idOrData
60
- await authorize(this, model.name, 'create', hasClientId ? [idOrData, mutationData] : [mutationData])
61
+ const mutationData = hasClientId ? data : uidOrData
62
+ await authorize(this, model.name, 'create', hasClientId ? [uidOrData, mutationData] : [mutationData])
61
63
  assertPlainObject(mutationData, 'mutation data')
62
64
  const safeData = hasClientId
63
- ? { ...mutationData, [model.primaryKey]: idOrData }
65
+ ? { ...mutationData, [model.primaryKey]: uidOrData }
64
66
  : { ...mutationData }
65
67
  const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
66
68
  const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
@@ -74,7 +74,7 @@ export async function reloadPlugin(app, options = {}) {
74
74
  // copy rooms
75
75
  for (const room of fromSocketRooms) {
76
76
  if (room === fromSocketId) continue // do not include room associated to socket#id
77
- toSocket.join(room)
77
+ await toSocket.join(room)
78
78
  }
79
79
  // copy data
80
80
  toSocket.data = {
@@ -12,11 +12,15 @@ class FakeSocket {
12
12
  this.emitted = []
13
13
  this.sockets = sockets
14
14
  this.disconnectingListener = disconnectingListener
15
+ this.joinDelay = Promise.resolve()
15
16
  }
16
17
 
17
18
  on(event, handler) { this.handlers.set(event, handler) }
18
19
  emit(event, ...args) { this.emitted.push([event, ...args]) }
19
- join(room) { this.rooms.add(room) }
20
+ async join(room) {
21
+ await this.joinDelay
22
+ this.rooms.add(room)
23
+ }
20
24
  disconnect() {
21
25
  this.disconnectingListener(this, 'server namespace disconnect')
22
26
  this.sockets.delete(this.id)
@@ -46,9 +50,16 @@ test('transfers state when the replacement connects before the old socket discon
46
50
  const transferToken = oldSocket.emitted.find(([event]) => event === 'cnx-transfer-token')[1]
47
51
 
48
52
  const newSocket = new FakeSocket('new', sockets, (...args) => disconnectingListener(...args))
53
+ let finishJoin
54
+ newSocket.joinDelay = new Promise(resolve => { finishJoin = resolve })
49
55
  sockets.set(newSocket.id, newSocket)
50
56
  connectListener(newSocket)
51
- await newSocket.handlers.get('cnx-transfer')('old', 'new', transferToken)
57
+ const transfer = newSocket.handlers.get('cnx-transfer')('old', 'new', transferToken)
58
+
59
+ await Promise.resolve()
60
+ assert.equal(newSocket.emitted.some(([event]) => event === 'cnx-transfer-ack'), false)
61
+ finishJoin()
62
+ await transfer
52
63
 
53
64
  assert.equal(newSocket.data.user, 'Ada')
54
65
  assert.equal(newSocket.rooms.has('project:1'), true)