@jcbuisson/express-x 2.1.19 → 3.0.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.
Files changed (3) hide show
  1. package/README.md +207 -136
  2. package/package.json +2 -2
  3. package/src/index.mjs +283 -68
package/README.md CHANGED
@@ -1,172 +1,153 @@
1
1
 
2
+ Full doc: [https://expressx.jcbuisson.dev](https://expressx.jcbuisson.dev)
2
3
 
3
- # Getting started
4
-
5
- ## Create a project
6
-
7
- Let's create a new folder for our application:
8
4
 
9
- ```bash
10
- mkdir expressx-project
11
- cd expressx-project
12
- ```
5
+ # Getting started
13
6
 
14
- Since any ExpressX application is a Node application, we can create a default package.json using npm:
7
+ ExpressX is a framework handling both backend and frontend and their communication using websockets.\
8
+ A single websocket is used to channel both data and events between the server and each client.
15
9
 
16
10
  ```bash
17
- npm init es6 --yes
11
+ mkdir myproject
12
+ cd myproject
13
+ mkdir backend frontend
18
14
  ```
19
15
 
20
- The `es6` argument adds `"type": "module"` in `package.json`. Beware! All further module imports must made with es6/esm `import` syntax.
16
+ ## Initialize backend
21
17
 
22
-
23
- ## Install ExpressX
18
+ `@jcbuisson/express-x` is the server-side library
24
19
 
25
20
  ```bash
26
- npm install @jcbuisson/express-x
21
+ cd backend
22
+ npm init es6
23
+ npm install @jcbuisson/express-x cors
27
24
  ```
28
25
 
29
- ## Our first server application
26
+ ## Back-end example with a custom service
30
27
 
31
- Now we can create an ExpressX application which will provide a complete REST API on a `user` resource
32
- backed in a [Prisma](https://www.prisma.io/) database
28
+ The following example provides a 'math' service with two custom functions 'square' and 'cube':
33
29
 
34
30
  ```js
35
31
  // app.js
36
- import { expressXServer } from '@jcbuisson/express-x'
32
+ import { expressX } from '@jcbuisson/express-x';
33
+ import cors from 'cors'
37
34
 
38
- // `app` is a regular express application, enhanced with service and real-time features
39
- const app = expressX()
35
+ // `app` is a regular express application, enhanced with express-x services and real-time features
36
+ const app = expressX();
37
+ // regular express middleware, to allow access from our front-end
38
+ app.use(cors());
40
39
 
41
- // configure prisma client from schema
42
- const prisma = new PrismaClient()
40
+ // create a custom 'math' service with 2 methods
41
+ app.createService('math', {
42
+ square: (x) => x*x,
43
+ cube: (x) => x*x*x,
44
+ });
45
+
46
+ app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`));
47
+ ```
43
48
 
44
- // create two CRUD database services. They provide Prisma methods: `create`, 'createMany', 'find', 'findMany', 'upsert', etc.
45
- app.createService('User', prisma.User)
46
- app.createService('Post', prisma.Post)
49
+ A service may have as many parameters as needed, of any types as long as they are serializable.
47
50
 
48
- app.server.listen(8000, () => console.log(`App listening at http://localhost:8000`))
51
+ ## Run back-end
52
+ ```
53
+ node app.js
49
54
  ```
50
55
 
51
- Before running it we need to setup the corresponding database.
56
+ ## Initialize front-end
52
57
 
58
+ `@jcbuisson/express-x-client` is the client-side library
53
59
 
54
- ## Create the database
60
+ ```bash
61
+ cd frontend
62
+ npm init es6
63
+ npm install @jcbuisson/express-x-client socket.io-client
64
+ ```
55
65
 
