@logux/server 0.14.0 → 0.16.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/LICENSE +1 -1
- package/README.md +15 -18
- package/add-http-pages/hello.html +34 -26
- package/add-sync-map/index.d.ts +1 -3
- package/add-sync-map/index.js +4 -10
- package/base-server/index.d.ts +88 -30
- package/base-server/index.js +208 -114
- package/context/index.d.ts +35 -3
- package/context/index.js +17 -6
- package/create-reporter/index.js +40 -32
- package/human-formatter/index.js +12 -7
- package/human-formatter/utils.js +14 -0
- package/index.d.ts +8 -1
- package/index.js +1 -0
- package/package.json +20 -22
- package/postgres-store/index.d.ts +149 -0
- package/postgres-store/index.js +444 -0
- package/server/index.d.ts +9 -5
- package/server/index.js +16 -7
- package/server-client/index.d.ts +25 -1
- package/server-client/index.js +42 -9
- package/test-client/index.d.ts +5 -2
- package/test-client/index.js +8 -8
- package/test-server/index.d.ts +9 -6
- package/test-server/index.js +4 -3
- package/url-pattern/index.js +28 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { zero, zeroPacker } from '@logux/actions'
|
|
2
|
+
import { toSorted } from '@logux/core'
|
|
3
|
+
|
|
4
|
+
class Params {
|
|
5
|
+
values = []
|
|
6
|
+
|
|
7
|
+
add(value) {
|
|
8
|
+
this.values.push(value)
|
|
9
|
+
return `$${this.values.length}`
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MIGRATIONS = [
|
|
14
|
+
[
|
|
15
|
+
`CREATE TABLE IF NOT EXISTS "logux_log" (
|
|
16
|
+
"added" bigint PRIMARY KEY,
|
|
17
|
+
"id" text NOT NULL UNIQUE,
|
|
18
|
+
"sorted" text COLLATE "C" NOT NULL,
|
|
19
|
+
"time" bigint NOT NULL,
|
|
20
|
+
"type" text NOT NULL,
|
|
21
|
+
"reasons" text[] NOT NULL,
|
|
22
|
+
"indexes" text[] NOT NULL DEFAULT '{}',
|
|
23
|
+
"meta" jsonb NOT NULL,
|
|
24
|
+
"action" jsonb,
|
|
25
|
+
"blob" bytea
|
|
26
|
+
)`,
|
|
27
|
+
`CREATE INDEX IF NOT EXISTS "logux_log_created"
|
|
28
|
+
ON "logux_log" ("sorted" DESC)`,
|
|
29
|
+
`CREATE INDEX IF NOT EXISTS "logux_log_reasons"
|
|
30
|
+
ON "logux_log" USING GIN ("reasons")`,
|
|
31
|
+
`CREATE INDEX IF NOT EXISTS "logux_log_indexes"
|
|
32
|
+
ON "logux_log" USING GIN ("indexes")`,
|
|
33
|
+
`CREATE TABLE IF NOT EXISTS "logux_extra" (
|
|
34
|
+
"key" text PRIMARY KEY,
|
|
35
|
+
"value" bigint NOT NULL
|
|
36
|
+
)`,
|
|
37
|
+
`INSERT INTO "logux_extra" ("key", "value")
|
|
38
|
+
VALUES ('added', 0), ('received', 0), ('sent', 0)
|
|
39
|
+
ON CONFLICT ("key") DO NOTHING`
|
|
40
|
+
]
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// JSON has no binary type, but actions can keep `Uint8Array`
|
|
44
|
+
// with the encrypted data
|
|
45
|
+
function encodeBytes(key, value) {
|
|
46
|
+
if (value instanceof Uint8Array) {
|
|
47
|
+
return { $bytes: Buffer.from(value).toString('base64') }
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decodeBytes(value) {
|
|
53
|
+
if (Array.isArray(value)) return value.map(decodeBytes)
|
|
54
|
+
if (typeof value !== 'object' || value === null) return value
|
|
55
|
+
let object = { ...value }
|
|
56
|
+
let bytes = object.$bytes
|
|
57
|
+
if (typeof bytes === 'string') {
|
|
58
|
+
return new Uint8Array(Buffer.from(bytes, 'base64'))
|
|
59
|
+
}
|
|
60
|
+
for (let key in object) object[key] = decodeBytes(object[key])
|
|
61
|
+
return object
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toJson(value) {
|
|
65
|
+
return JSON.stringify(value, encodeBytes)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fromJson(value) {
|
|
69
|
+
return decodeBytes(typeof value === 'string' ? JSON.parse(value) : value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Position of the action in the `created` order. Logux compares the time,
|
|
73
|
+
// the node ID and the time inside the ID, and `toSorted()` packs all three
|
|
74
|
+
// into the string, which the database can sort by itself
|
|
75
|
+
function position(meta) {
|
|
76
|
+
return toSorted({ ...meta, time: meta.time ?? 0 })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function criteriaSql(criteria, params) {
|
|
80
|
+
let where = []
|
|
81
|
+
if (typeof criteria.id !== 'undefined') {
|
|
82
|
+
where.push(`"id" = ${params.add(criteria.id)}`)
|
|
83
|
+
}
|
|
84
|
+
if (typeof criteria.ids !== 'undefined') {
|
|
85
|
+
where.push(`"id" = ANY(${params.add(criteria.ids)}::text[])`)
|
|
86
|
+
}
|
|
87
|
+
if (typeof criteria.index !== 'undefined') {
|
|
88
|
+
where.push(`${params.add(criteria.index)} = ANY("indexes")`)
|
|
89
|
+
}
|
|
90
|
+
if (typeof criteria.exceptIndex !== 'undefined') {
|
|
91
|
+
where.push(`NOT (${params.add(criteria.exceptIndex)} = ANY("indexes"))`)
|
|
92
|
+
}
|
|
93
|
+
if (typeof criteria.minAdded !== 'undefined') {
|
|
94
|
+
where.push(`"added" >= ${params.add(criteria.minAdded)}`)
|
|
95
|
+
}
|
|
96
|
+
if (typeof criteria.maxAdded !== 'undefined') {
|
|
97
|
+
where.push(`"added" <= ${params.add(criteria.maxAdded)}`)
|
|
98
|
+
}
|
|
99
|
+
if (typeof criteria.olderThan !== 'undefined') {
|
|
100
|
+
where.push(`"sorted" < ${params.add(position(criteria.olderThan))}`)
|
|
101
|
+
}
|
|
102
|
+
if (typeof criteria.youngerThan !== 'undefined') {
|
|
103
|
+
where.push(`"sorted" > ${params.add(position(criteria.youngerThan))}`)
|
|
104
|
+
}
|
|
105
|
+
if (where.length === 0) return 'TRUE'
|
|
106
|
+
return where.join(' AND ')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `pg` and PGlite return the rows inside the result, `postgres` returns
|
|
110
|
+
// the rows themselves
|
|
111
|
+
function toRows(result) {
|
|
112
|
+
return Array.isArray(result) ? result : result.rows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `postgres` is a tagged template function, so its methods are checked
|
|
116
|
+
// before the plain function from `PostgresQuery`
|
|
117
|
+
function toQuery(db) {
|
|
118
|
+
if (typeof db.unsafe === 'function') {
|
|
119
|
+
return async (sql, params) => toRows(await db.unsafe(sql, params))
|
|
120
|
+
} else if (typeof db.query === 'function') {
|
|
121
|
+
return async (sql, params) => toRows(await db.query(sql, params))
|
|
122
|
+
} else {
|
|
123
|
+
return async (sql, params) => toRows(await db(sql, params))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Every driver has its own way to keep the queries on a single connection.
|
|
128
|
+
// Without it `init()` can not lock the migrations
|
|
129
|
+
function toTransaction(db) {
|
|
130
|
+
if (typeof db.unsafe === 'function') {
|
|
131
|
+
// `postgres`
|
|
132
|
+
if (typeof db.begin !== 'function') return undefined
|
|
133
|
+
return body => db.begin(tx => body(toQuery(tx)))
|
|
134
|
+
} else if (typeof db.query === 'function') {
|
|
135
|
+
if (typeof db.transaction === 'function') {
|
|
136
|
+
// PGlite
|
|
137
|
+
return body => db.transaction(tx => body(toQuery(tx)))
|
|
138
|
+
} else if (typeof db.connect === 'function') {
|
|
139
|
+
// `pg`
|
|
140
|
+
return async body => {
|
|
141
|
+
let client = await db.connect()
|
|
142
|
+
try {
|
|
143
|
+
await client.query('BEGIN')
|
|
144
|
+
await body(toQuery(client))
|
|
145
|
+
await client.query('COMMIT')
|
|
146
|
+
} catch (e) {
|
|
147
|
+
await client.query('ROLLBACK')
|
|
148
|
+
throw e
|
|
149
|
+
} finally {
|
|
150
|
+
client.release()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
// The custom query function from `PostgresQuery`
|
|
158
|
+
return db.transaction
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export class PostgresStore {
|
|
163
|
+
constructor(db, opts = {}) {
|
|
164
|
+
this.query = toQuery(db)
|
|
165
|
+
this.transaction = toTransaction(db)
|
|
166
|
+
this.pageSize = opts.pageSize ?? 500
|
|
167
|
+
this.packers = { [zero.type]: zeroPacker, ...opts.packers }
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
|
179
|
+
), "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"
|
|
183
|
+
)
|
|
184
|
+
INSERT INTO "logux_log" (
|
|
185
|
+
"added", "id", "sorted", "time", "type",
|
|
186
|
+
"reasons", "indexes", "meta", "action", "blob"
|
|
187
|
+
)
|
|
188
|
+
SELECT
|
|
189
|
+
"next"."added", $1, $2, $3, $4,
|
|
190
|
+
$5::text[], $6::text[], $7::jsonb, $8::jsonb, $9
|
|
191
|
+
FROM "next"
|
|
192
|
+
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
|
+
]
|
|
205
|
+
)
|
|
206
|
+
if (rows.length === 0) return false
|
|
207
|
+
// Drivers can return `bigint` as a string not to lose the precision above
|
|
208
|
+
// `Number.MAX_SAFE_INTEGER`. Logux keeps `added` in `meta` as a number,
|
|
209
|
+
// so these columns can never go that high
|
|
210
|
+
meta.added = Number(rows[0].added)
|
|
211
|
+
return meta
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async addReason(reasons, criteria) {
|
|
215
|
+
let params = new Params()
|
|
216
|
+
let list = params.add(reasons)
|
|
217
|
+
await this.query(
|
|
218
|
+
`UPDATE "logux_log" SET "reasons" = "reasons" || (
|
|
219
|
+
SELECT coalesce(array_agg("new"."reason" ORDER BY "new"."at"), '{}')
|
|
220
|
+
FROM unnest(${list}::text[])
|
|
221
|
+
WITH ORDINALITY AS "new"("reason", "at")
|
|
222
|
+
WHERE NOT ("new"."reason" = ANY("logux_log"."reasons"))
|
|
223
|
+
)
|
|
224
|
+
WHERE ${criteriaSql(criteria, params)}`,
|
|
225
|
+
params.values
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async byId(id) {
|
|
230
|
+
let rows = await this.query(`SELECT * FROM "logux_log" WHERE "id" = $1`, [
|
|
231
|
+
id
|
|
232
|
+
])
|
|
233
|
+
if (rows.length === 0) return [null, null]
|
|
234
|
+
return this.toEntry(rows[0])
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async changeMeta(id, diff) {
|
|
238
|
+
let rest = { ...diff }
|
|
239
|
+
delete rest.added
|
|
240
|
+
let rows = await this.query(
|
|
241
|
+
`UPDATE "logux_log"
|
|
242
|
+
SET "meta" = "meta" || $2::jsonb,
|
|
243
|
+
"reasons" = coalesce($3::text[], "reasons")
|
|
244
|
+
WHERE "id" = $1
|
|
245
|
+
RETURNING "id"`,
|
|
246
|
+
[id, toJson(rest), diff.reasons ?? null]
|
|
247
|
+
)
|
|
248
|
+
return rows.length > 0
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async clean() {
|
|
252
|
+
await this.query(`DELETE FROM "logux_log"`, [])
|
|
253
|
+
await this.query(`UPDATE "logux_extra" SET "value" = 0`, [])
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async get(opts = {}) {
|
|
257
|
+
let byAdded = opts.order === 'added'
|
|
258
|
+
let order = byAdded ? '"added" DESC' : '"sorted" DESC'
|
|
259
|
+
|
|
260
|
+
let load = async last => {
|
|
261
|
+
let params = new Params()
|
|
262
|
+
let where = []
|
|
263
|
+
if (typeof opts.index !== 'undefined') {
|
|
264
|
+
where.push(`${params.add(opts.index)} = ANY("indexes")`)
|
|
265
|
+
}
|
|
266
|
+
if (typeof opts.reason !== 'undefined') {
|
|
267
|
+
where.push(`${params.add(opts.reason)} = ANY("reasons")`)
|
|
268
|
+
}
|
|
269
|
+
if (last) {
|
|
270
|
+
if (byAdded) {
|
|
271
|
+
where.push(`"added" < ${params.add(last.added)}`)
|
|
272
|
+
} else {
|
|
273
|
+
where.push(`"sorted" < ${params.add(last.sorted)}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let rows = await this.query(
|
|
277
|
+
`SELECT * FROM "logux_log"
|
|
278
|
+
WHERE ${where.length > 0 ? where.join(' AND ') : 'TRUE'}
|
|
279
|
+
ORDER BY ${order}
|
|
280
|
+
LIMIT ${this.pageSize}`,
|
|
281
|
+
params.values
|
|
282
|
+
)
|
|
283
|
+
// `Log#each()` reads every page from the end, so the newest entries
|
|
284
|
+
// must be in the first page and the oldest one inside the page
|
|
285
|
+
let page = {
|
|
286
|
+
entries: rows.map(row => this.toEntry(row)).toReversed()
|
|
287
|
+
}
|
|
288
|
+
if (rows.length === this.pageSize) {
|
|
289
|
+
let oldest = rows[rows.length - 1]
|
|
290
|
+
page.next = () => load(oldest)
|
|
291
|
+
}
|
|
292
|
+
return page
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return load()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async getLastAdded() {
|
|
299
|
+
let rows = await this.query(
|
|
300
|
+
`SELECT "value" FROM "logux_extra" WHERE "key" = 'added'`,
|
|
301
|
+
[]
|
|
302
|
+
)
|
|
303
|
+
return Number(rows[0].value)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async getLastSynced() {
|
|
307
|
+
let rows = await this.query(
|
|
308
|
+
`SELECT "key", "value" FROM "logux_extra"
|
|
309
|
+
WHERE "key" IN ('received', 'sent')`,
|
|
310
|
+
[]
|
|
311
|
+
)
|
|
312
|
+
let synced = { received: 0, sent: 0 }
|
|
313
|
+
for (let row of rows) {
|
|
314
|
+
if (row.key === 'received') {
|
|
315
|
+
synced.received = Number(row.value)
|
|
316
|
+
} else {
|
|
317
|
+
synced.sent = Number(row.value)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return synced
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async init() {
|
|
324
|
+
if (this.transaction) {
|
|
325
|
+
await this.transaction(query => this.migrate(query, true))
|
|
326
|
+
} else {
|
|
327
|
+
await this.migrate(this.query, false)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async migrate(query, locked) {
|
|
332
|
+
// The lock is taken before the first table, so two servers can not race
|
|
333
|
+
// on `CREATE TABLE IF NOT EXISTS` and can not migrate twice
|
|
334
|
+
if (locked) {
|
|
335
|
+
await query(`SELECT pg_advisory_xact_lock(hashtext('logux_log'))`, [])
|
|
336
|
+
}
|
|
337
|
+
await query(
|
|
338
|
+
`CREATE TABLE IF NOT EXISTS "logux_version" ("version" bigint NOT NULL)`,
|
|
339
|
+
[]
|
|
340
|
+
)
|
|
341
|
+
let rows = await query(`SELECT "version" FROM "logux_version"`, [])
|
|
342
|
+
let version = rows.length > 0 ? Number(rows[0].version) : 0
|
|
343
|
+
if (version > MIGRATIONS.length) {
|
|
344
|
+
let error = new Error('Log from a newer server')
|
|
345
|
+
error.name = 'LoguxNewerDatabase'
|
|
346
|
+
throw error
|
|
347
|
+
}
|
|
348
|
+
if (version === MIGRATIONS.length) return
|
|
349
|
+
|
|
350
|
+
for (let migration of MIGRATIONS.slice(version)) {
|
|
351
|
+
for (let sql of migration) await query(sql, [])
|
|
352
|
+
}
|
|
353
|
+
await query(`DELETE FROM "logux_version"`, [])
|
|
354
|
+
await query(`INSERT INTO "logux_version" ("version") VALUES ($1)`, [
|
|
355
|
+
MIGRATIONS.length
|
|
356
|
+
])
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async remove(id) {
|
|
360
|
+
let rows = await this.query(
|
|
361
|
+
`DELETE FROM "logux_log" WHERE "id" = $1 RETURNING *`,
|
|
362
|
+
[id]
|
|
363
|
+
)
|
|
364
|
+
if (rows.length === 0) return false
|
|
365
|
+
return this.toEntry(rows[0])
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async removeReason(reasons, criteria, callback) {
|
|
369
|
+
let params = new Params()
|
|
370
|
+
let list = params.add(reasons)
|
|
371
|
+
let where = criteriaSql(criteria, params)
|
|
372
|
+
// The action, which lost the last reason, is deleted. Both statements
|
|
373
|
+
// are in the same query to not read the log twice, and they touch
|
|
374
|
+
// different rows, so the row is never changed twice by one query
|
|
375
|
+
let rows = await this.query(
|
|
376
|
+
`WITH "matched" AS (
|
|
377
|
+
SELECT "id", "reasons" FROM "logux_log"
|
|
378
|
+
WHERE ${where} AND "reasons" && ${list}::text[]
|
|
379
|
+
), "emptied" AS (
|
|
380
|
+
DELETE FROM "logux_log"
|
|
381
|
+
WHERE "id" IN (
|
|
382
|
+
SELECT "id" FROM "matched" WHERE "reasons" <@ ${list}::text[]
|
|
383
|
+
)
|
|
384
|
+
RETURNING *
|
|
385
|
+
), "kept" AS (
|
|
386
|
+
UPDATE "logux_log" SET "reasons" = (
|
|
387
|
+
SELECT coalesce(array_agg("old"."reason" ORDER BY "old"."at"), '{}')
|
|
388
|
+
FROM unnest("logux_log"."reasons")
|
|
389
|
+
WITH ORDINALITY AS "old"("reason", "at")
|
|
390
|
+
WHERE NOT ("old"."reason" = ANY(${list}::text[]))
|
|
391
|
+
)
|
|
392
|
+
WHERE "id" IN (
|
|
393
|
+
SELECT "id" FROM "matched" WHERE NOT ("reasons" <@ ${list}::text[])
|
|
394
|
+
)
|
|
395
|
+
RETURNING "id"
|
|
396
|
+
)
|
|
397
|
+
SELECT * FROM "emptied"`,
|
|
398
|
+
params.values
|
|
399
|
+
)
|
|
400
|
+
for (let row of rows) {
|
|
401
|
+
let [action, meta] = this.toEntry(row)
|
|
402
|
+
callback(action, meta)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async setLastSynced(values) {
|
|
407
|
+
let keys = []
|
|
408
|
+
let numbers = []
|
|
409
|
+
for (let [key, value] of Object.entries(values)) {
|
|
410
|
+
if (typeof value !== 'undefined') {
|
|
411
|
+
keys.push(key)
|
|
412
|
+
numbers.push(value)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (keys.length === 0) return
|
|
416
|
+
// Upsert, so a key added to `LastSynced` will not need a migration
|
|
417
|
+
await this.query(
|
|
418
|
+
`INSERT INTO "logux_extra" ("key", "value")
|
|
419
|
+
SELECT * FROM unnest($1::text[], $2::bigint[])
|
|
420
|
+
ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value"`,
|
|
421
|
+
[keys, numbers]
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
toAction(row) {
|
|
426
|
+
let action = fromJson(row.action)
|
|
427
|
+
let blob = row.blob
|
|
428
|
+
// Only the packer which wrote the blob can put the action back together
|
|
429
|
+
if (blob === null) return action
|
|
430
|
+
let type = row.type
|
|
431
|
+
let packer = this.packers[type]
|
|
432
|
+
if (!packer) throw new Error(`No packer to unpack ${type} action`)
|
|
433
|
+
// `pg` and `postgres` return `bytea` as `Buffer`
|
|
434
|
+
if (Buffer.isBuffer(blob)) blob = new Uint8Array(blob)
|
|
435
|
+
return packer.unpack({ action, blob })
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
toEntry(row) {
|
|
439
|
+
let meta = fromJson(row.meta)
|
|
440
|
+
meta.added = Number(row.added)
|
|
441
|
+
if ('reasons' in meta) meta.reasons = row.reasons
|
|
442
|
+
return [this.toAction(row), meta]
|
|
443
|
+
}
|
|
444
|
+
}
|
package/server/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Log } from '@logux/core'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
2
4
|
BaseServer,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
BaseServerOptions,
|
|
6
|
+
Logger,
|
|
7
|
+
ServerMeta
|
|
5
8
|
} from '../base-server/index.js'
|
|
6
9
|
|
|
7
10
|
export interface LogStream {
|
|
@@ -62,8 +65,9 @@ export interface ServerOptions extends BaseServerOptions {
|
|
|
62
65
|
* ```
|
|
63
66
|
*/
|
|
64
67
|
export class Server<
|
|
65
|
-
Headers extends object = unknown
|
|
66
|
-
|
|
68
|
+
Headers extends object = unknown,
|
|
69
|
+
ClientData extends object = unknown
|
|
70
|
+
> extends BaseServer<Headers, Log<ServerMeta>, ClientData> {
|
|
67
71
|
/**
|
|
68
72
|
* Server options.
|
|
69
73
|
*
|
package/server/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { glob } from 'node:fs/promises'
|
|
2
|
+
import { join, matchesGlob, relative } from 'node:path'
|
|
2
3
|
import { styleText } from 'node:util'
|
|
3
|
-
import { glob } from 'tinyglobby'
|
|
4
4
|
|
|
5
5
|
import { BaseServer } from '../base-server/index.js'
|
|
6
6
|
import { createReporter } from '../create-reporter/index.js'
|
|
@@ -120,14 +120,23 @@ export class Server extends BaseServer {
|
|
|
120
120
|
files = ['modules/*/index.js', 'modules/*.js', '!**/*.{test,spec}.js']
|
|
121
121
|
) {
|
|
122
122
|
if (!Array.isArray(files)) files = [files]
|
|
123
|
-
let
|
|
124
|
-
|
|
123
|
+
let ignore = files.filter(i => i.startsWith('!')).map(i => i.slice(1))
|
|
124
|
+
let patterns = files.filter(i => !i.startsWith('!'))
|
|
125
|
+
|
|
126
|
+
let matches = new Set()
|
|
127
|
+
for await (let entry of glob(patterns, {
|
|
125
128
|
cwd: this.options.root,
|
|
126
|
-
|
|
127
|
-
})
|
|
129
|
+
withFileTypes: true
|
|
130
|
+
})) {
|
|
131
|
+
if (!entry.isFile()) continue
|
|
132
|
+
let file = join(entry.parentPath, entry.name)
|
|
133
|
+
let path = relative(this.options.root, file)
|
|
134
|
+
if (ignore.some(i => matchesGlob(path, i))) continue
|
|
135
|
+
matches.add(file)
|
|
136
|
+
}
|
|
128
137
|
|
|
129
138
|
await Promise.all(
|
|
130
|
-
matches.map(async file => {
|
|
139
|
+
[...matches].map(async file => {
|
|
131
140
|
let serverModule = (await import(file)).default
|
|
132
141
|
if (typeof serverModule === 'function') {
|
|
133
142
|
await serverModule(this)
|
package/server-client/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { BaseServer } from '../base-server/index.js'
|
|
|
9
9
|
* const client = server.connected.get(0)
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
|
-
export class ServerClient {
|
|
12
|
+
export class ServerClient<ClientData extends object = unknown> {
|
|
13
13
|
/**
|
|
14
14
|
* Server, which received client.
|
|
15
15
|
*/
|
|
@@ -30,6 +30,23 @@ export class ServerClient {
|
|
|
30
30
|
*/
|
|
31
31
|
connection: ServerConnection
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Open structure to save data for the whole connection.
|
|
35
|
+
*
|
|
36
|
+
* ```js
|
|
37
|
+
* server.auth(async ({ client, userId, token }) => {
|
|
38
|
+
* let session = await findSession(token)
|
|
39
|
+
* if (!session) return false
|
|
40
|
+
* client.data.sessionId = session.id
|
|
41
|
+
* return true
|
|
42
|
+
* })
|
|
43
|
+
* server.on('disconnected', client => {
|
|
44
|
+
* touchSession(client.data.sessionId)
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
data: ClientData
|
|
49
|
+
|
|
33
50
|
/**
|
|
34
51
|
* HTTP headers of WS connection.
|
|
35
52
|
*
|
|
@@ -100,4 +117,11 @@ export class ServerClient {
|
|
|
100
117
|
* Disconnect client.
|
|
101
118
|
*/
|
|
102
119
|
destroy(): void
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wait until the client will confirm all sent actions.
|
|
123
|
+
*
|
|
124
|
+
* @returns Promise with `false` if the client was disconnected.
|
|
125
|
+
*/
|
|
126
|
+
drain(): Promise<boolean>
|
|
103
127
|
}
|
package/server-client/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LoguxError, parseId } from '@logux/core'
|
|
2
|
-
import
|
|
2
|
+
import { parseCookie } from 'cookie'
|
|
3
3
|
import fastq from 'fastq'
|
|
4
4
|
|
|
5
5
|
import { ALLOWED_META } from '../allowed-meta/index.js'
|
|
@@ -11,6 +11,8 @@ async function onSend(action, meta) {
|
|
|
11
11
|
return [action, filterMeta(meta)]
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function ignore() {}
|
|
15
|
+
|
|
14
16
|
function reportDetails(client) {
|
|
15
17
|
return {
|
|
16
18
|
connectionId: client.key,
|
|
@@ -70,7 +72,9 @@ export class ServerClient {
|
|
|
70
72
|
this.userId = undefined
|
|
71
73
|
this.clientId = undefined
|
|
72
74
|
this.nodeId = undefined
|
|
75
|
+
this.data = {}
|
|
73
76
|
this.processing = false
|
|
77
|
+
this.sending = new Set()
|
|
74
78
|
this.connection = connection
|
|
75
79
|
this.key = key.toString()
|
|
76
80
|
if (connection.ws) {
|
|
@@ -89,6 +93,7 @@ export class ServerClient {
|
|
|
89
93
|
onSend,
|
|
90
94
|
ping: app.options.ping,
|
|
91
95
|
subprotocol: app.options.subprotocol,
|
|
96
|
+
syncBatch: app.options.syncBatch,
|
|
92
97
|
timeout: app.options.timeout
|
|
93
98
|
})
|
|
94
99
|
if (this.app.env === 'development') {
|
|
@@ -99,11 +104,12 @@ export class ServerClient {
|
|
|
99
104
|
this.node.syncSinceQuery = async lastSynced => {
|
|
100
105
|
let context = new Context(app, this)
|
|
101
106
|
let entries = await app.connectLoader(context, lastSynced)
|
|
102
|
-
let added =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
let added = 0
|
|
108
|
+
for (let entry of entries) {
|
|
109
|
+
// `added` should be taken before `filterMeta()` removes it
|
|
110
|
+
if (entry[1].added > added) added = entry[1].added
|
|
111
|
+
entry[1] = filterMeta(entry[1])
|
|
112
|
+
}
|
|
107
113
|
return { added, entries }
|
|
108
114
|
}
|
|
109
115
|
}
|
|
@@ -157,7 +163,7 @@ export class ServerClient {
|
|
|
157
163
|
try {
|
|
158
164
|
result = await this.app.authenticator({
|
|
159
165
|
client: this,
|
|
160
|
-
cookie:
|
|
166
|
+
cookie: parseCookie(headers.cookie || ''),
|
|
161
167
|
headers: this.node.remoteHeaders,
|
|
162
168
|
token,
|
|
163
169
|
userId: this.userId
|
|
@@ -239,6 +245,23 @@ export class ServerClient {
|
|
|
239
245
|
this.app.connected.delete(this.key)
|
|
240
246
|
}
|
|
241
247
|
|
|
248
|
+
async drain() {
|
|
249
|
+
if (this.sending.size > 0) await Promise.all(this.sending)
|
|
250
|
+
if (this.destroyed || !this.node.connected) return false
|
|
251
|
+
if (this.node.syncing === 0) return true
|
|
252
|
+
return new Promise(resolve => {
|
|
253
|
+
let unbind = this.node.on('state', () => {
|
|
254
|
+
if (this.node.state === 'synchronized') {
|
|
255
|
+
unbind()
|
|
256
|
+
resolve(true)
|
|
257
|
+
} else if (!this.node.connected) {
|
|
258
|
+
unbind()
|
|
259
|
+
resolve(false)
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
onReceive(action, meta) {
|
|
243
266
|
if (this.app.actionToQueue.has(meta.id)) {
|
|
244
267
|
return Promise.resolve(false)
|
|
@@ -263,8 +286,10 @@ export class ServerClient {
|
|
|
263
286
|
|
|
264
287
|
if (isChannel) {
|
|
265
288
|
for (let channel of this.app.channels) {
|
|
266
|
-
let
|
|
267
|
-
|
|
289
|
+
let match = channel.regexp
|
|
290
|
+
? action.channel.match(channel.regexp)
|
|
291
|
+
: channel.pattern(action.channel)
|
|
292
|
+
if (match) {
|
|
268
293
|
queueName = channel.queue
|
|
269
294
|
break
|
|
270
295
|
}
|
|
@@ -299,4 +324,12 @@ export class ServerClient {
|
|
|
299
324
|
})
|
|
300
325
|
})
|
|
301
326
|
}
|
|
327
|
+
|
|
328
|
+
track(sending) {
|
|
329
|
+
let done = Promise.resolve(sending).then(ignore, ignore)
|
|
330
|
+
this.sending.add(done)
|
|
331
|
+
void done.then(() => {
|
|
332
|
+
this.sending.delete(done)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
302
335
|
}
|
package/test-client/index.d.ts
CHANGED
|
@@ -141,7 +141,7 @@ export class TestClient {
|
|
|
141
141
|
* @param test Function, where do you expect action will be received
|
|
142
142
|
* @returns Promise with all received actions
|
|
143
143
|
*/
|
|
144
|
-
collect(test: () => Promise<
|
|
144
|
+
collect(test: () => Promise<unknown>): Promise<Action[]>
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* Connect to test server.
|
|
@@ -180,7 +180,10 @@ export class TestClient {
|
|
|
180
180
|
* @param meta Optional action’s meta.
|
|
181
181
|
* @returns Promise until `logux/processed` answer.
|
|
182
182
|
*/
|
|
183
|
-
process
|
|
183
|
+
process<TypeAction extends Action = AnyAction>(
|
|
184
|
+
action: TypeAction,
|
|
185
|
+
meta?: Partial<ServerMeta>
|
|
186
|
+
): Promise<Action[]>
|
|
184
187
|
|
|
185
188
|
/**
|
|
186
189
|
* Collect actions received from server during the `test` call.
|