matrix-nodejs-homeserver 0.3.3 → 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 +46 -35
- package/dist/api/longpoll.js +61 -0
- package/dist/api/slidingsync.js +28 -30
- package/dist/api/sync.js +180 -119
- 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 +6 -4
- 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 +16 -7
- package/dist/storage/sql.js +2 -2
- package/package.json +1 -1
package/dist/core/notifier.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
39
|
+
if (msg?.from !== this.instanceId && Array.isArray(msg?.users)) this.hold(msg.users, Number(msg.pos) || 0);
|
|
15
40
|
});
|
|
16
41
|
}
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
package/dist/core/push.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -6,6 +6,7 @@ import { globMatch, globSearchWords } from "./glob.js";
|
|
|
6
6
|
import { KINDS, readOnlyRules } from "./pushrules.js";
|
|
7
7
|
import { isSubscribed } from "../api/threadsubs.js";
|
|
8
8
|
import { upsertMany } from "../storage/batch.js";
|
|
9
|
+
import { freeze } from "./changes.js";
|
|
9
10
|
//#region src/core/push.ts
|
|
10
11
|
/** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
|
|
11
12
|
function valueAtPath(obj, path) {
|
|
@@ -85,10 +86,6 @@ function threadIdOf(event) {
|
|
|
85
86
|
const rel = event.content?.[M.RELATES_TO];
|
|
86
87
|
return isObject(rel) && rel.rel_type === M.THREAD && typeof rel.event_id === "string" ? rel.event_id : "main";
|
|
87
88
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Push rule evaluation and push gateway delivery. Every new event is evaluated for
|
|
90
|
-
* every local member; matches are stored for unread counts and `/notifications`.
|
|
91
|
-
*/
|
|
92
89
|
var PushService = class {
|
|
93
90
|
hs;
|
|
94
91
|
constructor(hs) {
|
|
@@ -176,6 +173,11 @@ var PushService = class {
|
|
|
176
173
|
return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
|
|
177
174
|
}
|
|
178
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) {
|
|
179
181
|
const read = await this.readPositions(userId, roomId);
|
|
180
182
|
const unthreaded = read.unthreaded;
|
|
181
183
|
const rows = await this.hs.db.findMany("notifications", {
|
package/dist/core/rooms.js
CHANGED
|
@@ -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
|
};
|
package/dist/core/semaphore.js
CHANGED
package/dist/homeserver.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/homeserver.js
CHANGED
|
@@ -14,6 +14,7 @@ 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
16
|
import { coalesceReads } from "./storage/batch.js";
|
|
17
|
+
import { ChangeTracker } from "./core/changes.js";
|
|
17
18
|
import { PushService } from "./core/push.js";
|
|
18
19
|
import { SsoService } from "./core/sso.js";
|
|
19
20
|
import { AdminService } from "./core/admin.js";
|
|
@@ -53,6 +54,11 @@ var Homeserver = class extends EventEmitter {
|
|
|
53
54
|
serverName;
|
|
54
55
|
config;
|
|
55
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;
|
|
56
62
|
media;
|
|
57
63
|
userAuth;
|
|
58
64
|
logger;
|
|
@@ -115,7 +121,7 @@ var Homeserver = class extends EventEmitter {
|
|
|
115
121
|
defaultRoomVersion,
|
|
116
122
|
accessTokenLifetimeMs: options.accessTokenLifetimeMs ?? null,
|
|
117
123
|
maxSyncTimeoutMs: options.maxSyncTimeoutMs ?? 3e4,
|
|
118
|
-
maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ??
|
|
124
|
+
maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 16),
|
|
119
125
|
urlPreviews: options.urlPreviews ? { allowPrivateAddresses: typeof options.urlPreviews === "object" && !!options.urlPreviews.allowPrivateAddresses } : null,
|
|
120
126
|
thumbnailer: options.thumbnailer ?? null,
|
|
121
127
|
rtc: options.rtc ?? {},
|
|
@@ -125,19 +131,21 @@ var Homeserver = class extends EventEmitter {
|
|
|
125
131
|
roomName: options.serverNotices?.roomName ?? "Server Notices"
|
|
126
132
|
}
|
|
127
133
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
this.changes = options.cluster ? null : new ChangeTracker();
|
|
135
|
+
let db = options.databaseAdapter;
|
|
136
|
+
if (!db || typeof db.findOne !== "function") {
|
|
131
137
|
this.logger.warn("No database adapter given; using in-memory storage (data is lost on restart)");
|
|
132
|
-
|
|
138
|
+
db = memoryAdapter();
|
|
133
139
|
}
|
|
140
|
+
this.db = coalesceReads(this.changes ? this.changes.wrap(db) : db);
|
|
134
141
|
const media = options.mediaAdapter;
|
|
135
142
|
this.media = media && typeof media.put === "function" ? media : memoryMedia();
|
|
136
143
|
this.userAuth = resolveUserAuth(options.userAuthAdapter);
|
|
137
144
|
this.cluster = options.cluster ?? localCluster();
|
|
138
145
|
this.instanceId = newInstanceId();
|
|
139
|
-
this.streams = new StreamManager(this.cluster, this.instanceId);
|
|
140
|
-
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();
|
|
141
149
|
this.auth = new AuthService(this);
|
|
142
150
|
this.rooms = new RoomService(this, !options.cluster);
|
|
143
151
|
this.keys = new KeyService(this);
|
|
@@ -197,6 +205,7 @@ var Homeserver = class extends EventEmitter {
|
|
|
197
205
|
}
|
|
198
206
|
const snap = await this.db.max("state_snapshots", "id");
|
|
199
207
|
if (snap !== null) await this.streams.advanceTo(snap);
|
|
208
|
+
this.changes?.start(await this.streams.position(true));
|
|
200
209
|
const oldest = await this.db.findMany("events", {
|
|
201
210
|
sortBy: {
|
|
202
211
|
field: "stream",
|
package/dist/storage/sql.js
CHANGED
|
@@ -207,8 +207,8 @@ function sqlAdapter({ dialect, executor: rootExecutor, tablePrefix = "", transac
|
|
|
207
207
|
};
|
|
208
208
|
return build(rootExecutor, false);
|
|
209
209
|
}
|
|
210
|
-
/** How long SQLite statements may run back to back before yielding to the event loop (#47). */
|
|
211
|
-
const YIELD_AFTER_MS =
|
|
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
212
|
/** Prepared statements an SQLite adapter keeps (#40). */
|
|
213
213
|
const STATEMENT_CACHE = 500;
|
|
214
214
|
/** SQLite adapter for `node:sqlite` (`new DatabaseSync(path)`) or `better-sqlite3`. */
|