56
- Presently, ExpressX only handles [Prisma](https://www.prisma.io/). Prisma is able to connect to most brands of relational or NoSQL databases.
66
+ ## Front-end example
57
67
 
58
- First, provide the database schema in `prisma/schema.prisma`:
59
- ```prisma
60
- generator client {
61
- provider = "prisma-client-js"
62
- }
68
+ index.html
63
69
 
64
- model User {
65
- id Int @default(autoincrement()) @id
66
- name String
67
- posts Post[]
68
- }
70
+ ```html
71
+ <html>
72
+ <input id="value-id" type="number" placeholder="Enter value"><br>
73
+ <button id="square-id" class="btn">Square</button>
74
+ <button id="cube-id" class="btn">Cube</button>
75
+ <p id="result-id"></p>
76
+ </html>
69
77
 
70
- model Post {
71
- id Int @default(autoincrement()) @id
72
- text String
73
- author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
74
- authorId Int
75
- }
78
+ <script type="module">
79
+ import io from 'socket.io-client';
80
+ import expressXClient from '@jcbuisson/express-x-client';
76
81
 
77
- datasource db {
78
- provider = "sqlite"
79
- url = "file:./dev.db"
80
- }
81
- ```
82
+ const socket = io('http://localhost:8000', {
83
+ transports: ["websocket"],
84
+ });
82
85
 
83
- Then create the database:
84
- ```bash
85
- npx prisma db push
86
- ```
86
+ const app = expressXClient(socket);
87
87
 
88
- The sqlite database file is created at `prisma/dev.db`
88
+ const valueInput = document.getElementById('value-id');
89
+ const squareBtn = document.getElementById('square-id');
90
+ const cubeBtn = document.getElementById('cube-id');
91
+ const resultParagraph = document.getElementById('result-id');
89
92
 
93
+ squareBtn.addEventListener('click', async (ev) => {
94
+ const result = await app.service('math').square(valueInput.value);
95
+ resultParagraph.innerHTML = result;
96
+ })
90
97
 
91
- ## Run the application on the server side
98
+ cubeBtn.addEventListener('click', async (ev) => {
99
+ const result = await app.service('math').cube(valueInput.value);
100
+ resultParagraph.innerHTML = result;
101
+ })
102
+ </script>
103
+ ```
92
104
 
93
- ```bash
94
- node app.js
105
+ ## Run front-end
106
+ ```
107
+ npx vite
95
108
  ```
96
109
 
110
+ Calling a service method from the frontend is as easy as `await app.service('math').square(value)`
97
111
 
98
- ## Use it with a websocket client
99
112
 
100
- Create the following client NodeJS script:
113
+ ## Add a CRUD API over a relational database
101
114
 
102
- ```js
103
- // client.js
104
- import io from 'socket.io-client'
105
- import { expressXClient } from '@jcbuisson/express-x'
115
+ With a few more lines to the backend, we can add a complete CRUD API on a `User` resource
116
+ backed in a [Prisma](https://www.prisma.io/) database
106
117
 
107
- const socket = io('http://localhost:8000', { transports: ["websocket"] })
118
+ ```js
119
+ // app.js
120
+ import { expressX } from '@jcbuisson/express-x'
121
+ import { PrismaClient } from '@prisma/client'
108
122
 
109
- const app = expressXClient(socket)
123
+ const prisma = new PrismaClient()
110
124
 
111
- async function main() {
112
- const user = await app.service('User').create({
113
- name: "Joe",
114
- })
115
- await app.service('Post').create({
116
- authorId: user.id,
117
- text: "Post#1"
118
- })
119
- await app.service('Post').create({
120
- authorId: user.id,
121
- text: "Post#2"
122
- })
123
- const joe = await app.service('User').findUnique({
124
- where: {
125
- id: user.id,
126
- },
127
- include: {
128
- posts: true,
129
- },
130
- })
131
- console.log('joe', joe)
132
- process.exit(0)
133
- }
134
- main()
135
- ```
125
+ // `app` is a regular express application, enhanced with express-x services and real-time features
126
+ const app = expressX()
136
127
 
137
- For simplicity we use a node client, but you would write something similar with your favorite front-end framework.
128
+ ...
138
129
 
139
- You can use the exact same statements on services on the client side as you would on the server side, such as: `app.service('User').create(...)`.
140
- Of course the `app` object here on the client is quite different that the `app` object on the server; you can find explanations [here]().
130
+ // Create a CRUD database 'user' service with the Prisma methods: `create`, 'findUnique', etc.
131
+ // Conveniently, `Prisma.User` is the map of all CRUD methods on User table
132
+ app.createService('user', Prisma.User)
141
133
 
142
- Now run the client script:
143
- ```bash
144
- node client.js
134
+ app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`))
145
135
  ```
146
136
 
147
- It prints the following lines in the console:
148
- ```json
149
- joe {
150
- id: 11,
151
- name: 'Joe',
152
- posts: [
153
- { id: 12, text: 'Post#1', authorId: 11 },
154
- { id: 13, text: 'Post#2', authorId: 11 }
155
- ]
156
- }
137
+ Of course the database must be created and setup first.
138
+
139
+ Now the full API of Prisma is accessible from the client-side, for example:
140
+ ```js
141
+ const user = await app.service('user').findUnique({ where: { id: userId }})
157
142
  ```
