matrix-nodejs-homeserver 0.3.3 → 0.3.5

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.
Files changed (59) hide show
  1. package/README.md +89 -44
  2. package/dist/api/account.js +109 -64
  3. package/dist/api/admin.js +9 -0
  4. package/dist/api/federation.js +1 -1
  5. package/dist/api/index.js +2 -0
  6. package/dist/api/longpoll.js +61 -0
  7. package/dist/api/misc.js +3 -3
  8. package/dist/api/oauthserver.js +620 -0
  9. package/dist/api/slidingsync.js +28 -30
  10. package/dist/api/sync.js +181 -120
  11. package/dist/api/threepid.js +1 -1
  12. package/dist/api/uia.js +20 -8
  13. package/dist/api/user.js +6 -3
  14. package/dist/core/admin.js +1 -0
  15. package/dist/core/auth.d.ts +33 -2
  16. package/dist/core/auth.js +84 -21
  17. package/dist/core/changes.d.ts +79 -0
  18. package/dist/core/changes.js +332 -0
  19. package/dist/core/cluster.d.ts +19 -3
  20. package/dist/core/cluster.js +44 -4
  21. package/dist/core/keys.js +4 -2
  22. package/dist/core/notifier.d.ts +49 -3
  23. package/dist/core/notifier.js +123 -26
  24. package/dist/core/oauth.js +2 -1
  25. package/dist/core/oauthserver.d.ts +141 -0
  26. package/dist/core/oauthserver.js +603 -0
  27. package/dist/core/push.d.ts +12 -11
  28. package/dist/core/push.js +6 -4
  29. package/dist/core/ratelimit.d.ts +1 -1
  30. package/dist/core/ratelimit.js +4 -0
  31. package/dist/core/rooms.js +7 -1
  32. package/dist/core/semaphore.js +1 -1
  33. package/dist/core/sso.d.ts +51 -15
  34. package/dist/core/sso.js +86 -26
  35. package/dist/homeserver.d.ts +19 -2
  36. package/dist/homeserver.js +33 -9
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.js +2 -1
  39. package/dist/storage/schema.js +20 -2
  40. package/dist/storage/sql.js +2 -2
  41. package/dist/vendor/zod/v4/classic/errors.js +1 -1
  42. package/dist/vendor/zod/v4/classic/parse.js +1 -1
  43. package/dist/vendor/zod/v4/classic/schemas.js +1 -1
  44. package/dist/vendor/zod/v4/core/api.js +1 -1
  45. package/dist/vendor/zod/v4/core/checks.js +1 -1
  46. package/dist/vendor/zod/v4/core/core.js +1 -1
  47. package/dist/vendor/zod/v4/core/doc.js +1 -1
  48. package/dist/vendor/zod/v4/core/errors.js +1 -1
  49. package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
  50. package/dist/vendor/zod/v4/core/memoizer.js +1 -1
  51. package/dist/vendor/zod/v4/core/parse.js +1 -1
  52. package/dist/vendor/zod/v4/core/regexes.js +1 -1
  53. package/dist/vendor/zod/v4/core/registries.js +1 -1
  54. package/dist/vendor/zod/v4/core/schemas.js +1 -1
  55. package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
  56. package/dist/vendor/zod/v4/core/util.js +1 -1
  57. package/dist/vendor/zod/v4/core/versions.js +1 -1
  58. package/dist/vendor/zod/v4/locales/en.js +1 -1
  59. package/package.json +12 -15
package/dist/core/auth.js CHANGED
@@ -11,13 +11,13 @@ var AuthService = class {
11
11
  if (msg?.from === hs.instanceId) return;
12
12
  if (msg.token) this.cache.delete(msg.token);
13
13
  if (msg.userId) {
14
- for (const [t, s] of this.cache) if (s.userId === msg.userId) this.cache.delete(t);
14
+ for (const [t, e] of this.cache) if (e.session.userId === msg.userId) this.cache.delete(t);
15
15
  }
16
16
  });
17
17
  }
