@vex-chat/spire 1.0.1 → 1.0.3

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 (106) hide show
  1. package/CLA.md +38 -0
  2. package/LICENSE-COMMERCIAL +10 -0
  3. package/LICENSING.md +15 -0
  4. package/README.md +15 -13
  5. package/dist/ClientManager.d.ts +5 -0
  6. package/dist/ClientManager.js +6 -2
  7. package/dist/ClientManager.js.map +1 -1
  8. package/dist/Database.d.ts +29 -1
  9. package/dist/Database.js +206 -64
  10. package/dist/Database.js.map +1 -1
  11. package/dist/Spire.d.ts +5 -0
  12. package/dist/Spire.js +86 -19
  13. package/dist/Spire.js.map +1 -1
  14. package/dist/db/schema.d.ts +5 -0
  15. package/dist/db/schema.js +5 -0
  16. package/dist/db/schema.js.map +1 -1
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.js +5 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/middleware/validate.d.ts +5 -0
  21. package/dist/middleware/validate.js +5 -0
  22. package/dist/middleware/validate.js.map +1 -1
  23. package/dist/migrations/2026-04-06_initial-schema.d.ts +5 -0
  24. package/dist/migrations/2026-04-06_initial-schema.js +5 -0
  25. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  26. package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +5 -0
  27. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +5 -0
  28. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -1
  29. package/dist/run.d.ts +5 -0
  30. package/dist/run.js +5 -0
  31. package/dist/run.js.map +1 -1
  32. package/dist/server/avatar.d.ts +5 -0
  33. package/dist/server/avatar.js +5 -0
  34. package/dist/server/avatar.js.map +1 -1
  35. package/dist/server/errors.d.ts +5 -0
  36. package/dist/server/errors.js +5 -0
  37. package/dist/server/errors.js.map +1 -1
  38. package/dist/server/file.d.ts +5 -0
  39. package/dist/server/file.js +5 -0
  40. package/dist/server/file.js.map +1 -1
  41. package/dist/server/index.d.ts +5 -0
  42. package/dist/server/index.js +20 -2
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/invite.d.ts +5 -0
  45. package/dist/server/invite.js +5 -0
  46. package/dist/server/invite.js.map +1 -1
  47. package/dist/server/openapi.d.ts +5 -0
  48. package/dist/server/openapi.js +5 -0
  49. package/dist/server/openapi.js.map +1 -1
  50. package/dist/server/permissions.d.ts +5 -0
  51. package/dist/server/permissions.js +5 -0
  52. package/dist/server/permissions.js.map +1 -1
  53. package/dist/server/rateLimit.d.ts +15 -0
  54. package/dist/server/rateLimit.js +56 -0
  55. package/dist/server/rateLimit.js.map +1 -1
  56. package/dist/server/user.d.ts +5 -0
  57. package/dist/server/user.js +12 -3
  58. package/dist/server/user.js.map +1 -1
  59. package/dist/server/utils.d.ts +5 -0
  60. package/dist/server/utils.js +5 -0
  61. package/dist/server/utils.js.map +1 -1
  62. package/dist/types/express.d.ts +5 -0
  63. package/dist/types/express.js +5 -0
  64. package/dist/types/express.js.map +1 -1
  65. package/dist/utils/createUint8UUID.d.ts +5 -0
  66. package/dist/utils/createUint8UUID.js +5 -0
  67. package/dist/utils/createUint8UUID.js.map +1 -1
  68. package/dist/utils/jwtSecret.d.ts +5 -0
  69. package/dist/utils/jwtSecret.js +5 -0
  70. package/dist/utils/jwtSecret.js.map +1 -1
  71. package/dist/utils/loadEnv.d.ts +5 -0
  72. package/dist/utils/loadEnv.js +5 -0
  73. package/dist/utils/loadEnv.js.map +1 -1
  74. package/dist/utils/msgpack.d.ts +5 -0
  75. package/dist/utils/msgpack.js +5 -0
  76. package/dist/utils/msgpack.js.map +1 -1
  77. package/package.json +18 -8
  78. package/src/ClientManager.ts +7 -2
  79. package/src/Database.ts +231 -68
  80. package/src/Spire.ts +91 -23
  81. package/src/__tests__/Database.spec.ts +6 -0
  82. package/src/ambient-modules.d.ts +6 -0
  83. package/src/db/schema.ts +6 -0
  84. package/src/index.ts +6 -0
  85. package/src/middleware/validate.ts +6 -0
  86. package/src/migrations/2026-04-06_initial-schema.ts +6 -0
  87. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +6 -0
  88. package/src/run.ts +6 -0
  89. package/src/server/avatar.ts +6 -0
  90. package/src/server/errors.ts +6 -0
  91. package/src/server/file.ts +6 -0
  92. package/src/server/index.ts +22 -2
  93. package/src/server/invite.ts +6 -0
  94. package/src/server/openapi.ts +6 -0
  95. package/src/server/permissions.ts +6 -0
  96. package/src/server/rateLimit.ts +43 -3
  97. package/src/server/user.ts +13 -3
  98. package/src/server/utils.ts +6 -0
  99. package/src/types/express.ts +6 -0
  100. package/src/utils/createUint8UUID.ts +6 -0
  101. package/src/utils/jwtSecret.ts +6 -0
  102. package/src/utils/loadEnv.ts +6 -0
  103. package/src/utils/msgpack.ts +6 -0
  104. package/dist/__tests__/Database.spec.d.ts +0 -1
  105. package/dist/__tests__/Database.spec.js +0 -122
  106. package/dist/__tests__/Database.spec.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"jwtSecret.js","sourceRoot":"","sources":["../../src/utils/jwtSecret.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IACxB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACX,mEAAmE,CACtE,CAAC;IACN,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC"}
