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,53 +1,150 @@
1
1
  //#region src/core/notifier.ts
2
+ /** Users whose latest wake-up position is remembered (#50); the oldest are forgotten first. */
3
+ const RECENT_USERS = 1e5;
4
+ /** How soon held wake-ups are looked at again while the position has not reached them. */
5
+ const RELEASE_RETRY_MS = 10;
2
6
  /**
3
7
  * Wakes long-polling `/sync` requests when something relevant happens. Notifications
4
8
  * are broadcast through the cluster adapter so waiters on other instances wake too.
9
+ *
10
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/50: every wake-up carries the stream
11
+ * position of its change, or an upper bound of it. Writes can complete out of order, so a change
12
+ * may be committed while an earlier write still holds the position below it. As in Synapse
13
+ * (`Notifier._notify_pending_new_room_events`), such a wake-up is held here until the position
14
+ * reaches it: a request is only woken for changes it can see.
5
15
  */
6
16
  var Notifier = class {
7
- waiters = /* @__PURE__ */ new Map();
17
+ listeners = /* @__PURE__ */ new Map();
18
+ /** Each user's latest wake-up position, oldest first (re-inserted on update). */
19
+ recent = /* @__PURE__ */ new Map();
8
20
  cluster;
9
21
  instanceId;
10
- constructor(cluster, instanceId) {
22
+ lastAllocated;
23
+ position;
24
+ /** Wake-ups whose change is beyond the position known to be visible. */
25
+ held = [];
26
+ /** The highest position known to be visible. */
27
+ visible = 0;
28
+ release = null;
29
+ /**
30
+ * `lastAllocated`: the highest stream position this instance has allocated (an upper bound of
31
+ * its changes); `position`: the position below which everything is visible.
32
+ */
33
+ constructor(cluster, instanceId, lastAllocated = () => 0, position = async () => Number.POSITIVE_INFINITY) {
11
34
  this.cluster = cluster;
12
35
  this.instanceId = instanceId;
36
+ this.lastAllocated = lastAllocated;
37
+ this.position = position;
13
38
  cluster.subscribe("notify", (msg) => {
14
- if (msg?.from !== this.instanceId && Array.isArray(msg?.users)) this.wakeLocal(msg.users);
39
+ if (msg?.from !== this.instanceId && Array.isArray(msg?.users)) this.hold(msg.users, Number(msg.pos) || 0);
15
40
  });
16
41
  }
17
- notify(userIds) {
42
+ /**
43
+ * Wake `userIds`. `pos` is the stream position of the change; without it, the highest position
44
+ * this instance has allocated stands in, which is never below it.
45
+ */
46
+ notify(userIds, pos) {
18
47
  const users = [...userIds];
19
48
  if (!users.length) return;
20
- this.wakeLocal(users);
49
+ const at = pos ?? this.lastAllocated();
50
+ this.hold(users, at);
21
51
  this.cluster.publish("notify", {
22
52
  from: this.instanceId,
23
- users
53
+ users,
54
+ pos: at
24
55
  }).catch(() => {});
25
56
  }
26
- wakeLocal(userIds) {
57
+ /** The position may have moved (a write of this instance completed): release what it covers. */
58
+ advanced() {
59
+ if (this.held.length) this.scheduleRelease(0);
60
+ }
61
+ /** Wake now if the change is visible, otherwise once the position reaches it. */
62
+ hold(users, pos) {
63
+ if (pos <= this.visible) return this.wakeLocal(users, pos);
64
+ this.held.push({
65
+ users,
66
+ pos
67
+ });
68
+ this.scheduleRelease(0);
69
+ }
70
+ scheduleRelease(delay) {
71
+ if (this.release) return;
72
+ this.release = setTimeout(() => {
73
+ this.release = null;
74
+ this.releaseHeld().catch(() => this.scheduleRelease(RELEASE_RETRY_MS));
75
+ }, delay);
76
+ this.release.unref?.();
77
+ }
78
+ async releaseHeld() {
79
+ const highest = Math.max(...this.held.map((h) => h.pos));
80
+ let pos = await this.position(false);
81
+ if (pos < highest) pos = await this.position(true);
82
+ if (pos > this.visible) this.visible = pos;
83
+ const due = this.held.filter((h) => h.pos <= this.visible);
84
+ this.held = this.held.filter((h) => h.pos > this.visible);
85
+ for (const h of due) this.wakeLocal(h.users, h.pos);
86
+ if (this.held.length) this.scheduleRelease(RELEASE_RETRY_MS);
87
+ }
88
+ wakeLocal(userIds, pos) {
27
89
  for (const u of userIds) {
28
- const set = this.waiters.get(u);
90
+ const previous = this.recent.get(u) ?? 0;
91
+ this.recent.delete(u);
92
+ this.recent.set(u, Math.max(previous, pos));
93
+ if (this.recent.size > RECENT_USERS) this.recent.delete(this.recent.keys().next().value);
94
+ const set = this.listeners.get(u);
29
95
  if (!set) continue;
30
- this.waiters.delete(u);
31
- for (const wake of set) wake();
96
+ this.listeners.delete(u);
97
+ for (const wake of set) wake(pos);
32
98
  }
33
99
  }
34
100
  notifyAll() {
35
- this.wakeLocal([...this.waiters.keys()]);
36
- }
37
- wait(userId, timeoutMs, signal) {
38
- return new Promise((resolve) => {
39
- let set = this.waiters.get(userId);
40
- if (!set) this.waiters.set(userId, set = /* @__PURE__ */ new Set());
41
- const done = () => {
42
- clearTimeout(timer);
43
- set.delete(done);
44
- signal?.removeEventListener("abort", done);
45
- resolve();
46
- };
47
- const timer = setTimeout(done, timeoutMs);
48
- signal?.addEventListener("abort", done, { once: true });
49
- set.add(done);
50
- });
101
+ for (const [u, set] of this.listeners) {
102
+ this.listeners.delete(u);
103
+ for (const wake of set) wake(0);
104
+ }
105
+ }
106
+ listen(userId) {
107
+ let woken = null;
108
+ let resolveWait = null;
109
+ let set = this.listeners.get(userId);
110
+ if (!set) this.listeners.set(userId, set = /* @__PURE__ */ new Set());
111
+ const onWake = (pos) => {
112
+ woken = Math.max(woken ?? 0, pos);
113
+ resolveWait?.(woken);
114
+ };
115
+ set.add(onWake);
116
+ const remove = () => {
117
+ const current = this.listeners.get(userId);
118
+ current?.delete(onWake);
119
+ if (current && current.size === 0) this.listeners.delete(userId);
120
+ };
121
+ return {
122
+ recent: this.recent.get(userId) ?? 0,
123
+ wait: (timeoutMs, signal) => {
124
+ if (woken !== null) return Promise.resolve(woken);
125
+ return new Promise((resolve) => {
126
+ const done = (result) => {
127
+ clearTimeout(timer);
128
+ signal?.removeEventListener("abort", aborted);
129
+ resolveWait = null;
130
+ remove();
131
+ resolve(result);
132
+ };
133
+ const aborted = () => done(null);
134
+ const timer = setTimeout(() => done(null), timeoutMs);
135
+ signal?.addEventListener("abort", aborted, { once: true });
136
+ resolveWait = (pos) => done(pos);
137
+ });
138
+ },
139
+ cancel: () => {
140
+ remove();
141
+ resolveWait?.(null);
142
+ }
143
+ };
144
+ }
145
+ /** Wait for a wake-up of `userId` (for callers that do not track positions). */
146
+ async wait(userId, timeoutMs, signal) {
147
+ await this.listen(userId).wait(timeoutMs, signal);
51
148
  }
52
149
  };
53
150
  //#endregion
@@ -7,6 +7,14 @@ import { Homeserver } from "../homeserver.js";
7
7
  * Push rule evaluation and push gateway delivery. Every new event is evaluated for
8
8
  * every local member; matches are stored for unread counts and `/notifications`.
9
9
  */
10
+ type Counts = {
11
+ notification_count: number;
12
+ highlight_count: number;
13
+ };
14
+ export interface UnreadCounts {
15
+ readonly room: Readonly<Counts>;
16
+ readonly threads: Readonly<Record<string, Readonly<Counts>>>;
17
+ }
10
18
  export declare class PushService {
11
19
  private hs;
12
20
  constructor(hs: Homeserver);
@@ -18,16 +26,8 @@ export declare class PushService {
18
26
  unthreaded: number;
19
27
  threads: Map<string, number>;
20
28
  }): boolean;
21
- unreadCounts(userId: string, roomId: string): Promise<{
22
- room: {
23
- notification_count: number;
24
- highlight_count: number;
25
- };
26
- threads: Record<string, {
27
- notification_count: number;
28
- highlight_count: number;
29
- }>;
30
- }>;
29
+ unreadCounts(userId: string, roomId: string): Promise<UnreadCounts>;
30
+ private countUnread;
31
31
  private deliver;
32
32
  /** Rows for `GET /notifications`. */
33
33
  list(userId: string, opts: {
@@ -43,4 +43,5 @@ export declare class PushService {
43
43
  stream: any;
44
44
  }[]>;
45
45
  }
46
- //#endregion
46
+ //#endregion
47
+ export {};
package/dist/core/push.js CHANGED
@@ -3,8 +3,10 @@ import { M, UNSTABLE } from "./m.js";
3
3
  import { splitStateKey, toClientEvent } from "./events.js";
4
4
  import { getState, powerLevels, userLevel } from "./authrules.js";
5
5
  import { globMatch, globSearchWords } from "./glob.js";
6
- import { KINDS, loadRules } from "./pushrules.js";
6
+ import { KINDS, readOnlyRules } from "./pushrules.js";
7
7
  import { isSubscribed } from "../api/threadsubs.js";
8
+ import { upsertMany } from "../storage/batch.js";
9
+ import { freeze } from "./changes.js";
8
10
  //#region src/core/push.ts
9
11
  /** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
10
12
  function valueAtPath(obj, path) {
@@ -84,10 +86,6 @@ function threadIdOf(event) {
84
86
  const rel = event.content?.[M.RELATES_TO];
85
87
  return isObject(rel) && rel.rel_type === M.THREAD && typeof rel.event_id === "string" ? rel.event_id : "main";
86
88
  }
87
- /**
88
- * Push rule evaluation and push gateway delivery. Every new event is evaluated for
89
- * every local member; matches are stored for unread counts and `/notifications`.
90
- */
91
89
  var PushService = class {
92
90
  hs;
93
91
  constructor(hs) {
@@ -109,7 +107,7 @@ var PushService = class {
109
107
  const notified = [];
110
108
  for (const userId of members) {
111
109
  if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
112
- const actions = evaluateRules(await loadRules(this.hs, userId), {
110
+ const actions = evaluateRules(await readOnlyRules(this.hs, userId), {
113
111
  event,
114
112
  userId,
115
113
  displayName: getState(state, M.ROOM.MEMBER, userId)?.content?.displayname ?? null,
@@ -136,9 +134,7 @@ var PushService = class {
136
134
  });
137
135
  }
138
136
  if (notified.length === 0) return;
139
- await this.hs.transaction(async (db) => {
140
- for (const n of notified) await db.upsert("notifications", n.row);
141
- });
137
+ await this.hs.transaction((db) => upsertMany(db, "notifications", notified.map((n) => n.row)));
142
138
  const pushers = await this.hs.db.findMany("pushers", { where: [{
143
139
  field: "user_id",
144
140
  operator: "in",
@@ -177,23 +173,35 @@ var PushService = class {
177
173
  return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
178
174
  }
179
175
  async unreadCounts(userId, roomId) {
176
+ const changes = this.hs.changes;
177
+ if (!changes) return this.countUnread(userId, roomId);
178
+ return changes.values.unreadCounts.get(`${userId}|${roomId}`, async () => freeze(await this.countUnread(userId, roomId)));
179
+ }
180
+ async countUnread(userId, roomId) {
180
181
  const read = await this.readPositions(userId, roomId);
181
182
  const unthreaded = read.unthreaded;
182
- const rows = await this.hs.db.findMany("notifications", { where: [
183
- {
184
- field: "user_id",
185
- value: userId
186
- },
187
- {
188
- field: "room_id",
189
- value: roomId
190
- },
191
- {
192
- field: "stream",
193
- operator: "gt",
194
- value: unthreaded
195
- }
196
- ] });
183
+ const rows = await this.hs.db.findMany("notifications", {
184
+ where: [
185
+ {
186
+ field: "user_id",
187
+ value: userId
188
+ },
189
+ {
190
+ field: "room_id",
191
+ value: roomId
192
+ },
193
+ {
194
+ field: "stream",
195
+ operator: "gt",
196
+ value: unthreaded
197
+ }
198
+ ],
199
+ fields: [
200
+ "stream",
201
+ "thread_id",
202
+ "highlight"
203
+ ]
204
+ });
197
205
  const room = {
198
206
  notification_count: 0,
199
207
  highlight_count: 0
@@ -131,14 +131,33 @@ function cacheFor(hs) {
131
131
  }
132
132
  return c;
133
133
  }
134
+ /** A user's push rules, as a copy the caller may change. */
134
135
  async function loadRules(hs, userId) {
136
+ return structuredClone(await readOnlyRules(hs, userId));
137
+ }
138
+ /**
139
+ * A user's push rules, shared and frozen: for callers that only read them.
140
+ *
141
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/41: push evaluation copied every
142
+ * member's rules for every message, about 6% of the CPU under load, though it only reads them.
143
+ * The cached rulesets are deep-frozen, so a caller that tries to change one fails loudly instead
144
+ * of changing everyone's.
145
+ */
146
+ async function readOnlyRules(hs, userId) {
135
147
  const cached = cacheFor(hs).get(userId);
136
- if (cached) return structuredClone(cached);
137
- const rules = await readRules(hs, userId);
148
+ if (cached) return cached;
149
+ const rules = deepFreeze(await readRules(hs, userId));
138
150
  const cache = cacheFor(hs);
139
151
  if (cache.size > 5e3) cache.clear();
140
152
  cache.set(userId, rules);
141
- return structuredClone(rules);
153
+ return rules;
154
+ }
155
+ function deepFreeze(value) {
156
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
157
+ Object.freeze(value);
158
+ for (const v of Object.values(value)) deepFreeze(v);
159
+ }
160
+ return value;
142
161
  }
143
162
  async function readRules(hs, userId) {
144
163
  const row = await hs.db.findOne("account_data", [
@@ -162,7 +181,7 @@ async function readRules(hs, userId) {
162
181
  return rules;
163
182
  }
164
183
  async function saveRules(hs, userId, rules) {
165
- cacheFor(hs).set(userId, structuredClone(rules));
184
+ cacheFor(hs).set(userId, deepFreeze(structuredClone(rules)));
166
185
  hs.cluster.publish("pushrules", {
167
186
  from: hs.instanceId,
168
187
  userId
@@ -226,4 +245,4 @@ async function copyRules(hs, userId, oldRoomId, newRoomId) {
226
245
  if (changed) await saveRules(hs, userId, rules);
227
246
  }
228
247
  //#endregion
229
- export { KINDS, copyRoomPushRules, defaultRules, loadRules, saveRules, withRulesLock };
248
+ export { KINDS, copyRoomPushRules, defaultRules, loadRules, readOnlyRules, saveRules, withRulesLock };
@@ -731,6 +731,12 @@ var RoomService = class {
731
731
  events: touched,
732
732
  sharing: true
733
733
  });
734
+ const changedMembers = /* @__PURE__ */ new Set();
735
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(oldIds), ...Object.keys(newIds)])) {
736
+ const [type, userId] = splitStateKey(k);
737
+ if (type === M.ROOM.MEMBER && oldIds[k] !== newIds[k] && this.hs.isLocalUser(userId)) changedMembers.add(userId);
738
+ }
739
+ if (changedMembers.size) this.hs.notifier.notify([...changedMembers, ...await this.interestedUsers(roomId)]);
734
740
  for (const k of Object.keys(oldIds)) {
735
741
  const [type, userId] = splitStateKey(k);
736
742
  if (type !== M.ROOM.MEMBER || this.hs.isLocalUser(userId) || oldIds[k] === newIds[k]) continue;
@@ -995,7 +1001,7 @@ var RoomService = class {
995
1001
  if (this.hs.appservices.registrations.length) await this.hs.appservices.onEvent(view, newState).catch((err) => this.hs.logger.warn("Appservice push failed", err));
996
1002
  if (isState) await this.hs.delayedEvents.onStateEvent(roomId, pdu.type, pdu.state_key, sender).catch(() => {});
997
1003
  wake = async () => {
998
- this.hs.notifier.notify(await this.interestedUsers(roomId));
1004
+ this.hs.notifier.notify(await this.interestedUsers(roomId), backfill ? void 0 : stream);
999
1005
  this.hs.emit("event", toClientEvent(view));
1000
1006
  if (this.hs.adminRoom) this.hs.adminRoom.onEvent(view).catch((err) => this.hs.logger.error("Admin command failed", err));
1001
1007
  };
@@ -14,7 +14,7 @@ var Semaphore = class {
14
14
  return await fn();
15
15
  } finally {
16
16
  const next = this.waiting.shift();
17
- if (next) next();
17
+ if (next) setImmediate(next);
18
18
  else this.active--;
19
19
  }
20
20
  }
@@ -26,6 +26,7 @@ import { SettingsService } from "./core/settings.js";
26
26
  import { AdminRoomOptions, AdminRoomService } from "./core/adminroom.js";
27
27
  import { ResolvedUserAuth, UserAuthAdapter } from "./adapters/auth.js";
28
28
  import { MediaAdapter, Thumbnailer } from "./adapters/media.js";
29
+ import { ChangeTracker } from "./core/changes.js";
29
30
  import { AddressPolicy } from "./core/outbound.js";
30
31
  import { EventEmitter } from "node:events";
31
32
  //#region src/homeserver.d.ts
@@ -84,7 +85,7 @@ export interface HomeserverOptions {
84
85
  /** Maximum `/sync` long-poll duration in ms. Default: 30000. */
85
86
  maxSyncTimeoutMs?: number;
86
87
  /**
87
- * How many `/sync` responses one process builds at once (default 4). Requests woken together
88
+ * How many `/sync` responses one process builds at once (default 16). Requests woken together
88
89
  * wait in the process instead of queueing their database reads ahead of everything else.
89
90
  */
90
91
  maxConcurrentSyncs?: number;
@@ -176,6 +177,11 @@ export declare class Homeserver extends EventEmitter {
176
177
  readonly serverName: string;
177
178
  readonly config: ResolvedConfig;
178
179
  readonly db: DatabaseAdapter;
180
+ /**
181
+ * Where keys last changed, when this is the only instance on its database (no cluster
182
+ * adapter): /sync skips the reads that would find nothing new (#51). Null in a cluster.
183
+ */
184
+ readonly changes: ChangeTracker | null;
179
185
  readonly media: MediaAdapter;
180
186
  readonly userAuth: ResolvedUserAuth;
181
187
  readonly logger: Logger;
@@ -13,6 +13,8 @@ import { KeyService } from "./core/keys.js";
13
13
  import { Notifier } from "./core/notifier.js";
14
14
  import { StreamManager, localCluster, newInstanceId } from "./core/cluster.js";
15
15
  import { TypingService } from "./core/typing.js";
16
+ import { coalesceReads } from "./storage/batch.js";
17
+ import { ChangeTracker } from "./core/changes.js";
16
18
  import { PushService } from "./core/push.js";
17
19
  import { SsoService } from "./core/sso.js";
18
20
  import { AdminService } from "./core/admin.js";
@@ -52,6 +54,11 @@ var Homeserver = class extends EventEmitter {
52
54
  serverName;
53
55
  config;
54
56
  db;
57
+ /**
58
+ * Where keys last changed, when this is the only instance on its database (no cluster
59
+ * adapter): /sync skips the reads that would find nothing new (#51). Null in a cluster.
60
+ */
61
+ changes;
55
62
  media;
56
63
  userAuth;
57
64
  logger;
@@ -114,7 +121,7 @@ var Homeserver = class extends EventEmitter {
114
121
  defaultRoomVersion,
115
122
  accessTokenLifetimeMs: options.accessTokenLifetimeMs ?? null,
116
123
  maxSyncTimeoutMs: options.maxSyncTimeoutMs ?? 3e4,
117
- maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 4),
124
+ maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 16),
118
125
  urlPreviews: options.urlPreviews ? { allowPrivateAddresses: typeof options.urlPreviews === "object" && !!options.urlPreviews.allowPrivateAddresses } : null,
119
126
  thumbnailer: options.thumbnailer ?? null,
120
127
  rtc: options.rtc ?? {},
@@ -124,19 +131,21 @@ var Homeserver = class extends EventEmitter {
124
131
  roomName: options.serverNotices?.roomName ?? "Server Notices"
125
132
  }
126
133
  };
127
- const db = options.databaseAdapter;
128
- if (db && typeof db.findOne === "function") this.db = db;
129
- else {
134
+ this.changes = options.cluster ? null : new ChangeTracker();
135
+ let db = options.databaseAdapter;
136
+ if (!db || typeof db.findOne !== "function") {
130
137
  this.logger.warn("No database adapter given; using in-memory storage (data is lost on restart)");
131
- this.db = memoryAdapter();
138
+ db = memoryAdapter();
132
139
  }
140
+ this.db = coalesceReads(this.changes ? this.changes.wrap(db) : db);
133
141
  const media = options.mediaAdapter;
134
142
  this.media = media && typeof media.put === "function" ? media : memoryMedia();
135
143
  this.userAuth = resolveUserAuth(options.userAuthAdapter);
136
144
  this.cluster = options.cluster ?? localCluster();
137
145
  this.instanceId = newInstanceId();
138
- this.streams = new StreamManager(this.cluster, this.instanceId);
139
- this.notifier = new Notifier(this.cluster, this.instanceId);
146
+ this.streams = new StreamManager(this.cluster, this.instanceId, (...args) => this.logger.warn(...args));
147
+ this.notifier = new Notifier(this.cluster, this.instanceId, () => this.streams.lastAllocated, (fresh) => this.streams.position(fresh));
148
+ this.streams.onAdvance = () => this.notifier.advanced();
140
149
  this.auth = new AuthService(this);
141
150
  this.rooms = new RoomService(this, !options.cluster);
142
151
  this.keys = new KeyService(this);
@@ -196,6 +205,7 @@ var Homeserver = class extends EventEmitter {
196
205
  }
197
206
  const snap = await this.db.max("state_snapshots", "id");
198
207
  if (snap !== null) await this.streams.advanceTo(snap);
208
+ this.changes?.start(await this.streams.position(true));
199
209
  const oldest = await this.db.findMany("events", {
200
210
  sortBy: {
201
211
  field: "stream",
@@ -7,5 +7,76 @@ function readBatch(db, queries) {
7
7
  if (db.findManyBatch) return db.findManyBatch(queries);
8
8
  return Promise.all(queries.map((query) => db.findMany(query.model, query.opts)));
9
9
  }
10
+ /** Upsert several rows: in a few statements when the adapter can (`upsertMany`), otherwise one by one. */
11
+ async function upsertMany(db, model, rows) {
12
+ if (rows.length === 0) return;
13
+ if (db.upsertMany) return db.upsertMany(model, rows);
14
+ for (const row of rows) await db.upsert(model, row);
15
+ }
16
+ /** At most this many reads go into one coalesced statement. */
17
+ const MAX_COALESCED = 32;
18
+ /**
19
+ * An adapter whose reads (`findMany`, `findOne`) issued within one turn of the event loop are
20
+ * sent together, through `findManyBatch`. Writes and transactions go straight to `db`. Adapters
21
+ * without `findManyBatch` are returned as they are.
22
+ *
23
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/43: under load, most of the homeserver's
24
+ * and the database's CPU went to the per-statement cost (a socket write, a round trip, parsing)
25
+ * of many small reads issued at the same moment by concurrent requests. Nothing is kept between
26
+ * turns: this merges requests, it does not cache results. A read waits for at most the rest of
27
+ * the current turn. If a coalesced statement fails, its reads are run one by one, so a bad read
28
+ * fails alone.
29
+ */
30
+ function coalesceReads(db) {
31
+ if (!db.findManyBatch) return db;
32
+ let queue = [];
33
+ const runAlone = (read) => db.findMany(read.query.model, read.query.opts).then(read.resolve, read.reject);
34
+ const run = async (reads) => {
35
+ if (reads.length === 1) return runAlone(reads[0]);
36
+ let results;
37
+ try {
38
+ results = await db.findManyBatch(reads.map((r) => r.query), { prepare: false });
39
+ } catch {
40
+ for (const read of reads) runAlone(read);
41
+ return;
42
+ }
43
+ reads.forEach((read, i) => read.resolve(results[i]));
44
+ };
45
+ const flush = () => {
46
+ const reads = queue;
47
+ queue = [];
48
+ for (let i = 0; i < reads.length; i += MAX_COALESCED) run(reads.slice(i, i + MAX_COALESCED));
49
+ };
50
+ const enqueue = (query) => new Promise((resolve, reject) => {
51
+ if (queue.length === 0) setImmediate(flush);
52
+ queue.push({
53
+ query,
54
+ resolve,
55
+ reject
56
+ });
57
+ });
58
+ return {
59
+ findMany: (model, opts) => enqueue({
60
+ model,
61
+ opts
62
+ }),
63
+ findOne: async (model, where) => (await enqueue({
64
+ model,
65
+ opts: {
66
+ where,
67
+ limit: 1
68
+ }
69
+ }))[0] ?? null,
70
+ findManyBatch: (queries, options) => db.findManyBatch(queries, options),
71
+ create: (model, data) => db.create(model, data),
72
+ upsert: (model, data) => db.upsert(model, data),
73
+ ...db.upsertMany ? { upsertMany: (model, rows) => db.upsertMany(model, rows) } : {},
74
+ update: (model, where, data) => db.update(model, where, data),
75
+ delete: (model, where) => db.delete(model, where),
76
+ max: (model, field) => db.max(model, field),
77
+ ...db.migrate ? { migrate: (schema) => db.migrate(schema) } : {},
78
+ ...db.transaction ? { transaction: (fn) => db.transaction(fn) } : {}
79
+ };
80
+ }
10
81
  //#endregion
11
- export { readBatch };
82
+ export { coalesceReads, readBatch, upsertMany };
@@ -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: {
@@ -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]>;