158
143
 
159
- We have a GraphQL-like experience with the nested posts, thanks to Prisma and its use through ExpressX services.
144
+ By default, errors on the server-side are serialized and re-emitted on the client-side, so you can catch them if needed.
160
145
 
161
- ::: info
162
- We could have removed from `app.js` all lines related to HTTP, since we are only using the websocket transport.
163
- :::
164
146
 
165
147
 
166
148
  ## Real-time applications
167
149
 
168
- When websocket transport is used (default situation) and when a connected client calls a service method,
169
- two twings happen on method completion:
150
+ When a connected client calls a service method, two things happen on method completion:
170
151
 
171
152
  - the resulting value is sent to the client
172
153
  - an event is emitted, and sent to connected clients we'll call subscribers. The calling client may or not be one of those subscribers.
@@ -177,50 +158,140 @@ For example in a medical application, whenever a patients's record is modified,
177
158
  to channels in order to receive those events. ExpressX provides functions to configure which events are published to which channels.
178
159
  A channel is represented by a name and you can create and use as many channels as you need.
179
160
 
180
- In the following example, every time a client connects to the server, it joins (= is subscribed to) the 'anonymous' channel.
161
+ ### Example: shared bilboard
162
+
163
+ In the following example of a shared bilboard, every time a client connects to the server, it joins (= is subscribed to) the 'all' channel.
164
+ And whenever an event is emited by the `bilboard` service, this event is published on this channel,
165
+ and then broacasted to all connected clients, leading to real-time updates.
166
+
167
+ ```js
168
+ // app.js
169
+ // Run it with: `node app.js`
170
+ import { expressX } from '@jcbuisson/express-x';
171
+ import cors from 'cors'
172
+
173
+ // `app` is a regular express application, enhanced with express-x services and real-time features
174
+ const app = expressX();
175
+ // express middleware which prevents cors issues with dev front-end
176
+ app.use(cors());
177
+
178
+ let bilboard = '';
179
+
180
+ // create a custom 'bilboard' service with 1 method
181
+ app.createService('bilboard', {
182
+ sendMessage: (message) => {
183
+ bilboard = message;
184
+ return message;
185
+ }
186
+ });
187
+
188
+ // publish
189
+ app.service('bilboard').publish(async (context) => {
190
+ return ['all']
191
+ });
192
+
193
+ // subscribe
194
+ app.on('connection', (socket) => {
195
+ app.joinChannel('all', socket)
196
+ })
197
+
198
+ app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`));
199
+ ```
200
+
201
+ ```html
202
+ <!-- index.html; run it with: npx vite -->
203
+ <html>
204
+ <input id="message-id" type="text" placeholder="Enter message"><br>
205
+ <button id="send-id" class="btn">Send</button>
206
+
207
+ <div id="bilboard-id"></div>
208
+ </html>
209
+
210
+ <script type="module">
211
+ import io from 'socket.io-client';
212
+ import expressXClient from '@jcbuisson/express-x-client';
213
+
214
+ const socket = io('http://localhost:8000', {
215
+ transports: ["websocket"],
216
+ });
217
+
218
+ const app = expressXClient(socket);
219
+
220
+ const messageInput = document.getElementById('message-id');
221
+ const sendBtn = document.getElementById('send-id');
222
+ const bilboardDiv = document.getElementById('bilboard-id');
223
+
224
+ sendBtn.addEventListener('click', async (ev) => {
225
+ await app.service('bilboard').sendMessage(messageInput.value);
226
+ });
227
+
228
+ app.service('bilboard').on('sendMessage', (message) => {
229
+ bilboardDiv.innerHTML = bilboardDiv.innerHTML + '<br>' + message;
230
+ })
231
+ </script>
232
+ ```
233
+
234
+ The listener is triggered whenever the client receives from the server a `sendMessage` event from the `bilboard` service.
235
+ This event is sent to all subscribers after the execution of `app.service('bilboard').sendMessage()` on the server.
236
+
237
+
238
+ ### Example : CRUD database
239
+
240
+ In this other example, every time a client connects to the server, it joins (= is subscribed to) the 'anonymous' channel.
181
241
  And whenever an event is emited by the `post` or `user` service, this event is published on this channel,
182
242
  and then broacasted to all connected clients, leading to real-time updates.
183
243
 
184
244
  ```js
