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.
- package/README.md +89 -44
- package/dist/api/account.js +109 -64
- package/dist/api/admin.js +9 -0
- package/dist/api/federation.js +1 -1
- package/dist/api/index.js +2 -0
- package/dist/api/longpoll.js +61 -0
- package/dist/api/misc.js +3 -3
- package/dist/api/oauthserver.js +620 -0
- package/dist/api/slidingsync.js +28 -30
- package/dist/api/sync.js +181 -120
- package/dist/api/threepid.js +1 -1
- package/dist/api/uia.js +20 -8
- package/dist/api/user.js +6 -3
- package/dist/core/admin.js +1 -0
- package/dist/core/auth.d.ts +33 -2
- package/dist/core/auth.js +84 -21
- 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/oauth.js +2 -1
- package/dist/core/oauthserver.d.ts +141 -0
- package/dist/core/oauthserver.js +603 -0
- package/dist/core/push.d.ts +12 -11
- package/dist/core/push.js +6 -4
- package/dist/core/ratelimit.d.ts +1 -1
- package/dist/core/ratelimit.js +4 -0
- package/dist/core/rooms.js +7 -1
- package/dist/core/semaphore.js +1 -1
- package/dist/core/sso.d.ts +51 -15
- package/dist/core/sso.js +86 -26
- package/dist/homeserver.d.ts +19 -2
- package/dist/homeserver.js +33 -9
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/storage/schema.js +20 -2
- package/dist/storage/sql.js +2 -2
- package/dist/vendor/zod/v4/classic/errors.js +1 -1
- package/dist/vendor/zod/v4/classic/parse.js +1 -1
- package/dist/vendor/zod/v4/classic/schemas.js +1 -1
- package/dist/vendor/zod/v4/core/api.js +1 -1
- package/dist/vendor/zod/v4/core/checks.js +1 -1
- package/dist/vendor/zod/v4/core/core.js +1 -1
- package/dist/vendor/zod/v4/core/doc.js +1 -1
- package/dist/vendor/zod/v4/core/errors.js +1 -1
- package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
- package/dist/vendor/zod/v4/core/memoizer.js +1 -1
- package/dist/vendor/zod/v4/core/parse.js +1 -1
- package/dist/vendor/zod/v4/core/regexes.js +1 -1
- package/dist/vendor/zod/v4/core/registries.js +1 -1
- package/dist/vendor/zod/v4/core/schemas.js +1 -1
- package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
- package/dist/vendor/zod/v4/core/util.js +1 -1
- package/dist/vendor/zod/v4/core/versions.js +1 -1
- package/dist/vendor/zod/v4/locales/en.js +1 -1
- 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,
|
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return this.
|
|
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,
|
|
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 };
|
package/dist/core/cluster.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
68
|
-
|
|
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>;
|