matrix-nodejs-homeserver 0.3.1 → 0.3.3

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.
@@ -192,7 +192,11 @@ const schema = {
192
192
  ts: n
193
193
  },
194
194
  primaryKey: ["user_id", "event_id"],
195
- indexes: [["user_id", "room_id"], ["user_id", "stream"]]
195
+ indexes: [[
196
+ "user_id",
197
+ "room_id",
198
+ "stream"
199
+ ], ["user_id", "stream"]]
196
200
  },
197
201
  sso_identities: {
198
202
  fields: {
@@ -249,7 +253,7 @@ const schema = {
249
253
  forgotten: b
250
254
  },
251
255
  primaryKey: ["room_id", "user_id"],
252
- indexes: [["user_id"]]
256
+ indexes: [["user_id"], ["stream"]]
253
257
  },
254
258
  room_aliases: {
255
259
  fields: {
@@ -397,7 +401,8 @@ const schema = {
397
401
  last_active_ts: n,
398
402
  stream: n
399
403
  },
400
- primaryKey: ["user_id"]
404
+ primaryKey: ["user_id"],
405
+ indexes: [["stream"]]
401
406
  },
402
407
  media: {
403
408
  fields: {
@@ -2,8 +2,10 @@ import { DatabaseAdapter, Row } from "./types.js";
2
2
  //#region src/storage/sql.d.ts
3
3
  export type SqlDialect = "sqlite" | "postgres" | "mysql";
4
4
  export interface SqlExecutor {
5
- /** Run a query that returns rows. */
6
- query(sql: string, params: unknown[]): Promise<Row[]>;
5
+ /** Run a query that returns rows; `prepare: false` when the statement is not worth preparing for reuse. */
6
+ query(sql: string, params: unknown[], options?: {
7
+ prepare?: boolean;
8
+ }): Promise<Row[]>;
7
9
  /** Run a statement; resolve with the number of affected rows. */
8
10
  execute(sql: string, params: unknown[]): Promise<number>;
9
11
  }
@@ -36,9 +38,20 @@ export interface SqliteDatabaseLike {
36
38
  /** SQLite adapter for `node:sqlite` (`new DatabaseSync(path)`) or `better-sqlite3`. */
37
39
  export declare function sqliteAdapter(db: SqliteDatabaseLike, options?: {
38
40
  tablePrefix?: string;
41
+ /**
42
+ * When commits reach the disk (SQLite's `synchronous`). `"normal"` (the default): at
43
+ * checkpoints; a commit survives a crash of the process, and a power loss or operating
44
+ * system crash can roll back the most recent ones, without corrupting the database.
45
+ * `"full"`: on every commit, which blocks the event loop for an fsync each time.
46
+ */
47
+ synchronous?: "normal" | "full";
39
48
  }): DatabaseAdapter;
40
49
  interface PgQueryable {
41
- query(text: string, values?: unknown[]): Promise<{
50
+ query(config: {
51
+ text: string;
52
+ values?: unknown[];
53
+ name?: string;
54
+ }): Promise<{
42
55
  rows: Row[];
43
56
  rowCount: number | null;
44
57
  }>;
@@ -53,6 +66,12 @@ export interface PgPoolLike extends PgQueryable {
53
66
  /** PostgreSQL adapter for `pg` (node-postgres). Pass a `Pool` to get transactions. */
54
67
  export declare function postgresAdapter(pool: PgPoolLike, options?: {
55
68
  tablePrefix?: string;
69
+ /**
70
+ * Prepare statements on each connection and reuse them (default true). Turn off behind a
71
+ * connection pooler that does not support prepared statements, such as PgBouncer before 1.21
72
+ * or in transaction mode without `max_prepared_statements`.
73
+ */
74
+ preparedStatements?: boolean;
56
75
  }): DatabaseAdapter;
57
76
  interface MysqlQueryable {
58
77
  query(sql: string, values?: unknown[]): Promise<[any, unknown]>;
@@ -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);
@@ -130,11 +161,31 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
130
161
  },
131
162
  async findMany(model, opts = {}) {
132
163
  const params = [];
133
- let sql = `SELECT * FROM ${t(model)}${buildWhere(model, opts.where, params)}`;
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
  },
169
+ ...dialect === "postgres" ? { async findManyBatch(queries, options) {
170
+ if (queries.length === 0) return [];
171
+ const params = [];
172
+ const parts = queries.map(({ model, opts = {} }, i) => {
173
+ const fields = opts.fields?.length && opts.sortBy && !opts.fields.includes(opts.sortBy.field) ? [...opts.fields, opts.sortBy.field] : opts.fields;
174
+ let inner = `SELECT ${fields?.length ? fields.map(q).join(", ") : "*"} FROM ${t(model)}${buildWhere(model, opts.where, params)}`;
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`;
179
+ });
180
+ const rows = await executor.query(parts.join(" UNION ALL "), params, options);
181
+ const out = queries.map(() => []);
182
+ for (const row of rows) {
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));
186
+ }
187
+ return out;
188
+ } } : {},
138
189
  async update(model, where, data) {
139
190
  const fields = Object.keys(data).filter((k) => data[k] !== void 0);
140
191
  if (fields.length === 0) return 0;
@@ -156,21 +207,36 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
156
207
  };
157
208
  return build(rootExecutor, false);
158
209
  }
210
+ /** How long SQLite statements may run back to back before yielding to the event loop (#47). */
211
+ const YIELD_AFTER_MS = 10;
212
+ /** Prepared statements an SQLite adapter keeps (#40). */
213
+ const STATEMENT_CACHE = 500;
159
214
  /** SQLite adapter for `node:sqlite` (`new DatabaseSync(path)`) or `better-sqlite3`. */
160
215
  function sqliteAdapter(db, options = {}) {
161
216
  try {
162
217
  db.exec("PRAGMA journal_mode = WAL");
218
+ db.exec(`PRAGMA synchronous = ${options.synchronous === "full" ? "FULL" : "NORMAL"}`);
163
219
  } catch {}
164
220
  const cache = /* @__PURE__ */ new Map();
165
221
  const stmt = (sql) => {
166
222
  let s = cache.get(sql);
167
- 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);
168
227
  return s;
169
228
  };
170
229
  const inTransaction = new AsyncLocalStorage();
171
230
  let gate = Promise.resolve();
231
+ let lastYield = performance.now();
172
232
  const waitGate = async () => {
173
- 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
+ }
174
240
  };
175
241
  const executor = {
176
242
  async query(sql, params) {
@@ -210,29 +276,80 @@ function sqliteAdapter(db, options = {}) {
210
276
  }
211
277
  });
212
278
  }
213
- const pgExecutor = (conn) => ({
214
- async query(sql, params) {
215
- return (await conn.query(sql, params)).rows;
216
- },
217
- async execute(sql, params) {
218
- return (await conn.query(sql, params)).rowCount ?? 0;
219
- }
220
- });
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
+ };
221
337
  /** PostgreSQL adapter for `pg` (node-postgres). Pass a `Pool` to get transactions. */
222
338
  function postgresAdapter(pool, options = {}) {
339
+ const namer = statementNamer(options.preparedStatements ?? true);
223
340
  return sqlAdapter({
224
341
  dialect: "postgres",
225
342
  tablePrefix: options.tablePrefix,
226
- executor: pgExecutor(pool),
343
+ executor: pgExecutor(pool, namer),
227
344
  transaction: pool.connect ? async (fn) => {
228
345
  const client = await pool.connect();
229
346
  try {
230
- await client.query("BEGIN");
231
- const result = await fn(pgExecutor(client));
232
- await client.query("COMMIT");
347
+ await client.query({ text: "BEGIN" });
348
+ const result = await fn(pgExecutor(client, namer));
349
+ await client.query({ text: "COMMIT" });
233
350
  return result;
234
351
  } catch (err) {
235
- await client.query("ROLLBACK").catch(() => {});
352
+ await client.query({ text: "ROLLBACK" }).catch(() => {});
236
353
  throw err;
237
354
  } finally {
238
355
  client.release();
@@ -21,8 +21,26 @@ 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
+ }
32
+ /** One read of a `findManyBatch`. */
33
+ export interface BatchQuery {
34
+ model: string;
35
+ opts?: FindManyOptions;
36
+ }
24
37
  export interface FindManyOptions {
25
38
  where?: WhereClause[];
39
+ /**
40
+ * Only these fields are needed. Adapters should read just these (a smaller query and less to
41
+ * parse); one that ignores the option and returns whole rows is still correct.
42
+ */
43
+ fields?: string[];
26
44
  sortBy?: {
27
45
  field: string;
28
46
  direction: "asc" | "desc";
@@ -35,11 +53,23 @@ export interface FindManyOptions {
35
53
  * All `where` clauses are combined with AND.
36
54
  */
37
55
  export interface DatabaseAdapter {
56
+ /**
57
+ * Several independent reads in one go: the results in the same order as the queries. Optional;
58
+ * an adapter that talks to a database server can send them in one round trip. Without it,
59
+ * callers run the reads separately (see `readBatch`).
60
+ */
61
+ findManyBatch?(queries: BatchQuery[], options?: BatchOptions): Promise<Row[][]>;
38
62
  /** Create tables / collections for the given schema. Must be idempotent. */
39
63
  migrate?(schema: Schema): Promise<void>;
40
64
  create(model: string, data: Row): Promise<void>;
41
65
  /** Insert or replace by primary key. */
42
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>;
43
73
  findOne(model: string, where: WhereClause[]): Promise<Row | null>;
44
74
  findMany(model: string, options?: FindManyOptions): Promise<Row[]>;
45
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.1",
3
+ "version": "0.3.3",
4
4
  "description": "A framework- and database-agnostic Matrix homeserver library for Node.js",
5
5
  "keywords": [
6
6
  "chat",
@@ -41,10 +41,12 @@
41
41
  },
42
42
  "scripts": {
43
43
  "typecheck": "tsc -p tsconfig.json",
44
+ "test": "node --test \"test/**/*.test.ts\"",
44
45
  "lint": "oxlint",
45
46
  "format": "oxfmt .",
46
47
  "format:check": "oxfmt --check .",
47
48
  "build": "tsdown",
49
+ "check:package": "publint && attw --pack . --profile esm-only",
48
50
  "prepare": "npm run build",
49
51
  "start:example": "node examples/node-http.ts",
50
52
  "start:multi-node-example": "node examples/multi-node.ts",
@@ -53,15 +55,17 @@
53
55
  "release": "changeset publish"
54
56
  },
55
57
  "devDependencies": {
58
+ "@arethetypeswrong/cli": "^0.18.5",
56
59
  "@changesets/cli": "^3.0.3",
57
60
  "@types/node": "^26.6.5",
58
61
  "oxfmt": "^0.73.0",
59
62
  "oxlint": "^1.88.0",
63
+ "publint": "^0.3.25",
60
64
  "tsdown": "^0.23.1",
61
65
  "typescript": "7.0.2",
62
66
  "zod": "^4.6.7"
63
67
  },
64
68
  "engines": {
65
- "node": ">=26"
69
+ "node": ">=22"
66
70
  }
67
71
  }