@logux/server 0.15.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.
@@ -25,15 +25,27 @@ function addTarget(targets, client, action, meta) {
25
25
  }
26
26
  }
27
27
 
28
- function sendTargets(targets) {
28
+ function sendTargets(app, targets, settled) {
29
29
  for (let [client, entries] of targets) {
30
30
  let sending = client.node.onAdd(entries)
31
31
  // Only `ServerClient` tracks sending for `Server#drain()`
32
32
  if (client.track) client.track(sending)
33
33
  }
34
+ if (settled > 0) {
35
+ for (let client of app.connected.values()) {
36
+ if (!targets.has(client) && client.node.lastAddedCache < settled) {
37
+ client.node.lastAddedCache = settled
38
+ }
39
+ }
40
+ }
34
41
  targets.clear()
35
42
  }
36
43
 
44
+ function maxAdded(settled, meta) {
45
+ if (typeof meta.added === 'undefined' || meta.added < settled) return settled
46
+ return meta.added
47
+ }
48
+
37
49
  function optionError(msg) {
38
50
  let error = new Error(msg)
39
51
  error.logux = true
@@ -939,22 +951,24 @@ export class BaseServer {
939
951
  let targets = new Map()
940
952
  let waiting = this.resolveTargets(action, meta, targets)
941
953
  if (waiting) await waiting
942
- sendTargets(targets)
954
+ sendTargets(this, targets, maxAdded(0, meta))
943
955
  }
944
956
 
945
957
  sendBatch(entries) {
946
958
  let targets = new Map()
947
959
  let waiting = []
960
+ let settled = 0
948
961
  for (let [action, meta] of entries) {
962
+ settled = maxAdded(settled, meta)
949
963
  let promise = this.resolveTargets(action, meta, targets)
950
964
  if (promise) waiting.push(promise)
951
965
  }
952
966
  if (waiting.length === 0) {
953
- sendTargets(targets)
967
+ sendTargets(this, targets, settled)
954
968
  return undefined
955
969
  }
956
970
  return Promise.all(waiting).then(() => {
957
- sendTargets(targets)
971
+ sendTargets(this, targets, settled)
958
972
  })
959
973
  }
960
974
 
package/index.d.ts CHANGED
@@ -18,6 +18,13 @@ export {
18
18
  } from './base-server/index.js'
19
19
  export { Context, ConnectContext, ChannelContext } from './context/index.js'
20
20
  export { filterMeta } from './filter-meta/index.js'
21
+ export {
22
+ PostgresDriver,
23
+ PostgresQuery,
24
+ PostgresRows,
25
+ PostgresStore,
26
+ PostgresStoreOptions
27
+ } from './postgres-store/index.js'
21
28
  export {
22
29
  del,
23
30
  get,
package/index.js CHANGED
@@ -8,6 +8,7 @@ export { ALLOWED_META } from './allowed-meta/index.js'
8
8
  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
+ export { PostgresStore } from './postgres-store/index.js'
11
12
  export { ResponseError } from './request/index.js'
12
13
  export { Server } from './server/index.js'
13
14
  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.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Build own Logux server",
5
5
  "keywords": [
6
6
  "collaborative",
@@ -24,8 +24,8 @@
24
24
  "./package.json": "./package.json"
25
25
  },
26
26
  "dependencies": {
27
- "@logux/actions": "^0.6.0",
28
- "@logux/core": "^0.11.0",
27
+ "@logux/actions": "^0.7.2",
28
+ "@logux/core": "^0.11.6",
29
29
  "cookie": "^2.0.1",
30
30
  "fastq": "^1.20.1",
31
31
  "nanoevents": "^10.0.0",
@@ -0,0 +1,149 @@
1
+ import type { ActionPackerMap } from '@logux/actions'
2
+ import type {
3
+ Action,
4
+ AnyAction,
5
+ Criteria,
6
+ LogPage,
7
+ LogStore,
8
+ Meta
9
+ } from '@logux/core'
10
+
11
+ /**
12
+ * Logux declares these two, but does not export them, so they are taken
13
+ * from the methods not to repeat the definition here.
14
+ */
15
+ type GetOptions = NonNullable<Parameters<LogStore['get']>[0]>
16
+ type LastSynced = Awaited<ReturnType<LogStore['getLastSynced']>>
17
+
18
+ /**
19
+ * Rows of the query. `pg` and PGlite keep them in the result object,
20
+ * `postgres` returns them as an array.
21
+ */
22
+ export type PostgresRows =
23
+ | { rows: Record<string, unknown>[] }
24
+ | Record<string, unknown>[]
25
+
26
+ /**
27
+ * Minimal database interface for the drivers, which the store does not
28
+ * support out of the box.
29
+ *
30
+ * ```js
31
+ * let query = (sql, params) => driver.run(sql, params)
32
+ * query.transaction = body => {
33
+ * return driver.tx(tx => body((sql, params) => tx.run(sql, params)))
34
+ * }
35
+ *
36
+ * let store = new PostgresStore(query)
37
+ * ```
38
+ */
39
+ export interface PostgresQuery {
40
+ (sql: string, params: unknown[]): Promise<PostgresRows>
41
+
42
+ /**
43
+ * Run the callback on a single connection inside a transaction.
44
+ *
45
+ * Without it `init()` can not lock the migrations, so two servers
46
+ * starting at the same moment can apply them twice.
47
+ */
48
+ transaction?: (body: (query: PostgresQuery) => Promise<void>) => Promise<void>
49
+ }
50
+
51
+ /**
52
+ * Database driver, which the store can use without any wrapper:
53
+ * `pg`’s `Pool` or `Client`, PGlite, or `postgres`.
54
+ *
55
+ * The parameters are `never[]`, so that the driver’s own stricter types
56
+ * for them still match this type.
57
+ */
58
+ export type PostgresDriver =
59
+ | {
60
+ begin?(body: (tx: PostgresDriver) => Promise<unknown>): Promise<unknown>
61
+ unsafe(sql: string, params: never[]): Promise<unknown>
62
+ }
63
+ | {
64
+ connect?(): Promise<unknown>
65
+ query(sql: string, params: never[]): Promise<unknown>
66
+ transaction?(
67
+ body: (tx: PostgresDriver) => Promise<unknown>
68
+ ): Promise<unknown>
69
+ }
70
+
71
+ export interface PostgresStoreOptions<Packers = Record<string, never>> {
72
+ /**
73
+ * How many entries to load by a single query in `get()`.
74
+ */
75
+ pageSize?: number
76
+
77
+ /**
78
+ * Packers to keep the binary parts of the action in the `blob` column
79
+ * instead of Base64 inside JSON. The key must be the `type` of the action.
80
+ *
81
+ * `zeroPacker` is always added, so this is only for custom actions.
82
+ */
83
+ packers?: Packers
84
+ }
85
+
86
+ /**
87
+ * Log store, which keeps actions in PostgreSQL.
88
+ *
89
+ * It takes the database of `pg`, `postgres`, or PGlite. For any other driver,
90
+ * pass the function to send the query, see {@link PostgresQuery}.
91
+ *
92
+ * ```js
93
+ * import { PostgresStore, Server } from '@logux/server'
94
+ * import { Pool } from 'pg'
95
+ *
96
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL })
97
+ * const store = new PostgresStore(pool)
98
+ * await store.init()
99
+ *
100
+ * const server = new Server(Server.loadOptions(process, {
101
+ * minSubprotocol: 1,
102
+ * subprotocol: 1,
103
+ * root: import.meta.dirname,
104
+ * store
105
+ * }))
106
+ * ```
107
+ */
108
+ export class PostgresStore<
109
+ Packers extends ActionPackerMap<Packers> = Record<string, never>
110
+ >
111
+ implements LogStore
112
+ {
113
+ constructor(
114
+ db: PostgresDriver | PostgresQuery,
115
+ opts?: PostgresStoreOptions<Packers>
116
+ )
117
+
118
+ add(action: AnyAction, meta: Meta): Promise<false | Meta>
119
+
120
+ addReason(reasons: string[], criteria: Criteria): Promise<void>
121
+
122
+ byId(id: string): Promise<[Action, Meta] | [null, null]>
123
+
124
+ changeMeta(id: string, diff: Partial<Meta>): Promise<boolean>
125
+
126
+ clean(): Promise<void>
127
+
128
+ get(opts?: GetOptions): Promise<LogPage>
129
+
130
+ getLastAdded(): Promise<number>
131
+
132
+ getLastSynced(): Promise<LastSynced>
133
+
134
+ /**
135
+ * Bring the log tables to the latest version, creating them if they were
136
+ * not created by the application’s migration.
137
+ */
138
+ init(): Promise<void>
139
+
140
+ remove(id: string): Promise<[Action, Meta] | false>
141
+
142
+ removeReason(
143
+ reasons: string[],
144
+ criteria: Criteria,
145
+ callback: (action: Action, meta: Meta) => void
146
+ ): Promise<void>
147
+
148
+ setLastSynced(values: Partial<LastSynced>): Promise<void>
149
+ }
@@ -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
+ }