matrix-nodejs-homeserver 0.3.2 → 0.3.4

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.
@@ -1,5 +1,8 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  //#region src/storage/sql.ts
4
+ /** Parameters per statement for multi-row writes: within SQLite's (32766), Postgres' and MySQL's (65535) limits. */
5
+ const MAX_PARAMS = 4e3;
3
6
  const OPS = {
4
7
  eq: "=",
5
8
  ne: "<>",
@@ -60,6 +63,12 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
60
63
  return out;
61
64
  };
62
65
  const placeholder = (i) => dialect === "postgres" ? `$${i}` : "?";
66
+ const limitClause = (limit, params) => {
67
+ const n = Math.max(0, Math.floor(limit));
68
+ if (dialect === "mysql") return ` LIMIT ${n}`;
69
+ params.push(n);
70
+ return ` LIMIT ${placeholder(params.length)}`;
71
+ };
63
72
  const buildWhere = (model, where = [], params) => {
64
73
  if (where.length === 0) return "";
65
74
  return ` WHERE ${where.map((w) => {
@@ -67,6 +76,10 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
67
76
  if (op === "in") {
68
77
  const values = w.value;
69
78
  if (values.length === 0) return "1=0";
79
+ if (dialect === "postgres") {
80
+ params.push(values.map((v) => toDb(model, w.field, v)));
81
+ return `${q(w.field)} = ANY(${placeholder(params.length)})`;
82
+ }
70
83
  const ph = values.map((v) => {
71
84
  params.push(toDb(model, w.field, v));
72
85
  return placeholder(params.length);
@@ -83,11 +96,15 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
83
96
  };
84
97
  const build = (executor, inTx) => {
85
98
  const insert = async (model, data, mode) => {
86
- const fields = Object.keys(data).filter((k) => data[k] !== void 0);
87
- const params = fields.map((f) => toDb(model, f, data[f]));
99
+ const rows = Array.isArray(data) ? data : [data];
100
+ const fields = Object.keys(rows[0]).filter((k) => rows[0][k] !== void 0);
101
+ const params = [];
102
+ const values = rows.map((row) => `(${fields.map((f) => {
103
+ params.push(toDb(model, f, row[f]));
104
+ return placeholder(params.length);
105
+ }).join(", ")})`);
88
106
  const cols = fields.map(q).join(", ");
89
- const ph = fields.map((_, i) => placeholder(i + 1)).join(", ");
90
- let sql = `INSERT INTO ${t(model)} (${cols}) VALUES (${ph})`;
107
+ let sql = `INSERT INTO ${t(model)} (${cols}) VALUES ${values.join(", ")}`;
91
108
  if (mode === "upsert") {
92
109
  const pk = schema[model]?.primaryKey ?? [];
93
110
  const rest = fields.filter((f) => !pk.includes(f));
@@ -123,6 +140,20 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
123
140
  },
124
141
  create: (model, data) => insert(model, data, "insert"),
125
142
  upsert: (model, data) => insert(model, data, "upsert"),
143
+ async upsertMany(model, rows) {
144
+ const pk = schema[model]?.primaryKey ?? [];
145
+ const byKey = /* @__PURE__ */ new Map();
146
+ for (const row of rows) byKey.set(JSON.stringify(pk.map((f) => row[f])), row);
147
+ const groups = /* @__PURE__ */ new Map();
148
+ for (const row of byKey.values()) {
149
+ const shape = Object.keys(row).filter((k) => row[k] !== void 0).join("\0");
150
+ groups.set(shape, [...groups.get(shape) ?? [], row]);
151
+ }
152
+ for (const group of groups.values()) {
153
+ const perStatement = Math.max(1, Math.floor(MAX_PARAMS / Object.keys(group[0]).length));
154
+ for (let i = 0; i < group.length; i += perStatement) await insert(model, group.slice(i, i + perStatement), "upsert");
155
+ }
156
+ },
126
157
  async findOne(model, where) {
127
158
  const params = [];
128
159
  const rows = await executor.query(`SELECT * FROM ${t(model)}${buildWhere(model, where, params)} LIMIT 1`, params);
@@ -132,25 +163,26 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
132
163
  const params = [];
133
164
  let sql = `SELECT ${opts.fields?.length ? opts.fields.map(q).join(", ") : "*"} FROM ${t(model)}${buildWhere(model, opts.where, params)}`;
134
165
  if (opts.sortBy) sql += ` ORDER BY ${q(opts.sortBy.field)} ${opts.sortBy.direction === "asc" ? "ASC" : "DESC"}`;
135
- if (opts.limit !== void 0) sql += ` LIMIT ${Math.max(0, Math.floor(opts.limit))}`;
166
+ if (opts.limit !== void 0) sql += limitClause(opts.limit, params);
136
167
  return (await executor.query(sql, params)).map((r) => fromDb(model, r));
137
168
  },
138
- ...dialect === "postgres" ? { async findManyBatch(queries) {
169
+ ...dialect === "postgres" ? { async findManyBatch(queries, options) {
139
170
  if (queries.length === 0) return [];
140
171
  const params = [];
141
172
  const parts = queries.map(({ model, opts = {} }, i) => {
142
173
  const fields = opts.fields?.length && opts.sortBy && !opts.fields.includes(opts.sortBy.field) ? [...opts.fields, opts.sortBy.field] : opts.fields;
143
174
  let inner = `SELECT ${fields?.length ? fields.map(q).join(", ") : "*"} FROM ${t(model)}${buildWhere(model, opts.where, params)}`;
144
- const order = opts.sortBy ? `${q(opts.sortBy.field)} ${opts.sortBy.direction === "asc" ? "ASC" : "DESC"}` : "";
145
- if (order) inner += ` ORDER BY ${order}`;
146
- if (opts.limit !== void 0) inner += ` LIMIT ${Math.max(0, Math.floor(opts.limit))}`;
147
- return `SELECT ${i} AS i, row_number() OVER (${order ? `ORDER BY ${order.replace(/^"/, "b.\"")}` : ""}) AS n, to_jsonb(b) AS r FROM (${inner}) b`;
175
+ const direction = opts.sortBy?.direction === "asc" ? "ASC" : "DESC";
176
+ if (opts.sortBy) inner += ` ORDER BY ${q(opts.sortBy.field)} ${direction}`;
177
+ if (opts.limit !== void 0) inner += limitClause(opts.limit, params);
178
+ return `SELECT ${i} AS i, (SELECT json_agg(b${opts.sortBy ? ` ORDER BY b.${q(opts.sortBy.field)} ${direction}` : ""}) FROM (${inner}) b) AS r`;
148
179
  });
149
- const rows = await executor.query(`${parts.join(" UNION ALL ")} ORDER BY i, n`, params);
180
+ const rows = await executor.query(parts.join(" UNION ALL "), params, options);
150
181
  const out = queries.map(() => []);
151
182
  for (const row of rows) {
152
- const r = typeof row.r === "string" ? JSON.parse(row.r) : row.r;
153
- out[Number(row.i)].push(fromDb(queries[Number(row.i)].model, r));
183
+ const list = typeof row.r === "string" ? JSON.parse(row.r) : row.r;
184
+ const model = queries[Number(row.i)].model;
185
+ out[Number(row.i)] = (list ?? []).map((r) => fromDb(model, r));
154
186
  }
155
187
  return out;
156
188
  } } : {},
@@ -175,21 +207,36 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
175
207
  };
176
208
  return build(rootExecutor, false);
177
209
  }
210
+ /** How long SQLite statements may run back to back before yielding to the event loop (#47, #52). */
211
+ const YIELD_AFTER_MS = 50;
212
+ /** Prepared statements an SQLite adapter keeps (#40). */
213
+ const STATEMENT_CACHE = 500;
178
214
  /** SQLite adapter for `node:sqlite` (`new DatabaseSync(path)`) or `better-sqlite3`. */
179
215
  function sqliteAdapter(db, options = {}) {
180
216
  try {
181
217
  db.exec("PRAGMA journal_mode = WAL");
218
+ db.exec(`PRAGMA synchronous = ${options.synchronous === "full" ? "FULL" : "NORMAL"}`);
182
219
  } catch {}
183
220
  const cache = /* @__PURE__ */ new Map();
184
221
  const stmt = (sql) => {
185
222
  let s = cache.get(sql);
186
- if (!s) cache.set(sql, s = db.prepare(sql));
223
+ if (s) cache.delete(sql);
224
+ else s = db.prepare(sql);
225
+ cache.set(sql, s);
226
+ if (cache.size > STATEMENT_CACHE) cache.delete(cache.keys().next().value);
187
227
  return s;
188
228
  };
189
229
  const inTransaction = new AsyncLocalStorage();
190
230
  let gate = Promise.resolve();
231
+ let lastYield = performance.now();
191
232
  const waitGate = async () => {
192
- if (!inTransaction.getStore()) await gate;
233
+ if (inTransaction.getStore()) return;
234
+ await gate;
235
+ if (performance.now() - lastYield > YIELD_AFTER_MS) {
236
+ await new Promise((resolve) => setImmediate(resolve));
237
+ lastYield = performance.now();
238
+ await gate;
239
+ }
193
240
  };
194
241
  const executor = {
195
242
  async query(sql, params) {
@@ -229,29 +276,80 @@ function sqliteAdapter(db, options = {}) {
229
276
  }
230
277
  });
231
278
  }
232
- const pgExecutor = (conn) => ({
233
- async query(sql, params) {
234
- return (await conn.query(sql, params)).rows;
235
- },
236
- async execute(sql, params) {
237
- return (await conn.query(sql, params)).rowCount ?? 0;
238
- }
239
- });
279
+ /**
280
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/40: unnamed statements are parsed and
281
+ * planned by Postgres on every call, which took most of its CPU under load. Named ones are prepared
282
+ * once per connection and reused. Statement texts depend only on the code (lists and limits are
283
+ * parameters), so there are a few hundred of them; past `MAX_PREPARED` distinct texts, further
284
+ * ones run unnamed, which bounds the memory both node-postgres and Postgres keep per connection.
285
+ */
286
+ const MAX_PREPARED = 2e3;
287
+ function statementNamer(enabled) {
288
+ let names = /* @__PURE__ */ new Map();
289
+ let generation = 0;
290
+ return {
291
+ name(sql) {
292
+ if (!enabled) return void 0;
293
+ let name = names.get(sql);
294
+ if (!name && names.size < MAX_PREPARED) names.set(sql, name = `m${generation}_${createHash("sha1").update(sql).digest("base64url")}`);
295
+ return name;
296
+ },
297
+ reset() {
298
+ names = /* @__PURE__ */ new Map();
299
+ generation++;
300
+ }
301
+ };
302
+ }
303
+ /**
304
+ * A prepared `SELECT *` fails once its table has gained a column, for example when another node of
305
+ * a cluster migrated the schema during a rolling update. Then the statements are prepared again
306
+ * under new names and the query is retried once.
307
+ */
308
+ const isStalePlan = (err) => err?.code === "0A000" && /cached plan/.test(String(err.message));
309
+ const pgExecutor = (conn, namer) => {
310
+ const run = async (sql, params, prepare = true) => {
311
+ const name = () => prepare ? namer.name(sql) : void 0;
312
+ try {
313
+ return await conn.query({
314
+ text: sql,
315
+ values: params,
316
+ name: name()
317
+ });
318
+ } catch (err) {
319
+ if (!isStalePlan(err)) throw err;
320
+ namer.reset();
321
+ return conn.query({
322
+ text: sql,
323
+ values: params,
324
+ name: name()
325
+ });
326
+ }
327
+ };
328
+ return {
329
+ async query(sql, params, options) {
330
+ return (await run(sql, params, options?.prepare)).rows;
331
+ },
332
+ async execute(sql, params) {
333
+ return (await run(sql, params)).rowCount ?? 0;
334
+ }
335
+ };
336
+ };
240
337
  /** PostgreSQL adapter for `pg` (node-postgres). Pass a `Pool` to get transactions. */
241
338
  function postgresAdapter(pool, options = {}) {
339
+ const namer = statementNamer(options.preparedStatements ?? true);
242
340
  return sqlAdapter({
243
341
  dialect: "postgres",
244
342
  tablePrefix: options.tablePrefix,
245
- executor: pgExecutor(pool),
343
+ executor: pgExecutor(pool, namer),
246
344
  transaction: pool.connect ? async (fn) => {
247
345
  const client = await pool.connect();
248
346
  try {
249
- await client.query("BEGIN");
250
- const result = await fn(pgExecutor(client));
251
- await client.query("COMMIT");
347
+ await client.query({ text: "BEGIN" });
348
+ const result = await fn(pgExecutor(client, namer));
349
+ await client.query({ text: "COMMIT" });
252
350
  return result;
253
351
  } catch (err) {
254
- await client.query("ROLLBACK").catch(() => {});
352
+ await client.query({ text: "ROLLBACK" }).catch(() => {});
255
353
  throw err;
256
354
  } finally {
257
355
  client.release();
@@ -21,6 +21,14 @@ export interface WhereClause {
21
21
  operator?: Operator;
22
22
  }
23
23
  export type Row = Record<string, any>;
24
+ /** How a `findManyBatch` is run. */
25
+ export interface BatchOptions {
26
+ /**
27
+ * The combination of reads is a one-off (as when unrelated reads are coalesced), so preparing
28
+ * the statement for reuse would only fill the database's statement cache. Default true.
29
+ */
30
+ prepare?: boolean;
31
+ }
24
32
  /** One read of a `findManyBatch`. */
25
33
  export interface BatchQuery {
26
34
  model: string;
@@ -50,12 +58,18 @@ export interface DatabaseAdapter {
50
58
  * an adapter that talks to a database server can send them in one round trip. Without it,
51
59
  * callers run the reads separately (see `readBatch`).
52
60
  */
53
- findManyBatch?(queries: BatchQuery[]): Promise<Row[][]>;
61
+ findManyBatch?(queries: BatchQuery[], options?: BatchOptions): Promise<Row[][]>;
54
62
  /** Create tables / collections for the given schema. Must be idempotent. */
55
63
  migrate?(schema: Schema): Promise<void>;
56
64
  create(model: string, data: Row): Promise<void>;
57
65
  /** Insert or replace by primary key. */
58
66
  upsert(model: string, data: Row): Promise<void>;
67
+ /**
68
+ * `upsert` for several rows at once (the last row wins when two share a primary key). Optional;
69
+ * an adapter that talks to a database server can write them in a few statements. Without it,
70
+ * callers upsert the rows one by one (see `upsertMany`).
71
+ */
72
+ upsertMany?(model: string, rows: Row[]): Promise<void>;
59
73
  findOne(model: string, where: WhereClause[]): Promise<Row | null>;
60
74
  findMany(model: string, options?: FindManyOptions): Promise<Row[]>;
61
75
  update(model: string, where: WhereClause[], data: Row): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "matrix-nodejs-homeserver",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "A framework- and database-agnostic Matrix homeserver library for Node.js",
5
5
  "keywords": [
6
6
  "chat",