@logux/server 0.16.2 → 0.17.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.
@@ -97,7 +97,7 @@ export class ConnectContext<Headers extends object = unknown> {
97
97
  *
98
98
  * @param action The action or the array of actions.
99
99
  * @param meta Action’s meta.
100
- * @returns Promise until action was added to the server log.
100
+ * @returns Promise until the action was sent to the client.
101
101
  */
102
102
  sendBack<TypeAction extends Action = AnyAction>(
103
103
  action:
@@ -141,6 +141,18 @@ export class Context<
141
141
  */
142
142
  isServer: boolean
143
143
 
144
+ /**
145
+ * Signal, which will be aborted when the action will be undone
146
+ * by `queueTimeout`.
147
+ *
148
+ * ```js
149
+ * process (ctx, action) {
150
+ * return fetch(url, { signal: ctx.signal })
151
+ * }
152
+ * ```
153
+ */
154
+ signal: AbortSignal
155
+
144
156
  constructor(server: Server, meta: ServerMeta)
145
157
  }
146
158
 
package/context/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseId } from '@logux/core'
2
2
 
3
3
  export class Context {
4
- constructor(server, meta) {
4
+ constructor(server, meta, task) {
5
5
  this.server = server
6
6
  this.data = {}
7
7
 
@@ -31,6 +31,8 @@ export class Context {
31
31
  } else {
32
32
  this.headers = {}
33
33
  }
34
+
35
+ if (task) this.signal = task.controller.signal
34
36
  }
35
37
 
36
38
  drain() {
@@ -38,7 +40,7 @@ export class Context {
38
40
  }
39
41
 
40
42
  sendBack(actions, meta = {}) {
41
- let common = { clients: [this.clientId], status: 'processed', ...meta }
43
+ let common = { clients: [this.clientId], ...meta }
42
44
  if (Array.isArray(actions)) {
43
45
  return this.server.log.add(
44
46
  actions.map(item => {
@@ -79,6 +79,14 @@ const REPORTERS = {
79
79
 
80
80
  destroy: () => ({ msg: 'Shutting down Logux server' }),
81
81
 
82
+ destroyDetached: () => ({
83
+ level: 'warn',
84
+ msg: 'Shutting down, but some actions that exceeded ' +
85
+ 'queueTimeout will be killed'
86
+ }),
87
+
88
+ duplicate: () => ({ level: 'warn', msg: 'Action was already received' }),
89
+
82
90
  disconnect: () => ({ msg: 'Client was disconnected' }),
83
91
 
84
92
  error: record => {
@@ -1,9 +1,27 @@
1
- import { ServerNode } from '@logux/core'
1
+ import { idToTime, ServerNode } from '@logux/core'
2
+
3
+ import { Context } from '../context/index.js'
4
+ import { filterMeta } from '../filter-meta/index.js'
2
5
 
3
6
  function has(array, item) {
4
7
  return array && array.includes(item)
5
8
  }
6
9
 
10
+ async function loadOnConnect(app, client, lastSynced) {
11
+ let entries = await app.connectLoader(new Context(app, client), lastSynced)
12
+ let added = 0
13
+ for (let entry of entries) {
14
+ // `added` should be taken before `filterMeta()` removes it
15
+ let entryAdded = entry[1].added
16
+ if (entryAdded > added) added = entryAdded
17
+ entry[1] = filterMeta(entry[1])
18
+ // The core needs `added` to set the sync position of every message
19
+ // and removes it before sending the action to the client
20
+ if (typeof entryAdded !== 'undefined') entry[1].added = entryAdded
21
+ }
22
+ return { added, entries }
23
+ }
24
+
7
25
  export class FilteredNode extends ServerNode {
8
26
  constructor(client, nodeId, log, connection, options) {
9
27
  super(nodeId, log, connection, options)
@@ -24,4 +42,23 @@ export class FilteredNode extends ServerNode {
24
42
  !has(meta.excludeClients, this.client.clientId)
25
43
  )
26
44
  }
45
+
46
+ async syncSinceQuery(lastSynced) {
47
+ let app = this.client.app
48
+ let data = app.connectLoader
49
+ ? await loadOnConnect(app, this.client, lastSynced)
50
+ : await super.syncSinceQuery(lastSynced)
51
+
52
+ let actions = data.entries.length
53
+ if (actions > 0) {
54
+ let id = app.log.generateId()
55
+ // Entries are ordered from the newest to the oldest one,
56
+ // so the last entry will be sent to the client first
57
+ data.entries.push([
58
+ { actions, type: 'logux/prepare' },
59
+ { id, time: idToTime(id) }
60
+ ])
61
+ }
62
+ return data
63
+ }
27
64
  }
package/index.d.ts CHANGED
@@ -25,6 +25,11 @@ export {
25
25
  PostgresStore,
26
26
  PostgresStoreOptions
27
27
  } from './postgres-store/index.js'
28
+ export {
29
+ ProcessingEntry,
30
+ ProcessingOutcome,
31
+ ProcessingStore
32
+ } from './processing-store/index.js'
28
33
  export {
29
34
  del,
30
35
  get,
package/index.js CHANGED
@@ -9,6 +9,7 @@ export { BaseServer, wasNot403 } from './base-server/index.js'
9
9
  export { Context } from './context/index.js'
10
10
  export { filterMeta } from './filter-meta/index.js'
11
11
  export { PostgresStore } from './postgres-store/index.js'
12
+ export { ProcessingStore } from './processing-store/index.js'
12
13
  export { ResponseError } from './request/index.js'
13
14
  export { Server } from './server/index.js'
14
15
  export { TestClient } from './test-client/index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logux/server",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Build own Logux server",
5
5
  "keywords": [
6
6
  "collaborative",
@@ -24,13 +24,12 @@
24
24
  "./package.json": "./package.json"
25
25
  },
26
26
  "dependencies": {
27
- "@logux/actions": "^0.7.3",
28
- "@logux/core": "^0.11.7",
27
+ "@logux/actions": "^0.8.0",
28
+ "@logux/core": "^0.12.0",
29
29
  "cookie": "^2.0.1",
30
- "fastq": "^1.20.3",
31
30
  "nanoevents": "^10.0.0",
32
31
  "nanoid": "^6.0.1",
33
- "ws": "^8.21.3"
32
+ "ws": "^8.22.0"
34
33
  },
35
34
  "engines": {
36
35
  "node": "^22.5.0 || ^24.0.0 || >=26.0.0"
@@ -108,7 +108,7 @@ export class PostgresStore<
108
108
  opts?: PostgresStoreOptions<Packers>
109
109
  )
110
110
 
111
- add(action: AnyAction, meta: Meta): Promise<false | Meta>
111
+ add(entries: [AnyAction, Meta][]): Promise<(false | Meta)[]>
112
112
 
113
113
  addReason(reasons: string[], criteria: Criteria): Promise<void>
114
114
 
@@ -124,6 +124,8 @@ export class PostgresStore<
124
124
 
125
125
  getLastSynced(): Promise<LastSynced>
126
126
 
127
+ has(ids: string[]): Promise<string[]>
128
+
127
129
  /**
128
130
  * Bring the log tables to the latest version, creating them if they were
129
131
  * not created by the application’s migration.
@@ -76,6 +76,13 @@ function position(meta) {
76
76
  return toSorted({ ...meta, time: meta.time ?? 0 })
77
77
  }
78
78
 
79
+ // A row of the `VALUES` list. Every cell keeps its own parameter with
80
+ // an explicit type, since the drivers send them all as text
81
+ function valuesSql(params, at, cells) {
82
+ let sql = cells.map(([type, value]) => `${params.add(value)}::${type}`)
83
+ return `(${at}, ${sql.join(', ')})`
84
+ }
85
+
79
86
  function criteriaSql(criteria, params) {
80
87
  let where = []
81
88
  if (typeof criteria.id !== 'undefined') {
@@ -167,48 +174,76 @@ export class PostgresStore {
167
174
  this.packers = { [zero.type]: zeroPacker, ...opts.packers }
168
175
  }
169
176
 
170
- async add(action, meta) {
171
- let packed = this.packers[action.type]?.pack(action)
172
- let rest = { ...meta }
173
- delete rest.added
174
- let rows = await this.query(
175
- // The action with the same ID is ignored, so it must not spend
176
- // the next `added` number
177
- `WITH "found" AS (
178
- SELECT 1 FROM "logux_log" WHERE "id" = $1
177
+ async add(entries) {
178
+ if (entries.length === 0) return []
179
+
180
+ let params = new Params()
181
+ let rows = entries.map(([action, meta], i) => {
182
+ let packed = this.packers[action.type]?.pack(action)
183
+ let rest = { ...meta }
184
+ delete rest.added
185
+ // The place in the call sets the `added` order and keeps the first
186
+ // action among the duplicates of the same call
187
+ return valuesSql(params, i + 1, [
188
+ ['text', meta.id],
189
+ ['text', position(meta)],
190
+ ['bigint', meta.time ?? 0],
191
+ ['text', typeof action.type === 'string' ? action.type : ''],
192
+ ['text[]', meta.reasons ?? []],
193
+ ['text[]', meta.indexes ?? []],
194
+ ['jsonb', toJson(rest)],
195
+ ['jsonb', toJson(packed ? packed.action : action)],
196
+ ['bytea', packed ? packed.blob : null]
197
+ ])
198
+ })
199
+
200
+ // The action with the same ID is ignored, so it must not spend
201
+ // the next `added` number
202
+ let added = await this.query(
203
+ `WITH "input" (
204
+ "at", "id", "sorted", "time", "type",
205
+ "reasons", "indexes", "meta", "action", "blob"
206
+ ) AS (
207
+ VALUES ${rows.join(', ')}
208
+ ), "fresh" AS (
209
+ SELECT DISTINCT ON ("id") * FROM "input"
210
+ WHERE NOT EXISTS (
211
+ SELECT 1 FROM "logux_log" WHERE "logux_log"."id" = "input"."id"
212
+ )
213
+ ORDER BY "id", "at"
214
+ ), "numbered" AS (
215
+ SELECT *, row_number() OVER (ORDER BY "at") AS "offset" FROM "fresh"
179
216
  ), "next" AS (
180
- UPDATE "logux_extra" SET "value" = "value" + 1
181
- WHERE "key" = 'added' AND NOT EXISTS (SELECT 1 FROM "found")
182
- RETURNING "value" AS "added"
217
+ UPDATE "logux_extra"
218
+ SET "value" = "value" + (SELECT count(*) FROM "numbered")
219
+ WHERE "key" = 'added' AND EXISTS (SELECT 1 FROM "numbered")
220
+ RETURNING "value" - (SELECT count(*) FROM "numbered") AS "base"
183
221
  )
184
222
  INSERT INTO "logux_log" (
185
223
  "added", "id", "sorted", "time", "type",
186
224
  "reasons", "indexes", "meta", "action", "blob"
187
225
  )
188
226
  SELECT
189
- "next"."added", $1, $2, $3, $4,
190
- $5::text[], $6::text[], $7::jsonb, $8::jsonb, $9
191
- FROM "next"
227
+ "next"."base" + "numbered"."offset", "id", "sorted", "time", "type",
228
+ "reasons", "indexes", "meta", "action", "blob"
229
+ FROM "numbered", "next"
192
230
  ON CONFLICT ("id") DO NOTHING
193
- RETURNING "added"`,
194
- [
195
- meta.id,
196
- position(meta),
197
- meta.time ?? 0,
198
- typeof action.type === 'string' ? action.type : '',
199
- meta.reasons ?? [],
200
- meta.indexes ?? [],
201
- toJson(rest),
202
- toJson(packed ? packed.action : action),
203
- packed ? packed.blob : null
204
- ]
231
+ RETURNING "added", "id"`,
232
+ params.values
205
233
  )
206
- if (rows.length === 0) return false
234
+
207
235
  // Drivers can return `bigint` as a string not to lose the precision above
208
236
  // `Number.MAX_SAFE_INTEGER`. Logux keeps `added` in `meta` as a number,
209
237
  // so these columns can never go that high
210
- meta.added = Number(rows[0].added)
211
- return meta
238
+ let numbers = new Map(added.map(i => [i.id, Number(i.added)]))
239
+ return entries.map(([, meta]) => {
240
+ let number = numbers.get(meta.id)
241
+ if (typeof number === 'undefined') return false
242
+ // Only the first action with the ID was inserted
243
+ numbers.delete(meta.id)
244
+ meta.added = number
245
+ return meta
246
+ })
212
247
  }
213
248
 
214
249
  async addReason(reasons, criteria) {
@@ -320,6 +355,15 @@ export class PostgresStore {
320
355
  return synced
321
356
  }
322
357
 
358
+ async has(ids) {
359
+ if (ids.length === 0) return []
360
+ let rows = await this.query(
361
+ `SELECT "id" FROM "logux_log" WHERE "id" = ANY($1::text[])`,
362
+ [ids]
363
+ )
364
+ return rows.map(i => i.id)
365
+ }
366
+
323
367
  async init() {
324
368
  if (this.transaction) {
325
369
  await this.transaction(query => this.migrate(query, true))