185
245
  // app.js
186
- import { expressXServer } from '@jcbuisson/express-x'
187
- import { PrismaClient } from '@prisma/client'
246
+ import { expressX } from '@jcbuisson/express-x';
247
+ import { PrismaClient } from '@prisma/client';
188
248
 
189
- // `app` is a regular express application, enhanced with service and real-time features
190
- const app = expressX()
249
+ const prisma = new PrismaClient();
191
250
 
192
- // configure prisma client from schema
193
- const prisma = new PrismaClient()
251
+ // `app` is a regular express application, enhanced with express-x services and real-time features
252
+ const app = expressX(prisma);
194
253
 
195
- // create two CRUD database services. They provide Prisma methods: `create`, 'createMany', 'find', 'findMany', 'upsert', etc.
196
- app.createService('User', prisma.User)
197
- app.createService('Post', prisma.Post)
254
+ // create two CRUD database services with the Prisma methods: `create`, 'update', etc
255
+ app.createService('user', prisma.User);
256
+ app.createService('post', prisma.Post);
198
257
 
199
258
  // publish
200
- app.service('User').publish(async (post, context) => {
259
+ app.service('user').publish(async (context) => {
201
260
  return ['anonymous']
202
- })
203
- app.service('Post').publish(async (post, context) => {
261
+ });
262
+ app.service('post').publish(async (context) => {
204
263
  return ['anonymous']
205
- })
264
+ });
206
265
 
207
266
  // subscribe
208
- app.on('connection', (socket) => {
209
- console.log('connection', socket.id)
267
+ app.addConnectListener((socket) => {
210
268
  app.joinChannel('anonymous', socket)
211
- })
269
+ });
212
270
 
213
- app.server.listen(8000, () => console.log(`App listening at http://localhost:8000`))
271
+ app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`));
214
272
  ```
215
273
 
216
274
  Here is how a client may listen to channel events:
217
275
 
218
276
  ```js
219
- ...
220
- app.service('Post').on('create', post => {
221
- console.log('post event created', post)
277
+ import io from 'socket.io-client'
278
+ import expressXClient from '@jcbuisson/express-x-client'
279
+
280
+ const socket = io('http://localhost:8000', { transports: ["websocket"] })
281
+
282
+ const app = expressXClient(socket)
283
+
284
+
285
+ app.service('user').on('create', (user) => {
286
+ console.log('User created', user)
287
+ // update client cache
288
+ })
289
+
290
+ app.service('post').on('create', (post) => {
291
+ console.log('Post created', post)
292
+ // update client cache
222
293
  })
223
294
  ```
224
295
 
225
296
  The listener is triggered whenever the client receives from the server a `create` event from the service `post`.
226
- This event results from the completion on the server of a call `app.service('Post').create()`
297
+ This event is sent to all subscribers after the execution of `app.service('post').create()` on the server.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x",
3
- "version": "2.1.19",
3
+ "version": "3.0.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git@github.com:jcbuisson/express-x.git"
9
+ "url": "git+ssh://git@github.com/jcbuisson/express-x.git"
10
10
  },