1
+ {"version":3,"file":"jwtSecret.js","sourceRoot":"","sources":["../../src/utils/jwtSecret.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,YAAY;IACxB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACX,mEAAmE,CACtE,CAAC;IACN,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC"}
@@ -1 +1,6 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
1
6
  export declare function loadEnv(): void;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
1
6
  import { config } from "dotenv";
2
7
  /* Populate process.env with vars from .env and verify required vars are present. */
3
8
  export function loadEnv() {
@@ -1 +1 @@
1
- {"version":3,"file":"loadEnv.js","sourceRoot":"","sources":["../../src/utils/loadEnv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,oFAAoF;AACpF,MAAM,UAAU,OAAO;IACnB,MAAM,EAAE,CAAC;IACT,MAAM,eAAe,GAAa,CAAC,SAAS,EAAE,YAAY,EAAE,KAAK,CAAC,CAAC;IACnE,KAAK,MAAM,QAAQ,IAAI,eAAe,EAAE,CAAC;QACrC,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,SAAS,EAAE,CAAC;YACtC,OAAO,CAAC,MAAM,CAAC,KAAK,CAChB,kCAAkC,QAAQ,4CAA4C,CACzF,CAAC;YACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACpB,CAAC;IACL,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"loadEnv.js","sourceRoot":"","sources":["../../src/utils/loadEnv.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,oFAAoF;AACpF,MAAM,UAAU,OAAO;IACnB,MAAM,EAAE,CAAC;IACT,MAAM,eAAe,GAAa,CAAC,SAAS,EAAE,YAAY,EAAE,KAAK,CAAC,CAAC;IACnE,KAAK,MAAM,QAAQ,IAAI,eAAe,EAAE,CAAC;QACrC,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,SAAS,EAAE,CAAC;YACtC,OAAO,CAAC,MAAM,CAAC,KAAK,CAChB,kCAAkC,QAAQ,4CAA4C,CACzF,CAAC;YACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACpB,CAAC;IACL,CAAC;AACL,CAAC"}
@@ -1,2 +1,7 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
1
6
  import { Packr } from "msgpackr";
2
7
  export declare const msgpack: Packr;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
1
6
  import { Packr } from "msgpackr";
2
7
  // Standard msgpack encoder/decoder configured for broad compatibility.
3
8
  export const msgpack = new Packr({ moreTypes: false, useRecords: false });
@@ -1 +1 @@
1
- {"version":3,"file":"msgpack.js","sourceRoot":"","sources":["../../src/utils/msgpack.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC,uEAAuE;AACvE,MAAM,CAAC,MAAM,OAAO,GAAG,IAAI,KAAK,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC"}
1
+ {"version":3,"file":"msgpack.js","sourceRoot":"","sources":["../../src/utils/msgpack.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC,uEAAuE;AACvE,MAAM,CAAC,MAAM,OAAO,GAAG,IAAI,KAAK,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@vex-chat/spire",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Vex server implementation in NodeJS.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
8
8
  "src",
9
- "LICENSE"
9
+ "LICENSE",
10
+ "LICENSE-COMMERCIAL",
11
+ "LICENSING.md",
12
+ "CLA.md"
10
13
  ],
11
14
  "author": "Extra <extrahash@protonmail.com>",
12
15
  "license": "AGPL-3.0-or-later",
@@ -23,16 +26,20 @@
23
26
  "preinstall": "npx only-allow npm",
24
27
  "start": "node --experimental-strip-types src/run.ts",
25
28
  "restart": "pm2 restart spire && pm2 logs spire",
26
- "build": "tsc",
29
+ "build": "tsc && node scripts/clean-dist-tests.mjs",
27
30
  "format": "prettier --write .",
28
31
  "format:check": "prettier --check .",
29
- "lint": "eslint src/",
30
- "lint:fix": "eslint src/ --fix",
32
+ "lint": "eslint src/ scripts/stress/",
33
+ "lint:fix": "eslint src/ scripts/stress/ --fix",
31
34
  "prepare": "husky || true",
32
35
  "test": "vitest run",
36
+ "stress:web": "node --experimental-strip-types scripts/stress/spire-stress.ts",
37
+ "stress:cli": "node --experimental-strip-types scripts/stress/stress-cli-sweep.ts",
38
+ "stress:repo-manifest": "node --experimental-strip-types scripts/stress/stress-repo-manifest.ts",
39
+ "stress:docs-pack": "node --experimental-strip-types scripts/stress/stress-docs-pack.ts",
40
+ "stress:llm-triage": "node --experimental-strip-types scripts/stress/stress-llm-triage.ts",
33
41
  "gen-spk": "node scripts/gen-spk.js",
34
42
  "docs": "node ./scripts/generate-docs.js",
35
- "license:check": "node scripts/fix-license-checker.mjs && npx @onebeyond/license-checker scan --allowOnly MIT MIT-0 ISC BSD-2-Clause BSD-3-Clause Apache-2.0 0BSD MPL-2.0 CC0-1.0 CC-BY-3.0 CC-BY-4.0 Unlicense BlueOak-1.0.0 Python-2.0 Zlib PSF-2.0 Artistic-2.0 AGPL-3.0-or-later WTFPL \"(MIT AND CC-BY-3.0)\" --ignoreRootPackageLicense --disableReport",
36
43
  "service:status-monitor": "node ./services/status-monitor/index.js",
37
44
  "service:status-monitor:clear-history": "node ./services/status-monitor/clear-history.js",
38
45
  "service:deploy-hook": "node ./services/deploy-hook/index.js",
@@ -40,7 +47,7 @@
40
47
  },
41
48
  "devDependencies": {
42
49
  "@changesets/cli": "2.30.0",
43
- "@onebeyond/license-checker": "2.2.0",
50
+ "@socketsecurity/cli": "1.1.84",
44
51
  "@types/better-sqlite3": "7.6.13",
45
52
  "@types/cors": "2.8.19",
46
53
  "@types/express": "5.0.6",
@@ -50,13 +57,16 @@
50
57
  "@types/node": "25.6.0",
51
58
  "@types/uuid": "10.0.0",
52
59
  "@types/ws": "8.18.1",
60
+ "@vex-chat/libvex": "5.2.0",
53
61
  "@vitest/eslint-plugin": "1.6.15",
62
+ "axios": "1.15.0",
54
63
  "eslint": "10.2.0",
55
64
  "eslint-config-prettier": "10.1.8",
56
65
  "eslint-plugin-n": "17.24.0",
57
66
  "eslint-plugin-perfectionist": "5.8.0",
58
67
  "husky": "9.1.7",
59
68
  "lint-staged": "16.4.0",
69
+ "npm-package-json-lint": "10.2.1",
60
70
  "prettier": "3.8.2",
61
71
  "type-coverage": "2.29.7",
62
72
  "typedoc": "0.28.18",
@@ -86,7 +96,7 @@
86
96
  "dependencies": {
87
97
  "@asyncapi/web-component": "2.6.5",
88
98
  "@scalar/express-api-reference": "0.9.7",
89
- "@vex-chat/crypto": "2.0.0",
99
+ "@vex-chat/crypto": "2.0.1",
90
100
  "@vex-chat/types": "2.0.0",
91
101
  "argon2": "0.44.0",
92
102
  "better-sqlite3": "11.10.0",
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
1
7
  import type { Database } from "./Database.ts";
2
8
  import type {
3
9
  BaseMsg,
@@ -271,8 +277,7 @@ export class ClientManager extends EventEmitter {
271
277
  }
272
278
 
273
279
  private async pingLoop() {
274
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop
275
- while (true) {
280
+ while (!this.failed) {
276
281
  this.ping();
277
282
  await sleep(5000);
278
283
  }
package/src/Database.ts CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
1
7
  import type { ServerDatabase } from "./db/schema.ts";
2
8
  import type { SpireOptions } from "./Spire.ts";
3
9
  import type {
@@ -21,6 +27,7 @@ import type { Migration, MigrationProvider } from "kysely";
21
27
 
22
28
  import { EventEmitter } from "events";
23
29
  import { pbkdf2Sync } from "node:crypto";
30
+ import { statSync } from "node:fs";
24
31
  import * as fs from "node:fs/promises";
25
32
  import path from "node:path";
26
33
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -119,6 +126,34 @@ export interface InternalUserRecord extends UserRecord {
119
126
  export class Database extends EventEmitter {
120
127
  private db: Kysely<ServerDatabase>;
121
128
 
129
+ /** Underlying better-sqlite3 handle (file or :memory:). */
130
+ private readonly rawSqlite: InstanceType<typeof BetterSqlite3>;
131
+
132
+ /**
133
+ * In-process cache for {@link retrieveUserDeviceList} when queried for a
134
+ * single owner. Libvex calls GET /user/:id/devices on every outgoing
135
+ * `forward()`; stress runs otherwise hammer SQLite with identical reads.
136
+ * Cleared on device create/delete and when the DB is closed.
137
+ */
138
+ private readonly userDeviceListCache = new Map<
139
+ string,
140
+ { devices: Device[]; until: number }
141
+ >();
142
+
143
+ /**
144
+ * Short TTL cache for {@link retrieveEmojiList} (key = server id, stored as
145
+ * `emojis.owner`). The noise harness fires `emoji.retrieveList` as a
146
+ * follow-up on ~1/25 successful ops, so this path can contend on SQLite
147
+ * before heavier surfaces. Invalidated on emoji create/delete.
148
+ */
149
+ private readonly emojiListByServerCache = new Map<
150
+ string,
151
+ { rows: Emoji[]; until: number }
152
+ >();
153
+
154
+ private static readonly USER_DEVICE_LIST_CACHE_TTL_MS = 10_000;
155
+ private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
156
+
122
157
  constructor(options?: SpireOptions) {
123
158
  super();
124
159
 
@@ -138,22 +173,86 @@ export class Database extends EventEmitter {
138
173
  break;
139
174
  }
140
175
 
141
- const sqliteDb = new BetterSqlite3(filename);
142
- sqliteDb.pragma("journal_mode = WAL");
143
- sqliteDb.pragma("synchronous = NORMAL");
144
- sqliteDb.pragma("busy_timeout = 5000");
145
- sqliteDb.pragma("cache_size = -64000");
146
- sqliteDb.pragma("temp_store = memory");
147
- sqliteDb.pragma("foreign_keys = ON");
176
+ this.rawSqlite = new BetterSqlite3(filename);
177
+ this.rawSqlite.pragma("journal_mode = WAL");
178
+ this.rawSqlite.pragma("synchronous = NORMAL");
179
+ this.rawSqlite.pragma("busy_timeout = 5000");
180
+ this.rawSqlite.pragma("cache_size = -64000");
181
+ this.rawSqlite.pragma("temp_store = memory");
182
+ this.rawSqlite.pragma("foreign_keys = ON");
148
183
 
149
184
  this.db = new Kysely<ServerDatabase>({
150
- dialect: new SqliteDialect({ database: sqliteDb }),
185
+ dialect: new SqliteDialect({ database: this.rawSqlite }),
151
186
  });
152
187
 
153
188
  void this.init();
154
189
  }
155
190
 
191
+ /**
192
+ * Dev-only snapshot of SQLite files + pragmas (same process as Spire).
193
+ * Not for production callers.
194
+ */
195
+ public getDevSqliteMonitor(): Record<string, unknown> {
196
+ const openedAs = this.rawSqlite.name;
197
+ const absPath =
198
+ openedAs === ":memory:"
199
+ ? ":memory:"
200
+ : path.isAbsolute(openedAs)
201
+ ? openedAs
202
+ : path.resolve(process.cwd(), openedAs);
203
+
204
+ const journalMode = this.rawSqlite.pragma("journal_mode", {
205
+ simple: true,
206
+ });
207
+ const synchronous = this.rawSqlite.pragma("synchronous", {
208
+ simple: true,
209
+ });
210
+ const busyTimeout = this.rawSqlite.pragma("busy_timeout", {
211
+ simple: true,
212
+ });
213
+ const cacheSize = this.rawSqlite.pragma("cache_size", { simple: true });
214
+ const pageCount = this.rawSqlite.pragma("page_count", { simple: true });
215
+ const pageSize = this.rawSqlite.pragma("page_size", { simple: true });
216
+ const freelistCount = this.rawSqlite.pragma("freelist_count", {
217
+ simple: true,
218
+ });
219
+
220
+ const out: Record<string, unknown> = {
221
+ absPath,
222
+ busyTimeout,
223
+ cacheSize,
224
+ freelistCount,
225
+ journalMode,
226
+ openedAs,
227
+ pageCount,
228
+ pageSize,
229
+ synchronous,
230
+ };
231
+
232
+ if (openedAs !== ":memory:") {
233
+ const filePaths = {
234
+ main: absPath,
235
+ shm: `${absPath}-shm`,
236
+ wal: `${absPath}-wal`,
237
+ };
238
+ out["filePaths"] = filePaths;
239
+ const sizes: Record<string, number> = {};
240
+ for (const [label, fp] of Object.entries(filePaths)) {
241
+ try {
242
+ sizes[label] = statSync(fp).size;
243
+ } catch {
244
+ sizes[label] = 0;
245
+ }
246
+ }
247
+ out["fileBytes"] = sizes;
248
+ }
249
+
250
+ return out;
251
+ }
252
+
156
253
  public async close(): Promise<void> {
254
+ this.userDeviceListCache.clear();
255
+ this.emojiListByServerCache.clear();
157
256
  await this.db.destroy();
158
257
  }
159
258
 
@@ -184,6 +283,7 @@ export class Database extends EventEmitter {
184
283
  };
185
284
 
186
285
  await this.db.insertInto("devices").values(device).execute();
286
+ this.userDeviceListCache.delete(owner);
187
287
 
188
288
  const medPreKeys = {
189
289
  deviceID: device.deviceID,
@@ -201,6 +301,7 @@ export class Database extends EventEmitter {
201
301
 
202
302
  public async createEmoji(emoji: Emoji): Promise<void> {
203
303
  await this.db.insertInto("emojis").values(emoji).execute();
304
+ this.emojiListByServerCache.delete(emoji.owner);
204
305
  }
205
306
 
206
307
  public async createFile(file: FileSQL): Promise<void> {
@@ -230,30 +331,31 @@ export class Database extends EventEmitter {
230
331
  resourceID: string,
231
332
  powerLevel: number,
232
333
  ): Promise<Permission> {
233
- const permissionID = crypto.randomUUID();
234
-
235
- // check if it already exists
236
- const checkPermission = await this.db
237
- .selectFrom("permissions")
238
- .selectAll()
239
- .where("userID", "=", userID)
240
- .where("resourceID", "=", resourceID)
241
- .execute();
242
- const existing = checkPermission[0];
243
- if (existing) {
244
- return existing;
245
- }
334
+ // Atomic check-then-insert inside a transaction so two concurrent
335
+ // callers cannot both find "no existing" and insert duplicates.
336
+ return this.db.transaction().execute(async (trx) => {
337
+ const checkPermission = await trx
338
+ .selectFrom("permissions")
339
+ .selectAll()
340
+ .where("userID", "=", userID)
341
+ .where("resourceID", "=", resourceID)
342
+ .execute();
343
+ const existing = checkPermission[0];
344
+ if (existing) {
345
+ return existing;
346
+ }
246
347
 
247
- const permission: Permission = {
248
- permissionID,
249
- powerLevel,
250
- resourceID,
251
- resourceType,
252
- userID,
253
- };
348
+ const permission: Permission = {
349
+ permissionID: crypto.randomUUID(),
350
+ powerLevel,
351
+ resourceID,
352
+ resourceType,
353
+ userID,
354
+ };
254
355
 
255
- await this.db.insertInto("permissions").values(permission).execute();
256
- return permission;
356
+ await trx.insertInto("permissions").values(permission).execute();
357
+ return permission;
358
+ });
257
359
  }
258
360
 
259
361
  public async createServer(name: string, ownerID: string): Promise<Server> {
@@ -321,6 +423,12 @@ export class Database extends EventEmitter {
321
423
  }
322
424
 
323
425
  public async deleteDevice(deviceID: string): Promise<void> {
426
+ const ownerRow = await this.db
427
+ .selectFrom("devices")
428
+ .select("owner")
429
+ .where("deviceID", "=", deviceID)
430
+ .executeTakeFirst();
431
+
324
432
  await this.db
325
433
  .deleteFrom("preKeys")
326
434
  .where("deviceID", "=", deviceID)
@@ -336,13 +444,21 @@ export class Database extends EventEmitter {
336
444
  .set({ deleted: 1 })
337
445
  .where("deviceID", "=", deviceID)
338
446
  .execute();
447
+
448
+ if (ownerRow?.owner) {
449
+ this.userDeviceListCache.delete(ownerRow.owner);
450
+ }
339
451
  }
340
452
 
341
453
  public async deleteEmoji(emojiID: string): Promise<void> {
454
+ const existing = await this.retrieveEmoji(emojiID);
342
455
  await this.db
343
456
  .deleteFrom("emojis")
344
457
  .where("emojiID", "=", emojiID)
345
458
  .execute();
459
+ if (existing) {
460
+ this.emojiListByServerCache.delete(existing.owner);
461
+ }
346
462
  }
347
463
 
348
464
  public async deleteInvite(inviteID: string): Promise<void> {
@@ -405,31 +521,34 @@ export class Database extends EventEmitter {
405
521
  }
406
522
 
407
523
  public async getOTK(deviceID: string): Promise<null | PreKeysWS> {
408
- const rows: PreKeysSQL[] = await this.db
409
- .selectFrom("oneTimeKeys")
410
- .selectAll()
411
- .where("deviceID", "=", deviceID)
412
- .orderBy("index")
413
- .limit(1)
414
- .execute();
415
- const otkInfo = rows[0];
416
- if (!otkInfo) {
417
- return null;
418
- }
419
- const otk: PreKeysWS = {
420
- deviceID: otkInfo.deviceID,
421
- index: otkInfo.index,
422
- publicKey: XUtils.decodeHex(otkInfo.publicKey),
423
- signature: XUtils.decodeHex(otkInfo.signature),
424
- };
524
+ // Atomic select-then-delete inside a transaction so two concurrent
525
+ // callers cannot dispense the same one-time key.
526
+ return this.db.transaction().execute(async (trx) => {
527
+ const rows: PreKeysSQL[] = await trx
528
+ .selectFrom("oneTimeKeys")
529
+ .selectAll()
530
+ .where("deviceID", "=", deviceID)
531
+ .orderBy("index")
532
+ .limit(1)
533
+ .execute();
534
+ const otkInfo = rows[0];
535
+ if (!otkInfo) {
536
+ return null;
537
+ }
425
538
 
426
- // delete the otk
427
- await this.db
428
- .deleteFrom("oneTimeKeys")
429
- .where("deviceID", "=", deviceID)
430
- .where("index", "=", otk.index)
431
- .execute();
432
- return otk;
539
+ await trx
540
+ .deleteFrom("oneTimeKeys")
541
+ .where("deviceID", "=", deviceID)
542
+ .where("index", "=", otkInfo.index)
543
+ .execute();
544
+
545
+ return {
546
+ deviceID: otkInfo.deviceID,
547
+ index: otkInfo.index,
548
+ publicKey: XUtils.decodeHex(otkInfo.publicKey),
549
+ signature: XUtils.decodeHex(otkInfo.signature),
550
+ };
551
+ });
433
552
  }
434
553
 
435
554
  public async getOTKCount(deviceID: string): Promise<number> {
@@ -605,12 +724,25 @@ export class Database extends EventEmitter {
605
724
  return rows[0] ?? null;
606
725
  }
607
726
 
608
- public async retrieveEmojiList(userID: string): Promise<Emoji[]> {
609
- return this.db
727
+ public async retrieveEmojiList(serverID: string): Promise<Emoji[]> {
728
+ const now = Date.now();
729
+ const hit = this.emojiListByServerCache.get(serverID);
730
+ if (hit && hit.until > now) {
731
+ return hit.rows.map((r) => ({ ...r }));
732
+ }
733
+
734
+ const rows: Emoji[] = await this.db
610
735
  .selectFrom("emojis")
611
736
  .selectAll()
612
- .where("owner", "=", userID)
737
+ .where("owner", "=", serverID)
613
738
  .execute();
739
+
740
+ this.emojiListByServerCache.set(serverID, {
741
+ rows: rows.map((r) => ({ ...r })),
742
+ until: now + Database.EMOJI_LIST_CACHE_TTL_MS,
743
+ });
744
+
745
+ return rows.map((r) => ({ ...r }));
614
746
  }
615
747
 
616
748
  public async retrieveFile(fileID: string): Promise<FileSQL | null> {
@@ -752,17 +884,25 @@ export class Database extends EventEmitter {
752
884
  .where("serverID", "=", serverID)
753
885
  .execute();
754
886
 
755
- return rows.filter((invite: Invite) => {
756
- const valid =
757
- new Date(Date.now()).getTime() <
758
- new Date(invite.expiration).getTime();
759
-
760
- if (!valid) {
761
- void this.deleteInvite(invite.inviteID);
887
+ const nowMs = Date.now();
888
+ const expiredIds: string[] = [];
889
+ const valid: Invite[] = [];
890
+ for (const row of rows) {
891
+ const expMs = new Date(row.expiration).getTime();
892
+ if (!Number.isFinite(expMs) || expMs <= nowMs) {
893
+ expiredIds.push(row.inviteID);
894
+ continue;
762
895
  }
763
-
764
- return valid;
765
- });
896
+ valid.push(row);
897
+ }
898
+ if (expiredIds.length > 0) {
899
+ await this.db
900
+ .deleteFrom("invites")
901
+ .where("serverID", "=", serverID)
902
+ .where("inviteID", "in", expiredIds)
903
+ .execute();
904
+ }
905
+ return valid;
766
906
  }
767
907
 
768
908
  public async retrieveServers(userID: string): Promise<Server[]> {
@@ -803,13 +943,36 @@ export class Database extends EventEmitter {
803
943
  }
804
944
 
805
945
  public async retrieveUserDeviceList(userIDs: string[]): Promise<Device[]> {
946
+ const now = Date.now();
947
+ if (userIDs.length === 1) {
948
+ const owner = userIDs[0];
949
+ if (typeof owner === "string") {
950
+ const hit = this.userDeviceListCache.get(owner);
951
+ if (hit && hit.until > now) {
952
+ return hit.devices.map((d) => ({ ...d }));
953
+ }
954
+ }
955
+ }
956
+
806
957
  const rows = await this.db
807
958
  .selectFrom("devices")
808
959
  .selectAll()
809
960
  .where("owner", "in", userIDs)
810
961
  .where("deleted", "=", 0)
811
962
  .execute();
812
- return rows.map(toDevice);
963
+ const devices = rows.map(toDevice);
964
+
965
+ if (userIDs.length === 1) {
966
+ const owner = userIDs[0];
967
+ if (typeof owner === "string") {
968
+ this.userDeviceListCache.set(owner, {
969
+ devices: devices.map((d) => ({ ...d })),
970
+ until: now + Database.USER_DEVICE_LIST_CACHE_TTL_MS,
971
+ });
972
+ }
973
+ }
974
+
975
+ return devices;
813
976
  }
814
977
 
815
978
  public async retrieveUsers(): Promise<InternalUserRecord[]> {