18
18
  async authenticate(token, ip) {
19
19
  const cached = this.cache.get(token);
20
- if (cached) return cached;
20
+ if (cached && cached.expires > Date.now()) return cached.session;
21
21
  const row = await this.hs.db.findOne("access_tokens", [{
22
22
  field: "token",
23
23
  value: token
@@ -39,9 +39,24 @@ var AuthService = class {
39
39
  isGuest: !!user.is_guest,
40
40
  isAdmin: !!user.is_admin,
41
41
  locked: !!user.locked,
42
- suspended: !!user.suspended
42
+ suspended: !!user.suspended,
43
+ ...row.client_id ? { oauth: true } : {}
43
44
  };
44
- if (row.expires_ts === null) this.cache.set(token, session);
45
+ if (row.refresh_parent) {
46
+ await this.hs.db.update("access_tokens", [{
47
+ field: "token",
48
+ value: row.refresh_parent
49
+ }], { refresh_used: true });
50
+ await this.hs.db.update("access_tokens", [{
51
+ field: "token",
52
+ value: token
53
+ }], { refresh_parent: null });
54
+ }
55
+ if (this.cache.size > 1e5) this.cache.clear();
56
+ this.cache.set(token, {
57
+ session,
58
+ expires: row.expires_ts ?? Infinity
59
+ });
45
60
  await this.hs.db.update("devices", [{
46
61
  field: "user_id",
47
62
  value: row.user_id
@@ -91,46 +106,94 @@ var AuthService = class {
91
106
  target: deviceId
92
107
  });
93
108
  }
94
- return this.issueToken(userId, deviceId, !!opts.refreshToken);
109
+ return this.issueToken(userId, deviceId, !!opts.refreshToken, {
110
+ clientId: opts.clientId,
111
+ lifetimeMs: opts.lifetimeMs,
112
+ scope: opts.scope
113
+ });
95
114
  }
96
- async issueToken(userId, deviceId, withRefresh) {
115
+ async issueToken(userId, deviceId, withRefresh, opts = {}) {
97
116
  const token = `mat_${randomToken()}`;
98
- const lifetime = this.hs.config.accessTokenLifetimeMs;
117
+ const lifetime = opts.lifetimeMs ?? this.hs.config.accessTokenLifetimeMs;
99
118
  const refresh = withRefresh ? `mar_${randomToken()}` : null;
100
119
  await this.hs.db.create("access_tokens", {
101
120
  token,
102
121
  user_id: userId,
103
122
  device_id: deviceId,
104
123
  refresh_token: refresh,
105
- expires_ts: refresh && lifetime ? Date.now() + lifetime : null
124
+ expires_ts: refresh && lifetime ? Date.now() + lifetime : null,
125
+ client_id: opts.clientId ?? null,
126
+ scope: opts.scope ?? null,
127
+ refresh_parent: opts.parent ?? null,
128
+ refresh_used: false
106
129
  });
107
130
  const out = {
108
131
  user_id: userId,
109
132
  device_id: deviceId,
110
133
  access_token: token
111
134
  };
135
+ if (opts.scope) out.scope = opts.scope;
112
136
  if (refresh) {
113
137
  out.refresh_token = refresh;
114
138
  if (lifetime) out.expires_in_ms = lifetime;
115
139
  }
116
140
  return out;
117
141
  }
118
- async refresh(refreshToken) {
119
- const row = await this.hs.db.findOne("access_tokens", [{
120
- field: "refresh_token",
121
- value: refreshToken
122
- }]);
123
- if (!row) throw errors.unknownToken("Unknown refresh token");
124
- await this.hs.db.delete("access_tokens", [{
125
- field: "token",
126
- value: row.token
127
- }]);
128
- this.cache.delete(row.token);
129
- return this.issueToken(row.user_id, row.device_id, true);
142
+ /**
143
+ * Exchange a refresh token for new tokens.
144
+ *
145
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/66: the old row used to be deleted
146
+ * at once, so a client whose response was lost could not retry and was logged out, and a
147
+ * refresh token used twice went unnoticed. As the OAuth 2.0 API asks (and the legacy API
148
+ * allows): the old refresh token keeps working until the new access token is first used; a
149
+ * retry before that replaces the unused tokens. Once the new token was used, the old refresh
150
+ * token is spent, and presenting it again means it leaked, so the device is logged out.
151
+ */
152
+ async refresh(refreshToken, opts = {}) {
153
+ return this.hs.cluster.lock(`refresh:${refreshToken}`, async () => {
154
+ const row = await this.hs.db.findOne("access_tokens", [{
155
+ field: "refresh_token",
156
+ value: refreshToken
157
+ }]);
158
+ if (!row) throw errors.unknownToken("Unknown refresh token");
159
+ if (opts.clientId !== void 0 && (row.client_id ?? null) !== opts.clientId) throw errors.unknownToken("Unknown refresh token");
160
+ if (row.refresh_used) {
161
+ this.hs.logger.warn("A spent refresh token was used again; logging out", row.user_id, row.device_id);
162
+ await this.hs.audit.record({
163
+ action: "logout",
164
+ actor: row.user_id,
165
+ target: row.device_id,
166
+ details: { refresh_token_reuse: true }
167
+ });
168
+ await this.deleteDevice(row.user_id, row.device_id);
169
+ throw errors.unknownToken("Refresh token was already used");
170
+ }
171
+ for (const unused of await this.hs.db.findMany("access_tokens", { where: [{
172
+ field: "refresh_parent",
173
+ value: row.token
174
+ }] })) await this.revokeToken(unused.token);
175
+ if (row.expires_ts === null || row.expires_ts > Date.now()) {
176
+ await this.hs.db.update("access_tokens", [{
177
+ field: "token",
178
+ value: row.token
179
+ }], { expires_ts: Date.now() });
180
+ this.cache.delete(row.token);
181
+ await this.hs.cluster.publish("auth", {
182
+ from: this.hs.instanceId,
183
+ token: row.token
184
+ });
185
+ }
186
+ return this.issueToken(row.user_id, row.device_id, true, {
187
+ clientId: row.client_id,
188
+ lifetimeMs: opts.lifetimeMs,
189
+ parent: row.token,
190
+ scope: row.scope
191
+ });
192
+ });
130
193
  }
131
194
  /** Drop cached sessions of a user (after admin/deactivation changes). */
132
195
  async invalidateUser(userId) {
133
- for (const [token, s] of this.cache) if (s.userId === userId) this.cache.delete(token);
196
+ for (const [token, e] of this.cache) if (e.session.userId === userId) this.cache.delete(token);
134
197
  await this.hs.cluster.publish("auth", {
135
198
  from: this.hs.instanceId,
136
199
  userId
@@ -0,0 +1,79 @@
1
+ import { DatabaseAdapter } from "../storage/types.js";
2
+ //#region src/core/changes.d.ts
3
+ /**
4
+ * Where keys last changed, for a homeserver that is the only instance on its database.
5
+ *
6
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/51: most of a `/sync` build asked the
7
+ * database whether something had changed since the token (to-device messages, presence, account
8
+ * data, the user's rooms) and almost always found nothing. Every write passes through the adapter
9
+ * returned by `ChangeTracker.wrap`, which notes, per key, the stream position (or, for cached
10
+ * values, the write) at which it last changed, so `/sync` knows without asking.
11
+ *
12
+ * It is only used without a cluster adapter: another instance's writes would not pass through it.
13
+ * Memory is bounded: each log keeps its most recently changed keys, and forgetting a key raises
14
+ * the log's floor, at or below which every key counts as changed (and the database is asked).
15
+ */
16
+ export declare class ChangeLog {
17
+ private latest;
18
+ private floor;
19
+ private max;
20
+ constructor(max: number, floor?: number);
21
+ /** `key` changed at `pos`. */
22
+ record(key: string, pos: number): void;
23
+ /** Every key may have changed at or below `pos` (for writes whose keys are not known). */
24
+ raiseFloor(pos: number): void;
25
+ /** Set the floor (also lower it: from "nothing is known" to a starting position). */
26
+ setFloor(pos: number): void;
27
+ /** Whether `key` may have changed after `pos`; false only when it is known not to have. */
28
+ changedAfter(key: string, pos: number): boolean;
29
+ /** The keys among `keys` that may have changed after `pos`. */
30
+ filterChanged(keys: Iterable<string>, pos: number): string[];
31
+ }
32
+ /**
33
+ * Values read from the database, kept until a write changes their key. A value is only kept if
34
+ * no write to its key started while it was being read; in a transaction, the keys are marked
35
+ * again once it commits, so a value read before the commit is not kept either.
36
+ */
37
+ export declare class ValueCache<V> {
38
+ private values;
39
+ private writes;
40
+ private clock;
41
+ private max;
42
+ constructor(max: number, clock: () => number);
43
+ /** A write to `key` (or, without a key, to any) has started or finished at `tick`. */
44
+ written(key: string | null, tick: number): void;
45
+ /** The kept value of `key`, if there is one and no write has changed it since it was read. */
46
+ peek(key: string): V | undefined;
47
+ /**
48
+ * Keep `value`, read by a read that began at `readAt` (the clock then): unless a write to `key`
49
+ * began since, which may or may not be in `value`.
50
+ */
51
+ store(key: string, value: V, readAt: number): void;
52
+ get(key: string, load: () => Promise<V>): Promise<V>;
53
+ }
54
+ /** Stream logs: keys that changed at a stream position. */
55
+ export type StreamLogName = "toDevice" | "presence" | "deviceLists" | "accountData" | "roomEvents" | "roomReceipts" | "roomMembers" | "roomPartialState";
56
+ /** Value caches: values kept until their key is written. */
57
+ export type ValueName = "userMemberships" | "ignoredUsers" | "oneTimeKeys" | "fallbackKeys" | "presenceRow" | "readReceipts" | "unreadCounts";
58
+ export declare class ChangeTracker {
59
+ readonly logs: Record<StreamLogName, ChangeLog>;
60
+ readonly values: Record<ValueName, ValueCache<any>>;
61
+ private tick;
62
+ constructor();
63
+ /**
64
+ * Begin answering for changes after `position`. Until then, every key counts as changed. Every
65
+ * write after the adapter was wrapped is noted, so the logs know all changes after `position`
66
+ * if it was read after wrapping.
67
+ */
68
+ start(position: number): void;
69
+ /** The clock to pass to `ValueCache.store` for a read that begins now. */
70
+ now(): number;
71
+ /** Note a write to `model`: `rows` written, or rows matching `where` changed or deleted. */
72
+ private note;
73
+ /** Mark cached values written again (after a transaction committed). */
74
+ private rewritten;
75
+ /** An adapter that notes every write before passing it on to `db`. */
76
+ wrap(db: DatabaseAdapter): DatabaseAdapter;
77
+ private wrapWith;
78
+ }
79
+ //#endregion
@@ -0,0 +1,332 @@
1
+ import { readBatch } from "../storage/batch.js";
2
+ //#region src/core/changes.ts
3
+ /** Rows kept in a cache are shared between requests: freeze them, so a change fails loudly. */
4
+ function freeze(value) {
5
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
6
+ Object.freeze(value);
7
+ for (const v of Object.values(value)) freeze(v);
8
+ }
9
+ return value;
10
+ }
11
+ /**
12
+ * Run `reads` like `readBatch`, but answer from the change tracker what it can (#51); the others
13
+ * still go to the database together. Without a tracker (in a cluster), all go to the database.
14
+ */
15
+ async function trackedReads(db, tracker, reads) {
16
+ if (!tracker) return readBatch(db, reads.map((r) => r.query));
17
+ const out = [];
18
+ const pending = [];
19
+ reads.forEach((read, i) => {
20
+ const skip = read.unlessChanged;
21
+ if (skip && !tracker.logs[skip.log].changedAfter(skip.key, skip.after)) out[i] = [];
22
+ else if (read.cached && (out[i] = tracker.values[read.cached.name].peek(read.cached.key)) !== void 0) return;
23
+ else pending.push(i);
24
+ });
25
+ if (!pending.length) return out;
26
+ const readAt = tracker.now();
27
+ const rows = await readBatch(db, pending.map((i) => reads[i].query));
28
+ pending.forEach((i, j) => {
29
+ out[i] = rows[j];
30
+ const cached = reads[i].cached;
31
+ if (cached) tracker.values[cached.name].store(cached.key, freeze(rows[j]), readAt);
32
+ });
33
+ return out;
34
+ }
35
+ /**
36
+ * Where keys last changed, for a homeserver that is the only instance on its database.
37
+ *
38
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/51: most of a `/sync` build asked the
39
+ * database whether something had changed since the token (to-device messages, presence, account
40
+ * data, the user's rooms) and almost always found nothing. Every write passes through the adapter
41
+ * returned by `ChangeTracker.wrap`, which notes, per key, the stream position (or, for cached
42
+ * values, the write) at which it last changed, so `/sync` knows without asking.
43
+ *
44
+ * It is only used without a cluster adapter: another instance's writes would not pass through it.
45
+ * Memory is bounded: each log keeps its most recently changed keys, and forgetting a key raises
46
+ * the log's floor, at or below which every key counts as changed (and the database is asked).
47
+ */
48
+ var ChangeLog = class {
49
+ latest = /* @__PURE__ */ new Map();
50
+ floor;
51
+ max;
52
+ constructor(max, floor = 0) {
53
+ this.max = max;
54
+ this.floor = floor;
55
+ }
56
+ /** `key` changed at `pos`. */
57
+ record(key, pos) {
58
+ const previous = this.latest.get(key);
59
+ this.latest.delete(key);
60
+ this.latest.set(key, previous === void 0 ? pos : Math.max(previous, pos));
61
+ if (this.latest.size > this.max) {
62
+ const [oldest, at] = this.latest.entries().next().value;
63
+ this.latest.delete(oldest);
64
+ if (at > this.floor) this.floor = at;
65
+ }
66
+ }
67
+ /** Every key may have changed at or below `pos` (for writes whose keys are not known). */
68
+ raiseFloor(pos) {
69
+ if (pos > this.floor) this.floor = pos;
70
+ }
71
+ /** Set the floor (also lower it: from "nothing is known" to a starting position). */
72
+ setFloor(pos) {
73
+ this.floor = pos;
74
+ }
75
+ /** Whether `key` may have changed after `pos`; false only when it is known not to have. */
76
+ changedAfter(key, pos) {
77
+ return pos < this.floor || (this.latest.get(key) ?? Number.NEGATIVE_INFINITY) > pos;
78
+ }
79
+ /** The keys among `keys` that may have changed after `pos`. */
80
+ filterChanged(keys, pos) {
81
+ const out = [];
82
+ for (const k of keys) if (this.changedAfter(k, pos)) out.push(k);
83
+ return out;
84
+ }
85
+ };
86
+ /**
87
+ * Values read from the database, kept until a write changes their key. A value is only kept if
88
+ * no write to its key started while it was being read; in a transaction, the keys are marked
89
+ * again once it commits, so a value read before the commit is not kept either.
90
+ */
91
+ var ValueCache = class {
92
+ values = /* @__PURE__ */ new Map();
93
+ writes;
94
+ clock;
95
+ max;
96
+ constructor(max, clock) {
97
+ this.max = max;
98
+ this.clock = clock;
99
+ this.writes = new ChangeLog(max * 2);
100
+ }
101
+ /** A write to `key` (or, without a key, to any) has started or finished at `tick`. */
102
+ written(key, tick) {
103
+ if (key === null) {
104
+ this.writes.raiseFloor(tick);
105
+ this.values.clear();
106
+ } else {
107
+ this.writes.record(key, tick);
108
+ this.values.delete(key);
109
+ }
110
+ }
111
+ /** The kept value of `key`, if there is one and no write has changed it since it was read. */
112
+ peek(key) {
113
+ const hit = this.values.get(key);
114
+ if (!hit) return void 0;
115
+ if (this.writes.changedAfter(key, hit.readAt)) {
116
+ this.values.delete(key);
117
+ return;
118
+ }
119
+ this.values.delete(key);
120
+ this.values.set(key, hit);
121
+ return hit.value;
122
+ }
123
+ /**
124
+ * Keep `value`, read by a read that began at `readAt` (the clock then): unless a write to `key`
125
+ * began since, which may or may not be in `value`.
126
+ */
127
+ store(key, value, readAt) {
128
+ if (this.writes.changedAfter(key, readAt)) return;
129
+ this.values.delete(key);
130
+ this.values.set(key, {
131
+ value,
132
+ readAt
133
+ });
134
+ if (this.values.size > this.max) this.values.delete(this.values.keys().next().value);
135
+ }
136
+ async get(key, load) {
137
+ const hit = this.peek(key);
138
+ if (hit !== void 0) return hit;
139
+ const readAt = this.clock();
140
+ const value = await load();
141
+ this.store(key, value, readAt);
142
+ return value;
143
+ }
144
+ };
145
+ const LOG_SIZE = 1e5;
146
+ const CACHE_SIZE = 5e4;
147
+ const join = (...parts) => parts.every((p) => typeof p === "string") ? parts.join("|") : null;
148
+ const GLOBAL = () => "*";
149
+ const RULES = {
150
+ to_device: [{
151
+ stream: "toDevice",
152
+ key: (f) => join(f.user_id, f.device_id)
153
+ }],
154
+ presence: [{
155
+ stream: "presence",
156
+ key: GLOBAL
157
+ }, {
158
+ value: "presenceRow",
159
+ key: (f) => join(f.user_id)
160
+ }],
161
+ device_list_changes: [{
162
+ stream: "deviceLists",
163
+ key: GLOBAL
164
+ }],
165
+ account_data: [{
166
+ stream: "accountData",
167
+ key: (f) => join(f.user_id)
168
+ }, {
169
+ value: "ignoredUsers",
170
+ key: (f) => join(f.user_id)
171
+ }],
172
+ events: [{
173
+ stream: "roomEvents",
174
+ key: (f) => join(f.room_id)
175
+ }],
176
+ receipts: [
177
+ {
178
+ stream: "roomReceipts",
179
+ key: (f) => join(f.room_id)
180
+ },
181
+ {
182
+ value: "readReceipts",
183
+ key: (f) => join(f.room_id, f.user_id)
184
+ },
185
+ {
186
+ value: "unreadCounts",
187
+ key: (f) => join(f.user_id, f.room_id)
188
+ }
189
+ ],
190
+ memberships: [{
191
+ stream: "roomMembers",
192
+ key: (f) => join(f.room_id)
193
+ }, {
194
+ value: "userMemberships",
195
+ key: (f) => join(f.user_id)
196
+ }],
197
+ partial_state_rooms: [{
198
+ stream: "roomPartialState",
199
+ key: (f) => join(f.room_id),
200
+ pos: "completed_stream"
201
+ }],
202
+ notifications: [{
203
+ value: "unreadCounts",
204
+ key: (f) => join(f.user_id, f.room_id)
205
+ }],
206
+ one_time_keys: [{
207
+ value: "oneTimeKeys",
208
+ key: (f) => join(f.user_id, f.device_id)
209
+ }],
210
+ fallback_keys: [{
211
+ value: "fallbackKeys",
212
+ key: (f) => join(f.user_id, f.device_id)
213
+ }]
214
+ };
215
+ /** The fields a where clause pins to one value. */
216
+ function pinned(where) {
217
+ const out = {};
218
+ for (const w of where) if ((w.operator ?? "eq") === "eq") out[w.field] = w.value;
219
+ return out;
220
+ }
221
+ var ChangeTracker = class {
222
+ logs;
223
+ values;
224
+ tick = 0;
225
+ constructor() {
226
+ const log = () => new ChangeLog(LOG_SIZE, Number.POSITIVE_INFINITY);
227
+ this.logs = {
228
+ toDevice: log(),
229
+ presence: log(),
230
+ deviceLists: log(),
231
+ accountData: log(),
232
+ roomEvents: log(),
233
+ roomReceipts: log(),
234
+ roomMembers: log(),
235
+ roomPartialState: log()
236
+ };
237
+ const clock = () => this.tick;
238
+ const cache = () => new ValueCache(CACHE_SIZE, clock);
239
+ this.values = {
240
+ userMemberships: cache(),
241
+ ignoredUsers: cache(),
242
+ oneTimeKeys: cache(),
243
+ fallbackKeys: cache(),
244
+ presenceRow: cache(),
245
+ readReceipts: cache(),
246
+ unreadCounts: cache()
247
+ };
248
+ }
249
+ /**
250
+ * Begin answering for changes after `position`. Until then, every key counts as changed. Every
251
+ * write after the adapter was wrapped is noted, so the logs know all changes after `position`
252
+ * if it was read after wrapping.
253
+ */
254
+ start(position) {
255
+ for (const log of Object.values(this.logs)) log.setFloor(position);
256
+ }
257
+ /** The clock to pass to `ValueCache.store` for a read that begins now. */
258
+ now() {
259
+ return this.tick;
260
+ }
261
+ /** Note a write to `model`: `rows` written, or rows matching `where` changed or deleted. */
262
+ note(model, rows, where, data) {
263
+ const rules = RULES[model];
264
+ if (!rules) return [];
265
+ const tick = ++this.tick;
266
+ const touched = [];
267
+ const sources = rows ?? [{
268
+ ...pinned(where ?? []),
269
+ ...data
270
+ }];
271
+ for (const fields of sources) for (const rule of rules) {
272
+ const key = rule.key(fields);
273
+ if (rule.value) {
274
+ this.values[rule.value].written(key, tick);
275
+ if (key !== null) touched.push(`${rule.value}\0${key}`);
276
+ }
277
+ if (rule.stream) {
278
+ const pos = fields[rule.pos ?? "stream"];
279
+ if (typeof pos !== "number") continue;
280
+ if (key === null) this.logs[rule.stream].raiseFloor(pos);
281
+ else this.logs[rule.stream].record(key, pos);
282
+ }
283
+ }
284
+ return touched;
285
+ }
286
+ /** Mark cached values written again (after a transaction committed). */
287
+ rewritten(touched) {
288
+ const tick = ++this.tick;
289
+ for (const t of touched) {
290
+ const [name, key] = t.split("\0");
291
+ this.values[name].written(key, tick);
292
+ }
293
+ }
294
+ /** An adapter that notes every write before passing it on to `db`. */
295
+ wrap(db) {
296
+ return this.wrapWith(db, null);
297
+ }
298
+ wrapWith(db, inTx) {
299
+ const around = async (touched, run) => {
300
+ try {
301
+ return await run();
302
+ } finally {
303
+ if (inTx) inTx.push(...touched);
304
+ else this.rewritten(touched);
305
+ }
306
+ };
307
+ const adapter = {
308
+ findMany: (model, opts) => db.findMany(model, opts),
309
+ findOne: (model, where) => db.findOne(model, where),
310
+ ...db.findManyBatch ? { findManyBatch: (queries, options) => db.findManyBatch(queries, options) } : {},
311
+ create: (model, data) => around(this.note(model, [data], null), () => db.create(model, data)),
312
+ upsert: (model, data) => around(this.note(model, [data], null), () => db.upsert(model, data)),
313
+ ...db.upsertMany ? { upsertMany: (model, rows) => around(this.note(model, rows, null), () => db.upsertMany(model, rows)) } : {},
314
+ update: (model, where, data) => around(this.note(model, null, where, data), () => db.update(model, where, data)),
315
+ delete: (model, where) => around(this.note(model, null, where), () => db.delete(model, where)),
316
+ max: (model, field) => db.max(model, field),
317
+ ...db.migrate ? { migrate: (schema) => db.migrate(schema) } : {}
318
+ };
319
+ if (db.transaction) adapter.transaction = async (fn) => {
320
+ if (inTx) return db.transaction((tx) => fn(this.wrapWith(tx, inTx)));
321
+ const touched = [];
322
+ try {
323
+ return await db.transaction((tx) => fn(this.wrapWith(tx, touched)));
324
+ } finally {
325
+ this.rewritten(touched);
326
+ }
327
+ };
328
+ return adapter;
329
+ }
330
+ };
331
+ //#endregion
332
+ export { ChangeLog, ChangeTracker, ValueCache, freeze, trackedReads };
@@ -52,9 +52,13 @@ export declare class StreamManager {
52
52
  private lastPos;
53
53
  private heartbeat;
54
54
  private positionCache;
55
+ private highestAllocated;
56
+ /** Called after a write of this instance completed, when the position may have moved (#50). */
57
+ onAdvance: () => void;
55
58
  private publishing;
56
59
  private nextPublish;
57
- constructor(cluster: ClusterAdapter, instanceId: string);
60
+ private warn;
61
+ constructor(cluster: ClusterAdapter, instanceId: string, warn?: (...args: unknown[]) => void);
58
62
  private key;
59
63
  /** Publish this writer's state as it is when the publish starts; concurrent callers share it. */
60
64
  private flush;
@@ -64,8 +68,20 @@ export declare class StreamManager {
64
68
  write<T>(fn: (id: number) => Promise<T>): Promise<T>;
65
69
  /** A fresh decreasing position for backfilled (historical) events. */
66
70
  backfillId(): Promise<number>;
67
- /** The highest position below which everything is persisted, cluster-wide. */
68
- position(): Promise<number>;
71
+ /** The highest position this instance has allocated: an upper bound of its changes so far. */
72
+ get lastAllocated(): number;
73
+ /**
74
+ * The highest position below which everything is persisted, cluster-wide. `fresh` skips the
75
+ * 20 ms cache, for a caller that knows a change has completed elsewhere.
76
+ */
77
+ position(fresh?: boolean): Promise<number>;
78
+ /**
79
+ * Resolve once the position has reached `pos` (true), or false after `timeoutMs` or on abort.
80
+ * For a request woken by a change that another write in flight still keeps out of sight
81
+ * (https://github.com/k18k/matrix-nodejs-homeserver/issues/50). It checks again at growing
82
+ * intervals: writes finish in milliseconds, and the position may be held by another instance.
83
+ */
84
+ waitFor(pos: number, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
69
85
  advanceTo(value: number): Promise<void>;
70
86
  lowerBackfillTo(value: number): Promise<void>;
71
87
  stop(): Promise<void>;