11
11
  "author": "Jean-Christophe Buisson <buisson@enseeiht.fr> (jcbuisson.dev)",
12
12
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -5,9 +5,51 @@ import bcrypt from 'bcryptjs'
5
5
 
6
6
 
7
7
 
8
+
8
9
  // UTILISER L'ACKNOWLEDGEMENT : https://socket.io/docs/v4/#acknowledgements
9
10
 
10
11
 
12
+
13
+
14
+ ////////////////////////// UTILITIES //////////////////////////
15
+
16
+ function truncateString(str, maxLength = 300, ellipsis = '...') {
17
+ // Check if the string already fits
18
+ if (str.length <= maxLength) return str;
19
+ // Calculate the cut-off point, accounting for the ellipsis length
20
+ const cutLength = maxLength - ellipsis.length;
21
+ // Ensure the string is long enough to be cut
22
+ if (cutLength < 0) return str.substring(0, maxLength); // Just cut it off if ellipsis doesn't fit
23
+ // Truncate the string and add the ellipsis
24
+ return str.substring(0, cutLength) + ellipsis;
25
+ }
26
+
27
+
28
+ class Mutex {
29
+ constructor() {
30
+ this.locked = false;
31
+ this.queue = [];
32
+ }
33
+
34
+ async acquire() {
35
+ if (this.locked) {
36
+ return new Promise(resolve => this.queue.push(resolve));
37
+ }
38
+ this.locked = true;
39
+ }
40
+
41
+ release() {
42
+ if (this.queue.length > 0) {
43
+ const next = this.queue.shift();
44
+ next();
45
+ } else {
46
+ this.locked = false;
47
+ }
48
+ }
49
+ }
50
+
51
+ ////////////////////////// EXPRESSX //////////////////////////
52
+
11
53
  export function expressX(config) {
12
54
 
13
55
  const services = {}
@@ -254,8 +296,8 @@ export function expressX(config) {
254
296
  return service
255
297
  }
256
298
 
257
- function configure(callback) {
258
- callback(app)
299
+ function configure(callback, ...args) {
300
+ callback(app, ...args)
259
301
  }
260
302
 
261
303
  // `app.service(name)` starts here!
@@ -305,14 +347,13 @@ export class EXError extends Error {
305
347
  }
306
348
 
307
349
 
308
-
350
+ ////////////////////////// HOOK METHODS //////////////////////////
309
351
 
310
352
  /*
311
353
  * Add a timestamp property of name `field` with current time as value
312
354
  */
313
355
  export const addTimestamp = (field) => async (context) => {
314
356
  context.result[field] = (new Date()).toISOString()
315
- return context
316
357
  }
317
358
 
318
359
  /*
@@ -321,84 +362,258 @@ export const addTimestamp = (field) => async (context) => {
321
362
  export const hashPassword = (passwordField) => async (context) => {
322
363
  const user = context.result
323
364
  user[passwordField] = await bcrypt.hash(user[passwordField], 5)
324
- return context
325
365
  }
326
366
 
327
367
  /*
328
368
  * Remove `field` from `context.result`
329
369
  */
330
- export function protect(field) {
331
- return async (context) => {
332
- if (context.result) {
333
- if (Array.isArray(context.result)) {
334
- for (const value of context.result) {
335
- delete value[field]
336
- }
337
- } else if (typeof context.result === "object") {
338
- delete context.result[field]
370
+ export const protect = (field) => (context) => {
371
+ if (context.result) {
372
+ if (Array.isArray(context.result)) {
373
+ for (const value of context.result) {
374
+ delete value[field]
339
375
  }
376
+ } else if (typeof context.result === "object") {
377
+ delete context.result[field]
340
378
  }
341
- return context
342
379
  }
343
380
  }
344
381
 
345
- export const isNotExpired = async (context) => {
346
- // do nothing if it's not a client call from a ws connexion
347
- if (!context.socket) return
348
- const expiresAt = context.socket?.data?.expiresAt
349
- if (expiresAt) {
350
- const expiresAtDate = new Date(expiresAt)
351
- const now = new Date()
352
- if (now > expiresAtDate) {
353
- // expiration date is met
354
- // clear socket.data
355
- context.socket.data = {}
356
- // leave all rooms except socket#id
357
- const rooms = new Set(context.socket.rooms)
358
- for (const room of rooms) {
359
- if (room === context.socket.id) continue
360
- context.socket.leave(room)
382
+
383
+ ////////////////////////// RELOAD PLUGIN //////////////////////////
384
+
385
+ export const roomCache = {}
386
+ export const dataCache = {}
387
+
388
+
389
+ export async function reloadPlugin(app) {
390
+
391
+ const io = app.get('io')
392
+
393
+ app.addDisconnectingListener((socket, reason) => {
394
+ console.log('onSocketDisconnecting', socket.id, reason)
395
+ // save socket data & rooms in caches
396
+ const alreadySavedData = dataCache[socket.id]
397
+ const alreadySavedRooms = roomCache[socket.id]
398
+
399
+ dataCache[socket.id] = Object.assign({}, socket.data)
400
+ roomCache[socket.id] = new Set(socket.rooms)
401
+
402
+ if (alreadySavedData) dataCache[socket.id] = Object.assign(dataCache[socket.id], alreadySavedData)
403
+ if (alreadySavedRooms) roomCache[socket.id].add(alreadySavedRooms)
404
+ })
405
+
406
+ app.addConnectListener((socket) => {
407
+ console.log('onSocketConnect', socket.id)
408
+
409
+ // when client ask for transfer from fromSocketId to toSocketId
410
+ socket.on('cnx-transfer', async (fromSocketId, toSocketId) => {
411
+ app.log('verbose', `cnx-transfer from ${fromSocketId} to ${toSocketId}`)
412
+ console.log('dataCache', dataCache)
413
+ console.log('roomCache', roomCache)
414
+ // copy connection room & data from 'fromSocketId' to 'toSocketId'
415
+ const toSocket = io.sockets.sockets.get(toSocketId)
416
+ // data & rooms of fromSocketId are taken from dataCache and roomCache, since socket no longer exists
417
+ const fromSocketRooms = roomCache[fromSocketId]
418
+ if (toSocket && fromSocketRooms) {
419
+ // copy rooms
420
+ for (const room of fromSocketRooms) {
421
+ if (room === fromSocketId) continue // do not include room associated to socket#id
422
+ toSocket.join(room)
423
+ }
424
+ // copy data
425
+ toSocket.data = dataCache[fromSocketId]
426
+ // console.log('cnx-transfer data', toSocket.data)
427
+ // console.log('cnx-transfer rooms', toSocket.rooms)
428
+ // remove 'from' cache data
429
+ delete roomCache[fromSocketId]
430
+ delete dataCache[fromSocketId]
431
+ // send acknowlegment to toSocket
432
+ toSocket.emit('cnx-transfer-ack', fromSocketId, toSocketId)
433
+ } else {
434
+ console.log(`*** CNX TRANSFER ERROR, ${fromSocketId} -> ${toSocketId}`)
435
+ toSocket.emit('cnx-transfer-error', fromSocketId, toSocketId)
361
436
  }
362
- // send an event to the client (typical client handling: logout)
363
- context.socket.emit('not-authenticated')
364
- // throw exception
365
- throw new EXError('not-authenticated', "Session expired")
366
- }
367
- } else {
368
- // send an event to the client (typical client handling: logout)
369
- context.socket.emit('not-authenticated')
370
- throw new EXError('not-authenticated', "No expiresAt in socket.data")
371
- }
437
+ })
438
+ })
372
439
  }
373
440
 
374
- /*
375
- * Throw an error for a client service method call when socket.data does not contain user
376
- */
377
- export const isAuthenticated = async (context) => {
378
- // do nothing if it's not a client call from a ws connexion
379
- if (context.caller !== 'client') return
380
- if (!context.socket?.data) {
381
- // send an event to the client (typical client handling: logout)
382
- context.socket.emit('not-authenticated')
383
- throw new EXError('not-authenticated', 'no data in socket')
384
- }
385
- if (!context.socket.data?.user) {
386
- // send an event to the client (typical client handling: logout)
387
- context.socket.emit('not-authenticated')
388
- throw new EXError('not-authenticated', 'no user in socket.data')
441
+
442
+ ////////////////////////// OFFLINE PLUGIN //////////////////////////
443
+
444
+ const mutex = new Mutex()
445
+
446
+ export function offlinePlugin(app, modelNames) {
447
+
448
+ const prisma = app.get('prisma');
449
+
450
+ if (!prisma.metadata) {
451
+ app.log('error', "A model named 'metadata' is expected in the prisma database - see https://expressx.jcbuisson.dev/server-api.html#offline")
452
+ return
389
453
  }
390
- }
391
454
 
392
- /*
393
- * Extend value of socket.data.expiresAt of `duration` milliseconds
394
- */
395
- export const extendExpiration = (duration) => async (context) => {
396
- const now = new Date()
397
- if (context.caller !== 'client') return
398
- if (!context.socket?.data) {
399
- // send an event to the client (typical client handling: logout)
400
- context.socket.emit('not-authenticated')
401
- throw new EXError('not-authenticated', 'no data in socket')
455
+ // add a database service for each model
456
+ for (const modelName of modelNames) {
457
+ const model = prisma[modelName];
458
+
459
+ app.createService(modelName, {
460
+
461
+ findUnique: model.findUnique,
462
+ findMany: model.findMany,
463
+
464
+ createWithMeta: async (uid, data, created_at) => {
465
+ const [value, meta] = await prisma.$transaction([
466
+ model.create({ data: { uid, ...data } }),
467
+ prisma.metadata.create({ data: { uid, created_at } })
468
+ ])
469
+ return [value, meta]
470
+ },
471
+
472
+ updateWithMeta: async (uid, data, updated_at) => {
473
+ const [value, meta] = await prisma.$transaction([
474
+ model.update({ where: { uid }, data }),
475
+ prisma.metadata.update({ where: { uid }, data: { updated_at } })
476
+ ])
477
+ return [value, meta]
478
+ },
479
+
480
+ deleteWithMeta: async (uid, deleted_at) => {
481
+ const [value, meta] = await prisma.$transaction([
482
+ model.delete({ where: { uid } }),
483
+ prisma.metadata.update({ where: { uid }, data: { deleted_at } })
484
+ ])
485
+ return [value, meta]
486
+ },
487
+ })
402
488
  }
403
- context.socket.data.expiresAt = new Date(now.getTime() + duration)
489
+
490
+ // add a synchronization service
491
+ app.createService('sync', {
492
+
493
+ // AMÉLIORER : ne pas avoir une exclusion mutuelle globale, mais seulement par model/where
494
+ go: async (modelName, where, cutoffDate, clientMetadataDict) => {
495
+ await mutex.acquire()
496
+ try {
497
+ console.log('>>>>> SYNC', modelName, where, cutoffDate)
498
+ const databaseService = app.service(modelName)
499
+ const prisma = app.get('prisma')
500
+
501
+ // STEP 1: get existing database `where` values
502
+ const databaseValues = await databaseService.findMany({ where })
503
+
504
+ const databaseValuesDict = databaseValues.reduce((accu, value) => {
505
+ accu[value.uid] = value
506
+ return accu
507
+ }, {})
508
+ // console.log('clientMetadataDict', clientMetadataDict)
509
+ // console.log('databaseValuesDict', databaseValuesDict)
510
+
511
+ // STEP 2: compute intersections between client and database uids
512
+ const onlyDatabaseIds = new Set()
513
+ const onlyClientIds = new Set()
514
+ const databaseAndClientIds = new Set()
515
+
516
+ for (const uid in databaseValuesDict) {
517
+ if (uid in clientMetadataDict) {
518
+ databaseAndClientIds.add(uid)
519
+ } else {
520
+ onlyDatabaseIds.add(uid)
521
+ }
522
+ }
523
+
524
+ for (const uid in clientMetadataDict) {
525
+ if (uid in databaseValuesDict) {
526
+ databaseAndClientIds.add(uid)
527
+ } else {
528
+ onlyClientIds.add(uid)
529
+ }
530
+ }
531
+ // console.log('onlyDatabaseIds', onlyDatabaseIds)
532
+ // console.log('onlyClientIds', onlyClientIds)
533
+ // console.log('databaseAndClientIds', databaseAndClientIds)
534
+
535
+ // STEP 3: build add/update/delete sets
536
+ const addDatabase = []
537
+ const updateDatabase = []
538
+ const deleteDatabase = []
539
+
540
+ const addClient = []
541
+ const updateClient = []
542
+ const deleteClient = []
543
+
544
+ for (const uid of onlyDatabaseIds) {
545
+ const databaseValue = databaseValuesDict[uid]
546
+ let databaseMetaData = await prisma.metadata.findUnique({ where: { uid }})
547
+ if (!databaseMetaData) {
548
+ console.log('no metadata - should not happen', modelName, where, uid)
549
+ databaseMetaData = { uid, created_at: new Date() }
550
+ }
551
+ addClient.push([databaseValue, databaseMetaData])
552
+ }
553
+
554
+ for (const uid of onlyClientIds) {
555
+ const clientMetaData = clientMetadataDict[uid]
556
+ if (clientMetaData.deleted_at) {
557
+ deleteClient.push([uid, clientMetaData.deleted_at])
558
+ } else if (new Date(clientMetaData.created_at) > cutoffDate) {
559
+ addDatabase.push(clientMetaData)
560
+ } else {
561
+ // ???
562
+ }
563
+ }
564
+
565
+ for (const uid of databaseAndClientIds) {
566
+ const databaseValue = databaseValuesDict[uid]
567
+ const clientMetaData = clientMetadataDict[uid]
568
+ || { uid, created_at: new Date() } // should not happen
569
+ if (clientMetaData.deleted_at) {
570
+ deleteDatabase.push(uid)
571
+ deleteClient.push([uid, clientMetaData.deleted_at])
572
+ } else {
573
+ const databaseMetaData = await prisma.metadata.findUnique({ where: { uid }})
574
+ || { uid, created_at: new Date() } // should not happen
575
+ const clientUpdatedAt = new Date(clientMetaData.updated_at || clientMetaData.created_at)
576
+ const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
577
+ const dateDifference = clientUpdatedAt - databaseUpdatedAt
578
+ // console.log('databaseMetaData', databaseMetaData, 'clientMetaData', clientMetaData, 'dateDifference', dateDifference)
579
+ if (dateDifference > 0) {
580
+ updateDatabase.push(clientMetaData)
581
+ } else if (dateDifference < 0) {
582
+ updateClient.push(databaseValue)
583
+ }
584
+ }
585
+ }
586
+ console.log('addDatabase', truncateString(JSON.stringify(addDatabase)))
587
+ console.log('deleteDatabase', truncateString(JSON.stringify(deleteDatabase)))
588
+ console.log('updateDatabase', truncateString(JSON.stringify(updateDatabase)))
589
+
590
+ console.log('addClient', truncateString(JSON.stringify(addClient)))
591
+ console.log('deleteClient', truncateString(JSON.stringify(deleteClient)))
592
+ console.log('updateClient', truncateString(JSON.stringify(updateClient)))
593
+
594
+ // STEP4: execute database deletions
595
+ for (const uid of deleteDatabase) {
596
+ const clientMetaData = clientMetadataDict[uid]
597
+ // console.log('---delete', uid, clientMetaData)
598
+ await databaseService.deleteWithMeta(uid, clientMetaData.deleted_at)
599
+ }
600
+
601
+ // STEP5: return to client the changes to perform on its cache, and create/update to perform on database with full data
602
+ // database creations & updates are done later by the client with complete data (this function only has client values's meta-data)
603
+ return {
604
+ toAdd: addClient,
605
+ toUpdate: updateClient,
606
+ toDelete: deleteClient,
607
+
608
+ addDatabase,
609
+ updateDatabase,
610
+ }
611
+ } catch(err) {
612
+ console.log('*** err sync', err)
613
+ } finally {
614
+ mutex.release()
615
+ }
616
+ },
617
+ })
618
+
404
619
  }