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.
- package/README.md +58 -20
- package/dist/api/longpoll.js +61 -0
- package/dist/api/slidingsync.js +30 -32
- package/dist/api/sync.js +331 -183
- package/dist/core/admin.js +1 -0
- package/dist/core/changes.d.ts +79 -0
- package/dist/core/changes.js +332 -0
- package/dist/core/cluster.d.ts +19 -3
- package/dist/core/cluster.js +44 -4
- package/dist/core/keys.js +4 -2
- package/dist/core/notifier.d.ts +49 -3
- package/dist/core/notifier.js +123 -26
- package/dist/core/push.d.ts +12 -11
- package/dist/core/push.js +32 -24
- package/dist/core/pushrules.js +24 -5
- package/dist/core/rooms.js +7 -1
- package/dist/core/semaphore.js +1 -1
- package/dist/homeserver.d.ts +7 -1
- package/dist/homeserver.js +17 -7
- package/dist/storage/batch.js +72 -1
- package/dist/storage/schema.js +7 -2
- package/dist/storage/sql.d.ts +22 -3
- package/dist/storage/sql.js +126 -28
- package/dist/storage/types.d.ts +15 -1
- package/package.json +1 -1
package/dist/storage/sql.js
CHANGED
|
@@ -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
|
|
87
|
-
const
|
|
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
|
-
|
|
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 +=
|
|
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
|
|
145
|
-
if (
|
|
146
|
-
if (opts.limit !== void 0) inner +=
|
|
147
|
-
return `SELECT ${i} AS i,
|
|
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(
|
|
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
|
|
153
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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();
|
package/dist/storage/types.d.ts
CHANGED
|
@@ -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>;
|