tetherdb 0.1.0

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 (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -0
  3. package/bin/tetherdb.js +7 -0
  4. package/dist/cli/args.d.cts +20 -0
  5. package/dist/cli/args.d.ts +20 -0
  6. package/dist/cli/args.d.ts.map +1 -0
  7. package/dist/cli/backend.d.cts +15 -0
  8. package/dist/cli/backend.d.ts +15 -0
  9. package/dist/cli/backend.d.ts.map +1 -0
  10. package/dist/cli/cli.d.cts +8 -0
  11. package/dist/cli/cli.d.ts +8 -0
  12. package/dist/cli/cli.d.ts.map +1 -0
  13. package/dist/cli/commands/apps.d.cts +9 -0
  14. package/dist/cli/commands/apps.d.ts +9 -0
  15. package/dist/cli/commands/apps.d.ts.map +1 -0
  16. package/dist/cli/commands/help.d.cts +5 -0
  17. package/dist/cli/commands/help.d.ts +5 -0
  18. package/dist/cli/commands/help.d.ts.map +1 -0
  19. package/dist/cli/commands/index.d.cts +8 -0
  20. package/dist/cli/commands/index.d.ts +8 -0
  21. package/dist/cli/commands/index.d.ts.map +1 -0
  22. package/dist/cli/commands/maintenance.d.cts +9 -0
  23. package/dist/cli/commands/maintenance.d.ts +9 -0
  24. package/dist/cli/commands/maintenance.d.ts.map +1 -0
  25. package/dist/cli/commands/serve.d.cts +14 -0
  26. package/dist/cli/commands/serve.d.ts +14 -0
  27. package/dist/cli/commands/serve.d.ts.map +1 -0
  28. package/dist/cli/commands/status.d.cts +9 -0
  29. package/dist/cli/commands/status.d.ts +9 -0
  30. package/dist/cli/commands/status.d.ts.map +1 -0
  31. package/dist/cli/commands/tables.d.cts +9 -0
  32. package/dist/cli/commands/tables.d.ts +9 -0
  33. package/dist/cli/commands/tables.d.ts.map +1 -0
  34. package/dist/cli/commands/users.d.cts +9 -0
  35. package/dist/cli/commands/users.d.ts +9 -0
  36. package/dist/cli/commands/users.d.ts.map +1 -0
  37. package/dist/cli/index.cjs +3999 -0
  38. package/dist/cli/index.cjs.map +1 -0
  39. package/dist/cli/index.d.cts +7 -0
  40. package/dist/cli/index.d.ts +7 -0
  41. package/dist/cli/index.d.ts.map +1 -0
  42. package/dist/cli/index.js +3958 -0
  43. package/dist/cli/index.js.map +1 -0
  44. package/dist/client/auth.d.cts +155 -0
  45. package/dist/client/auth.d.ts +155 -0
  46. package/dist/client/auth.d.ts.map +1 -0
  47. package/dist/client/client.d.cts +122 -0
  48. package/dist/client/client.d.ts +122 -0
  49. package/dist/client/client.d.ts.map +1 -0
  50. package/dist/client/errors.d.cts +39 -0
  51. package/dist/client/errors.d.ts +39 -0
  52. package/dist/client/errors.d.ts.map +1 -0
  53. package/dist/client/index.cjs +1742 -0
  54. package/dist/client/index.cjs.map +1 -0
  55. package/dist/client/index.d.cts +12 -0
  56. package/dist/client/index.d.ts +12 -0
  57. package/dist/client/index.d.ts.map +1 -0
  58. package/dist/client/index.js +1707 -0
  59. package/dist/client/index.js.map +1 -0
  60. package/dist/client/shared/event.d.cts +29 -0
  61. package/dist/client/shared/event.d.ts +29 -0
  62. package/dist/client/shared/event.d.ts.map +1 -0
  63. package/dist/client/shared/id.d.cts +5 -0
  64. package/dist/client/shared/id.d.ts +5 -0
  65. package/dist/client/shared/id.d.ts.map +1 -0
  66. package/dist/client/storage.d.cts +183 -0
  67. package/dist/client/storage.d.ts +183 -0
  68. package/dist/client/storage.d.ts.map +1 -0
  69. package/dist/client/sync.d.cts +126 -0
  70. package/dist/client/sync.d.ts +126 -0
  71. package/dist/client/sync.d.ts.map +1 -0
  72. package/dist/client/table.d.cts +146 -0
  73. package/dist/client/table.d.ts +146 -0
  74. package/dist/client/table.d.ts.map +1 -0
  75. package/dist/index.cjs +1742 -0
  76. package/dist/index.cjs.map +1 -0
  77. package/dist/index.d.cts +7 -0
  78. package/dist/index.d.ts +7 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +1707 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/server/crypto.d.cts +55 -0
  83. package/dist/server/crypto.d.ts +55 -0
  84. package/dist/server/crypto.d.ts.map +1 -0
  85. package/dist/server/errors.d.cts +39 -0
  86. package/dist/server/errors.d.ts +39 -0
  87. package/dist/server/errors.d.ts.map +1 -0
  88. package/dist/server/index.cjs +3621 -0
  89. package/dist/server/index.cjs.map +1 -0
  90. package/dist/server/index.d.cts +10 -0
  91. package/dist/server/index.d.ts +10 -0
  92. package/dist/server/index.d.ts.map +1 -0
  93. package/dist/server/index.js +3569 -0
  94. package/dist/server/index.js.map +1 -0
  95. package/dist/server/lock.d.cts +56 -0
  96. package/dist/server/lock.d.ts +56 -0
  97. package/dist/server/lock.d.ts.map +1 -0
  98. package/dist/server/rate-limiter.d.cts +81 -0
  99. package/dist/server/rate-limiter.d.ts +81 -0
  100. package/dist/server/rate-limiter.d.ts.map +1 -0
  101. package/dist/server/server.d.cts +206 -0
  102. package/dist/server/server.d.ts +206 -0
  103. package/dist/server/server.d.ts.map +1 -0
  104. package/dist/server/storage/app.d.cts +70 -0
  105. package/dist/server/storage/app.d.ts +70 -0
  106. package/dist/server/storage/app.d.ts.map +1 -0
  107. package/dist/server/storage/base/app.d.cts +42 -0
  108. package/dist/server/storage/base/app.d.ts +42 -0
  109. package/dist/server/storage/base/app.d.ts.map +1 -0
  110. package/dist/server/storage/base/index.d.cts +5 -0
  111. package/dist/server/storage/base/index.d.ts +5 -0
  112. package/dist/server/storage/base/index.d.ts.map +1 -0
  113. package/dist/server/storage/base/storage.d.cts +40 -0
  114. package/dist/server/storage/base/storage.d.ts +40 -0
  115. package/dist/server/storage/base/storage.d.ts.map +1 -0
  116. package/dist/server/storage/base/table.d.cts +32 -0
  117. package/dist/server/storage/base/table.d.ts +32 -0
  118. package/dist/server/storage/base/table.d.ts.map +1 -0
  119. package/dist/server/storage/base/user.d.cts +31 -0
  120. package/dist/server/storage/base/user.d.ts +31 -0
  121. package/dist/server/storage/base/user.d.ts.map +1 -0
  122. package/dist/server/storage/file/app.d.cts +50 -0
  123. package/dist/server/storage/file/app.d.ts +50 -0
  124. package/dist/server/storage/file/app.d.ts.map +1 -0
  125. package/dist/server/storage/file/index.d.cts +5 -0
  126. package/dist/server/storage/file/index.d.ts +5 -0
  127. package/dist/server/storage/file/index.d.ts.map +1 -0
  128. package/dist/server/storage/file/storage.d.cts +67 -0
  129. package/dist/server/storage/file/storage.d.ts +67 -0
  130. package/dist/server/storage/file/storage.d.ts.map +1 -0
  131. package/dist/server/storage/file/table.d.cts +14 -0
  132. package/dist/server/storage/file/table.d.ts +14 -0
  133. package/dist/server/storage/file/table.d.ts.map +1 -0
  134. package/dist/server/storage/file/user.d.cts +14 -0
  135. package/dist/server/storage/file/user.d.ts +14 -0
  136. package/dist/server/storage/file/user.d.ts.map +1 -0
  137. package/dist/server/storage/index.d.cts +14 -0
  138. package/dist/server/storage/index.d.ts +14 -0
  139. package/dist/server/storage/index.d.ts.map +1 -0
  140. package/dist/server/storage/memory/app.d.cts +29 -0
  141. package/dist/server/storage/memory/app.d.ts +29 -0
  142. package/dist/server/storage/memory/app.d.ts.map +1 -0
  143. package/dist/server/storage/memory/index.d.cts +5 -0
  144. package/dist/server/storage/memory/index.d.ts +5 -0
  145. package/dist/server/storage/memory/index.d.ts.map +1 -0
  146. package/dist/server/storage/memory/storage.d.cts +47 -0
  147. package/dist/server/storage/memory/storage.d.ts +47 -0
  148. package/dist/server/storage/memory/storage.d.ts.map +1 -0
  149. package/dist/server/storage/memory/table.d.cts +17 -0
  150. package/dist/server/storage/memory/table.d.ts +17 -0
  151. package/dist/server/storage/memory/table.d.ts.map +1 -0
  152. package/dist/server/storage/memory/user.d.cts +21 -0
  153. package/dist/server/storage/memory/user.d.ts +21 -0
  154. package/dist/server/storage/memory/user.d.ts.map +1 -0
  155. package/dist/server/storage/sqlite/app.d.cts +31 -0
  156. package/dist/server/storage/sqlite/app.d.ts +31 -0
  157. package/dist/server/storage/sqlite/app.d.ts.map +1 -0
  158. package/dist/server/storage/sqlite/index.d.cts +5 -0
  159. package/dist/server/storage/sqlite/index.d.ts +5 -0
  160. package/dist/server/storage/sqlite/index.d.ts.map +1 -0
  161. package/dist/server/storage/sqlite/storage.d.cts +92 -0
  162. package/dist/server/storage/sqlite/storage.d.ts +92 -0
  163. package/dist/server/storage/sqlite/storage.d.ts.map +1 -0
  164. package/dist/server/storage/sqlite/table.d.cts +15 -0
  165. package/dist/server/storage/sqlite/table.d.ts +15 -0
  166. package/dist/server/storage/sqlite/table.d.ts.map +1 -0
  167. package/dist/server/storage/sqlite/user.d.cts +14 -0
  168. package/dist/server/storage/sqlite/user.d.ts +14 -0
  169. package/dist/server/storage/sqlite/user.d.ts.map +1 -0
  170. package/dist/server/storage/storage.d.cts +152 -0
  171. package/dist/server/storage/storage.d.ts +152 -0
  172. package/dist/server/storage/storage.d.ts.map +1 -0
  173. package/dist/server/storage/table.d.cts +45 -0
  174. package/dist/server/storage/table.d.ts +45 -0
  175. package/dist/server/storage/table.d.ts.map +1 -0
  176. package/dist/server/storage/user.d.cts +45 -0
  177. package/dist/server/storage/user.d.ts +45 -0
  178. package/dist/server/storage/user.d.ts.map +1 -0
  179. package/dist/server/sync.d.cts +67 -0
  180. package/dist/server/sync.d.ts +67 -0
  181. package/dist/server/sync.d.ts.map +1 -0
  182. package/dist/server/validate.d.cts +113 -0
  183. package/dist/server/validate.d.ts +113 -0
  184. package/dist/server/validate.d.ts.map +1 -0
  185. package/dist/shared/clock.d.cts +22 -0
  186. package/dist/shared/clock.d.ts +22 -0
  187. package/dist/shared/clock.d.ts.map +1 -0
  188. package/dist/shared/path.d.cts +12 -0
  189. package/dist/shared/path.d.ts +12 -0
  190. package/dist/shared/path.d.ts.map +1 -0
  191. package/dist/shared/types.d.cts +234 -0
  192. package/dist/shared/types.d.ts +234 -0
  193. package/dist/shared/types.d.ts.map +1 -0
  194. package/package.json +133 -0
@@ -0,0 +1,3999 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/cli/index.ts
31
+ var cli_exports = {};
32
+ __export(cli_exports, {
33
+ runCli: () => runCli
34
+ });
35
+ module.exports = __toCommonJS(cli_exports);
36
+
37
+ // node_modules/tsup/assets/cjs_shims.js
38
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
39
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
40
+
41
+ // src/cli/cli.ts
42
+ var path8 = __toESM(require("path"), 1);
43
+ var import_node_url = require("url");
44
+
45
+ // src/shared/types.ts
46
+ var PROTOCOL_VERSION = 1;
47
+
48
+ // src/server/errors.ts
49
+ var TetherServerError = class extends Error {
50
+ /** Error category code identifying the broad error type. */
51
+ code;
52
+ /**
53
+ * Initializes a new `TetherServerError`.
54
+ *
55
+ * @param code - The error category code.
56
+ * @param message - User-safe error description message.
57
+ */
58
+ constructor(code, message) {
59
+ super(message ?? getDefaultServerErrorMessage(code));
60
+ this.name = "TetherServerError";
61
+ this.code = code;
62
+ Object.setPrototypeOf(this, new.target.prototype);
63
+ }
64
+ };
65
+ function getDefaultServerErrorMessage(code) {
66
+ switch (code) {
67
+ case 0 /* InvalidInput */:
68
+ return "Invalid request parameter";
69
+ case 1 /* NotFound */:
70
+ return "Requested resource not found";
71
+ case 2 /* AlreadyExists */:
72
+ return "Resource already exists";
73
+ case 3 /* Unauthorized */:
74
+ return "Authentication required";
75
+ case 4 /* AuthenticationFailed */:
76
+ return "Authentication failed";
77
+ case 5 /* LimitExceeded */:
78
+ return "Request or resource limit exceeded";
79
+ case 6 /* ConfigurationError */:
80
+ return "Server configuration error";
81
+ case 7 /* NotSupported */:
82
+ return "Operation not supported";
83
+ case 8 /* InternalError */:
84
+ return "Internal server error";
85
+ }
86
+ }
87
+
88
+ // src/server/server.ts
89
+ var http = __toESM(require("http"), 1);
90
+ var import_ws = require("ws");
91
+
92
+ // src/shared/path.ts
93
+ function normalizeBasePath(path9) {
94
+ if (path9 === "" || path9 === "/") return "";
95
+ if (path9.endsWith("/")) path9 = path9.slice(0, path9.length - 1);
96
+ if (!path9.startsWith("/")) path9 = `/${path9}`;
97
+ return path9 === "/" ? "" : path9;
98
+ }
99
+
100
+ // src/server/crypto.ts
101
+ var crypto = __toESM(require("crypto"), 1);
102
+ var fs = __toESM(require("fs"), 1);
103
+ var path = __toESM(require("path"), 1);
104
+ var DEFAULT_TOKEN_EXPIRES_IN = 7 * 24 * 60 * 60;
105
+ function getOrCreateKeyfileSecret(baseDir) {
106
+ const secretPath = path.join(baseDir, ".secret");
107
+ try {
108
+ if (fs.existsSync(secretPath)) {
109
+ const existing = fs.readFileSync(secretPath, "utf-8").trim();
110
+ if (existing.length >= 32) {
111
+ return existing;
112
+ }
113
+ }
114
+ } catch {
115
+ }
116
+ const generated = crypto.randomBytes(32).toString("hex");
117
+ try {
118
+ fs.mkdirSync(baseDir, { recursive: true });
119
+ fs.writeFileSync(secretPath, generated, {
120
+ encoding: "utf-8",
121
+ mode: 384
122
+ });
123
+ } catch {
124
+ }
125
+ return generated;
126
+ }
127
+ async function hashPassword(password) {
128
+ const salt = crypto.randomBytes(16).toString("hex");
129
+ return new Promise((resolve6, reject) => {
130
+ crypto.scrypt(
131
+ password.normalize("NFKC"),
132
+ salt,
133
+ 64,
134
+ SCRYPT_OPTIONS,
135
+ (err, derivedKey) => {
136
+ if (err) return reject(err);
137
+ resolve6(`scrypt$${salt}$${derivedKey.toString("hex")}`);
138
+ }
139
+ );
140
+ });
141
+ }
142
+ async function verifyPasswordHash(password, storedHash) {
143
+ if (!storedHash || typeof storedHash !== "string") return false;
144
+ const parts = storedHash.split("$");
145
+ if (parts.length !== 3 || parts[0] !== "scrypt") return false;
146
+ const [, salt, expectedHex] = parts;
147
+ return new Promise((resolve6) => {
148
+ crypto.scrypt(
149
+ password.normalize("NFKC"),
150
+ salt,
151
+ 64,
152
+ SCRYPT_OPTIONS,
153
+ (err, derivedKey) => {
154
+ if (err) return resolve6(false);
155
+ const expectedBuf = Buffer.from(expectedHex, "hex");
156
+ if (derivedKey.length !== expectedBuf.length) return resolve6(false);
157
+ resolve6(crypto.timingSafeEqual(derivedKey, expectedBuf));
158
+ }
159
+ );
160
+ });
161
+ }
162
+ async function verifyDummyPasswordHash(password) {
163
+ if (!dummyPasswordHashPromise) {
164
+ dummyPasswordHashPromise = hashPassword("TetherDB:dummy_seed_password");
165
+ }
166
+ const dummyHash = await dummyPasswordHashPromise;
167
+ return verifyPasswordHash(password, dummyHash);
168
+ }
169
+ function createSessionToken(userId, username, secret, expiresInSeconds = DEFAULT_TOKEN_EXPIRES_IN) {
170
+ const expiresAt = Math.floor(Date.now() / 1e3) + expiresInSeconds;
171
+ const payload = JSON.stringify({
172
+ userId,
173
+ username,
174
+ expiresAt
175
+ });
176
+ const payloadB64 = Buffer.from(payload, "utf-8").toString("base64url");
177
+ const signature = crypto.createHmac("sha256", secret).update(payloadB64).digest("base64url");
178
+ return `${payloadB64}.${signature}`;
179
+ }
180
+ function verifySessionToken(token, secret) {
181
+ if (!token || typeof token !== "string") return null;
182
+ const parts = token.split(".");
183
+ if (parts.length !== 2) return null;
184
+ const [payloadB64, signature] = parts;
185
+ const expectedSig = crypto.createHmac("sha256", secret).update(payloadB64).digest("base64url");
186
+ if (signature.length !== expectedSig.length || !crypto.timingSafeEqual(
187
+ Buffer.from(signature, "utf-8"),
188
+ Buffer.from(expectedSig, "utf-8")
189
+ )) {
190
+ return null;
191
+ }
192
+ try {
193
+ const raw = Buffer.from(payloadB64, "base64url").toString("utf-8");
194
+ const parsed = JSON.parse(raw);
195
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.userId !== "string" || !parsed.userId || typeof parsed.username !== "string" || !parsed.username || typeof parsed.expiresAt !== "number" || !Number.isFinite(parsed.expiresAt) || parsed.expiresAt < Math.floor(Date.now() / 1e3)) {
196
+ return null;
197
+ }
198
+ return {
199
+ userId: parsed.userId,
200
+ username: parsed.username,
201
+ expiresAt: parsed.expiresAt
202
+ };
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+ var SCRYPT_OPTIONS = {
208
+ N: process.env.NODE_ENV === "test" ? 512 : 16384,
209
+ r: 8,
210
+ p: 1,
211
+ maxmem: 32 * 1024 * 1024
212
+ };
213
+ var dummyPasswordHashPromise = null;
214
+
215
+ // src/server/lock.ts
216
+ var fs2 = __toESM(require("fs"), 1);
217
+ var path2 = __toESM(require("path"), 1);
218
+ function isProcessAlive(pid) {
219
+ if (!pid || pid <= 0) return false;
220
+ try {
221
+ process.kill(pid, 0);
222
+ return true;
223
+ } catch (err) {
224
+ const code = err.code;
225
+ return code === "EPERM";
226
+ }
227
+ }
228
+ function readServerLock(baseDir) {
229
+ const lockPath = path2.join(baseDir, "server.lock");
230
+ try {
231
+ if (!fs2.existsSync(lockPath)) return null;
232
+ const content = fs2.readFileSync(lockPath, "utf-8");
233
+ const info = JSON.parse(content);
234
+ if (typeof info.pid === "number" && isProcessAlive(info.pid)) {
235
+ return info;
236
+ }
237
+ return null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+ function acquireServerLock(baseDir, details) {
243
+ fs2.mkdirSync(baseDir, { recursive: true });
244
+ const lockPath = path2.join(baseDir, "server.lock");
245
+ const existing = readServerLock(baseDir);
246
+ if (existing && existing.pid !== process.pid) {
247
+ throw new TetherServerError(
248
+ 2 /* AlreadyExists */,
249
+ "A TetherDB server is already running on this data directory"
250
+ );
251
+ }
252
+ if (fs2.existsSync(lockPath)) {
253
+ try {
254
+ fs2.unlinkSync(lockPath);
255
+ } catch {
256
+ }
257
+ }
258
+ const info = {
259
+ pid: process.pid,
260
+ port: details.port,
261
+ host: details.host,
262
+ backend: details.backend,
263
+ startedAt: Date.now()
264
+ };
265
+ fs2.writeFileSync(lockPath, JSON.stringify(info, null, 2), {
266
+ encoding: "utf-8",
267
+ mode: 384
268
+ });
269
+ let isReleased = false;
270
+ const release = () => {
271
+ if (isReleased) return;
272
+ isReleased = true;
273
+ try {
274
+ if (fs2.existsSync(lockPath)) {
275
+ const current = JSON.parse(
276
+ fs2.readFileSync(lockPath, "utf-8")
277
+ );
278
+ if (current.pid === process.pid) {
279
+ fs2.unlinkSync(lockPath);
280
+ }
281
+ }
282
+ } catch {
283
+ }
284
+ };
285
+ return {
286
+ info,
287
+ lockPath,
288
+ release
289
+ };
290
+ }
291
+
292
+ // src/server/rate-limiter.ts
293
+ var RateLimiter = class {
294
+ store = /* @__PURE__ */ new Map();
295
+ windowMs;
296
+ maxRequests;
297
+ maxFailures;
298
+ initialBackoffMs;
299
+ maxBackoffMs;
300
+ maxEntries;
301
+ /**
302
+ * Initializes a new RateLimiter instance.
303
+ *
304
+ * @param options - Configuration options for window size, request limits, and backoff.
305
+ */
306
+ constructor(options = {}) {
307
+ this.windowMs = options.windowMs ?? 6e4;
308
+ this.maxRequests = options.maxRequests ?? 60;
309
+ this.maxFailures = options.maxFailures ?? 3;
310
+ this.initialBackoffMs = options.initialBackoffMs ?? 1e3;
311
+ this.maxBackoffMs = options.maxBackoffMs ?? 9e5;
312
+ this.maxEntries = options.maxEntries ?? 1e4;
313
+ }
314
+ /**
315
+ * Returns the current number of tracked keys in memory.
316
+ */
317
+ get size() {
318
+ return this.store.size;
319
+ }
320
+ /**
321
+ * Checks whether the given key is currently rate limited or blocked by backoff cooldown.
322
+ *
323
+ * @param key - Identifier (e.g. IP address or username).
324
+ * @param now - Current timestamp in milliseconds (defaults to Date.now()).
325
+ * @returns `true` if requests for this key are limited; `false` otherwise.
326
+ */
327
+ isLimited(key, now = Date.now()) {
328
+ const entry = this.store.get(key);
329
+ if (!entry) return false;
330
+ if (entry.blockedUntil > now) {
331
+ return true;
332
+ }
333
+ if (entry.resetAt <= now) {
334
+ this.store.delete(key);
335
+ return false;
336
+ }
337
+ return entry.count >= this.maxRequests;
338
+ }
339
+ /**
340
+ * Consumes one attempt for the given key if not currently limited.
341
+ *
342
+ * @param key - Identifier.
343
+ * @param now - Current timestamp in milliseconds.
344
+ * @returns `true` if request was allowed and consumed; `false` if rate limited.
345
+ */
346
+ consume(key, now = Date.now()) {
347
+ if (this.isLimited(key, now)) {
348
+ return false;
349
+ }
350
+ const entry = this.store.get(key);
351
+ if (!entry || entry.resetAt <= now && entry.blockedUntil <= now) {
352
+ this.setEntry(
353
+ key,
354
+ {
355
+ count: 1,
356
+ resetAt: now + this.windowMs,
357
+ failures: 0,
358
+ blockedUntil: 0
359
+ },
360
+ now
361
+ );
362
+ return true;
363
+ }
364
+ entry.count++;
365
+ return true;
366
+ }
367
+ /**
368
+ * Records a failed attempt for the given key and applies progressive exponential backoff.
369
+ *
370
+ * @param key - Identifier.
371
+ * @param now - Current timestamp in milliseconds.
372
+ * @returns Cooldown duration in milliseconds if blocked, or 0 if under failure threshold.
373
+ */
374
+ recordFailure(key, now = Date.now()) {
375
+ let entry = this.store.get(key);
376
+ if (!entry || entry.resetAt <= now && entry.blockedUntil <= now) {
377
+ entry = {
378
+ count: 1,
379
+ resetAt: now + this.windowMs,
380
+ failures: 0,
381
+ blockedUntil: 0
382
+ };
383
+ this.setEntry(key, entry, now);
384
+ }
385
+ entry.failures++;
386
+ if (entry.failures >= this.maxFailures) {
387
+ const exponent = entry.failures - this.maxFailures;
388
+ const backoff = Math.min(
389
+ this.initialBackoffMs * 2 ** exponent,
390
+ this.maxBackoffMs
391
+ );
392
+ entry.blockedUntil = now + backoff;
393
+ return backoff;
394
+ }
395
+ return 0;
396
+ }
397
+ /**
398
+ * Resets all failure counters and request tracking for the given key.
399
+ *
400
+ * @param key - Identifier to reset.
401
+ */
402
+ reset(key) {
403
+ this.store.delete(key);
404
+ }
405
+ /**
406
+ * Clears all stored rate limit entries.
407
+ */
408
+ clear() {
409
+ this.store.clear();
410
+ }
411
+ /**
412
+ * Purges expired entries from the internal store.
413
+ *
414
+ * @param now - Current timestamp in milliseconds.
415
+ */
416
+ cleanup(now = Date.now()) {
417
+ for (const [key, entry] of this.store.entries()) {
418
+ if (entry.resetAt <= now && entry.blockedUntil <= now) {
419
+ this.store.delete(key);
420
+ }
421
+ }
422
+ }
423
+ // -- Private Helpers --------------------------------------------------------
424
+ setEntry(key, entry, now) {
425
+ if (this.store.size >= this.maxEntries && !this.store.has(key)) {
426
+ this.cleanup(now);
427
+ if (this.store.size >= this.maxEntries) {
428
+ const oldestKey = this.store.keys().next().value;
429
+ if (oldestKey !== void 0) {
430
+ this.store.delete(oldestKey);
431
+ }
432
+ }
433
+ }
434
+ this.store.set(key, entry);
435
+ }
436
+ };
437
+
438
+ // src/shared/clock.ts
439
+ function shouldOverwrite(incoming, existing) {
440
+ if (!existing) return true;
441
+ if (incoming.timestamp > existing.timestamp) {
442
+ return true;
443
+ }
444
+ if (incoming.timestamp < existing.timestamp) {
445
+ return false;
446
+ }
447
+ const incomingClient = incoming.clientId ?? "";
448
+ const existingClient = existing.clientId ?? "";
449
+ return incomingClient >= existingClient;
450
+ }
451
+
452
+ // src/server/validate.ts
453
+ var MIN_USERNAME_LENGTH = 4;
454
+ var MAX_USERNAME_LENGTH = 128;
455
+ var MIN_PASSWORD_LENGTH = 4;
456
+ var MAX_PASSWORD_LENGTH = 512;
457
+ var MAX_FUTURE_TIMESTAMP_DRIFT_MS = 5 * 60 * 1e3;
458
+ function validateTimestamp(timestamp, maxFutureDriftMs = MAX_FUTURE_TIMESTAMP_DRIFT_MS) {
459
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) {
460
+ throw new TetherServerError(
461
+ 0 /* InvalidInput */,
462
+ "Invalid timestamp"
463
+ );
464
+ }
465
+ if (timestamp > Date.now() + maxFutureDriftMs) {
466
+ throw new TetherServerError(
467
+ 0 /* InvalidInput */,
468
+ "Timestamp drift exceeds maximum allowable threshold"
469
+ );
470
+ }
471
+ return timestamp;
472
+ }
473
+ function validateUserId(userId) {
474
+ return validateFilesystemSafe(userId, "user ID");
475
+ }
476
+ function validateAppId(appId) {
477
+ return validateFilesystemSafe(appId, "application ID");
478
+ }
479
+ function validateTableName(tableName) {
480
+ return validateFilesystemSafe(tableName, "table name");
481
+ }
482
+ function validateRecordId(id) {
483
+ if (typeof id !== "string" || id.length === 0 || id.length > 512) {
484
+ throw new TetherServerError(
485
+ 0 /* InvalidInput */,
486
+ "Invalid record ID"
487
+ );
488
+ }
489
+ return id;
490
+ }
491
+ function normalizeUsername(username) {
492
+ return typeof username === "string" ? username.trim().toLowerCase() : "";
493
+ }
494
+ function validateUsername(username) {
495
+ if (typeof username !== "string") {
496
+ throw new TetherServerError(
497
+ 0 /* InvalidInput */,
498
+ `Username must be between ${MIN_USERNAME_LENGTH} and ${MAX_USERNAME_LENGTH} characters`
499
+ );
500
+ }
501
+ const normalized = normalizeUsername(username);
502
+ if (normalized.length < MIN_USERNAME_LENGTH || normalized.length > MAX_USERNAME_LENGTH) {
503
+ throw new TetherServerError(
504
+ 0 /* InvalidInput */,
505
+ `Username must be between ${MIN_USERNAME_LENGTH} and ${MAX_USERNAME_LENGTH} characters`
506
+ );
507
+ }
508
+ return normalized;
509
+ }
510
+ function normalizePassword(password) {
511
+ return typeof password === "string" ? password.trim() : "";
512
+ }
513
+ function validatePassword(password) {
514
+ if (typeof password !== "string") {
515
+ throw new TetherServerError(
516
+ 0 /* InvalidInput */,
517
+ "Password must be a valid non-empty string"
518
+ );
519
+ }
520
+ const normalized = normalizePassword(password);
521
+ if (normalized.length < MIN_PASSWORD_LENGTH || normalized.length > MAX_PASSWORD_LENGTH) {
522
+ throw new TetherServerError(
523
+ 0 /* InvalidInput */,
524
+ `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`
525
+ );
526
+ }
527
+ return normalized;
528
+ }
529
+ function validateIdentifier(id, name = "identifier") {
530
+ if (typeof id !== "string" || !/^[a-zA-Z0-9_-]{2,128}$/.test(id)) {
531
+ throw new TetherServerError(
532
+ 0 /* InvalidInput */,
533
+ `Invalid ${name}`
534
+ );
535
+ }
536
+ return id;
537
+ }
538
+ function calculateByteSize(value) {
539
+ if (value === null || value === void 0) return 0;
540
+ if (typeof value === "string") return Buffer.byteLength(value, "utf-8");
541
+ if (typeof value === "number") return 8;
542
+ if (typeof value === "boolean") return 4;
543
+ try {
544
+ return Buffer.byteLength(JSON.stringify(value), "utf-8");
545
+ } catch {
546
+ return 0;
547
+ }
548
+ }
549
+ function getUserBucket(userId) {
550
+ const safeId = validateUserId(userId);
551
+ const clean = safeId.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
552
+ return clean.length >= 2 ? clean.slice(0, 2) : clean.padStart(2, "0");
553
+ }
554
+ function validateFilesystemSafe(id, name) {
555
+ if (typeof id !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(id)) {
556
+ throw new TetherServerError(
557
+ 0 /* InvalidInput */,
558
+ `Invalid ${name}`
559
+ );
560
+ }
561
+ return id;
562
+ }
563
+
564
+ // src/server/storage/base/app.ts
565
+ var AppBaseStorage = class {
566
+ id;
567
+ constructor(id) {
568
+ this.id = id;
569
+ }
570
+ };
571
+ function applyChangeToRecord(change, existing, seq) {
572
+ const isDeleted = change.op === "delete" /* Delete */;
573
+ const nextVersion = (existing?.version ?? 0) + 1;
574
+ const updatedRecord = {
575
+ id: change.id,
576
+ version: nextVersion,
577
+ timestamp: change.timestamp,
578
+ clientId: change.clientId,
579
+ deleted: isDeleted,
580
+ data: isDeleted ? null : change.data ?? null
581
+ };
582
+ const appliedChange = {
583
+ seq,
584
+ table: change.table,
585
+ id: change.id,
586
+ op: change.op,
587
+ version: nextVersion,
588
+ timestamp: change.timestamp,
589
+ clientId: change.clientId,
590
+ data: isDeleted ? void 0 : change.data
591
+ };
592
+ return { updatedRecord, appliedChange };
593
+ }
594
+
595
+ // src/server/storage/base/storage.ts
596
+ var BaseStorage = class {
597
+ options;
598
+ constructor(options) {
599
+ this.options = options;
600
+ }
601
+ /** Optional storage base directory if disk-backed. */
602
+ getBaseDir() {
603
+ return void 0;
604
+ }
605
+ async getUserByToken(token) {
606
+ const payload = verifySessionToken(token, this.secret);
607
+ if (!payload) return void 0;
608
+ return this.getUser(payload.userId);
609
+ }
610
+ async getStatus(appId) {
611
+ const users = await this.getUsers();
612
+ const allApps = await this.getApps();
613
+ const targetApps = filterTargetApps(allApps, appId);
614
+ const apps = await buildAppSummaries(targetApps);
615
+ const status = {
616
+ backend: this.backend,
617
+ usersCount: users.length,
618
+ appsCount: allApps.length,
619
+ apps
620
+ };
621
+ const baseDir = this.getBaseDir();
622
+ if (baseDir !== void 0) {
623
+ status.baseDir = baseDir;
624
+ }
625
+ return status;
626
+ }
627
+ };
628
+ function filterTargetApps(allApps, appId) {
629
+ const targetApps = appId ? allApps.filter((a) => a.id === validateAppId(appId)) : allApps;
630
+ if (appId && targetApps.length === 0) {
631
+ throw new TetherServerError(
632
+ 1 /* NotFound */,
633
+ `Application "${appId}" not found`
634
+ );
635
+ }
636
+ return targetApps;
637
+ }
638
+ async function buildAppSummaries(apps) {
639
+ const appSummaries = [];
640
+ for (const app of apps) {
641
+ const tables = await app.getTables();
642
+ appSummaries.push({
643
+ id: app.id,
644
+ tables: tables.map((t) => t.name)
645
+ });
646
+ }
647
+ return appSummaries;
648
+ }
649
+
650
+ // src/server/storage/base/table.ts
651
+ var TableBaseStorage = class {
652
+ name;
653
+ app;
654
+ constructor(name, app) {
655
+ this.name = name;
656
+ this.app = app;
657
+ }
658
+ /** Applies batch mutation changes to this table. */
659
+ async applyChanges(user, changes) {
660
+ return this.app.applyChanges(
661
+ user,
662
+ targetChangesForTable(this.name, this.app.id, changes)
663
+ );
664
+ }
665
+ };
666
+ function filterActiveRecords(tableName, records) {
667
+ const items = [];
668
+ for (const rec of records) {
669
+ if (!rec.deleted) {
670
+ items.push({
671
+ ...rec,
672
+ table: tableName
673
+ });
674
+ }
675
+ }
676
+ return items;
677
+ }
678
+ function targetChangesForTable(tableName, appId, changes) {
679
+ return changes.map((c) => ({
680
+ ...c,
681
+ table: tableName,
682
+ appId
683
+ }));
684
+ }
685
+
686
+ // src/server/storage/base/user.ts
687
+ var UserBaseStorage = class {
688
+ id;
689
+ username;
690
+ createdAt;
691
+ constructor(id, username, createdAt) {
692
+ this.id = id;
693
+ this.username = username;
694
+ this.createdAt = createdAt;
695
+ }
696
+ /** Creates a signed session token for this user. */
697
+ async createToken(expiresInSeconds) {
698
+ return createSessionToken(
699
+ this.id,
700
+ this.username,
701
+ this.getSecret(),
702
+ expiresInSeconds
703
+ );
704
+ }
705
+ /** Verifies whether the session token is valid for this user. */
706
+ async verifyToken(token) {
707
+ const payload = verifySessionToken(token, this.getSecret());
708
+ return payload !== null && payload.userId === this.id;
709
+ }
710
+ };
711
+ async function verifyUserPassword(password, passwordHash) {
712
+ if (!passwordHash) return false;
713
+ const normalized = normalizePassword(password);
714
+ if (!normalized) return false;
715
+ return verifyPasswordHash(normalized, passwordHash);
716
+ }
717
+ async function hashUserPassword(newPassword) {
718
+ const valid = validatePassword(newPassword);
719
+ return hashPassword(valid);
720
+ }
721
+
722
+ // src/server/storage/memory/table.ts
723
+ var TableMemoryStorage = class extends TableBaseStorage {
724
+ storage;
725
+ constructor(name, app, storage) {
726
+ super(name, app);
727
+ this.storage = storage;
728
+ }
729
+ async getRecord(user, id) {
730
+ const safeId = validateRecordId(id);
731
+ const userState = this.storage.getUserState(user.id, this.app.id);
732
+ const tableMap = userState.tables.get(this.name);
733
+ const record = tableMap?.get(safeId);
734
+ if (!record || record.deleted) {
735
+ return void 0;
736
+ }
737
+ return record;
738
+ }
739
+ async getAllRecords(user) {
740
+ const userState = this.storage.getUserState(user.id, this.app.id);
741
+ const tableMap = userState.tables.get(this.name);
742
+ return tableMap ? filterActiveRecords(this.name, tableMap.values()) : [];
743
+ }
744
+ async delete() {
745
+ return this.app.deleteTable(this.name);
746
+ }
747
+ };
748
+
749
+ // src/server/storage/memory/app.ts
750
+ var AppMemoryStorage = class extends AppBaseStorage {
751
+ tables = /* @__PURE__ */ new Map();
752
+ storage;
753
+ constructor(id, storage) {
754
+ super(id);
755
+ this.storage = storage;
756
+ }
757
+ async createTable(name) {
758
+ const safeName = validateTableName(name);
759
+ if (this.tables.has(safeName)) {
760
+ throw new TetherServerError(
761
+ 2 /* AlreadyExists */,
762
+ "Table already exists in this application"
763
+ );
764
+ }
765
+ const table = new TableMemoryStorage(safeName, this, this.storage);
766
+ this.tables.set(safeName, table);
767
+ return table;
768
+ }
769
+ async getTable(name) {
770
+ const safeName = validateTableName(name);
771
+ return this.tables.get(safeName);
772
+ }
773
+ async getTables() {
774
+ return Array.from(this.tables.values());
775
+ }
776
+ async applyChanges(user, changes) {
777
+ const userState = this.storage.getUserState(user.id, this.id);
778
+ const maxRecords = this.storage.options.maxRecordsPerTable ?? 1e4;
779
+ const maxRecordSize = this.storage.options.maxRecordSizeBytes ?? 512 * 1024;
780
+ const maxChangelog = this.storage.options.maxChangelogEntries ?? 1e3;
781
+ for (const change of changes) {
782
+ const tableName = validateTableName(change.table);
783
+ validateRecordId(change.id);
784
+ validateTimestamp(change.timestamp);
785
+ if (!this.tables.has(tableName)) {
786
+ throw new TetherServerError(
787
+ 1 /* NotFound */,
788
+ "Table not found"
789
+ );
790
+ }
791
+ const payloadBytes = calculateByteSize(change.data);
792
+ if (payloadBytes > maxRecordSize) {
793
+ throw new TetherServerError(
794
+ 5 /* LimitExceeded */,
795
+ "Record payload exceeds maximum allowed size"
796
+ );
797
+ }
798
+ }
799
+ const stagedTables = /* @__PURE__ */ new Map();
800
+ const stagedApplied = [];
801
+ let stagedCurrentSeq = userState.currentSeq;
802
+ let stagedMinSeq = userState.minSeq;
803
+ for (const change of changes) {
804
+ const tableName = validateTableName(change.table);
805
+ const recordId = validateRecordId(change.id);
806
+ let tableMap = stagedTables.get(tableName);
807
+ if (!tableMap) {
808
+ const existingTableMap = userState.tables.get(tableName);
809
+ tableMap = new Map(existingTableMap);
810
+ stagedTables.set(tableName, tableMap);
811
+ }
812
+ if (change.op === "put" /* Put */ && !tableMap.has(recordId) && tableMap.size >= maxRecords) {
813
+ throw new TetherServerError(
814
+ 5 /* LimitExceeded */,
815
+ "Table record limit reached"
816
+ );
817
+ }
818
+ const existing = tableMap.get(recordId);
819
+ const shouldApply = !existing || shouldOverwrite(change, existing);
820
+ if (shouldApply) {
821
+ stagedCurrentSeq++;
822
+ const assignedSeq = stagedCurrentSeq;
823
+ if (stagedMinSeq === 0) {
824
+ stagedMinSeq = 1;
825
+ }
826
+ const { updatedRecord, appliedChange } = applyChangeToRecord(
827
+ change,
828
+ existing,
829
+ assignedSeq
830
+ );
831
+ tableMap.set(recordId, updatedRecord);
832
+ stagedApplied.push(appliedChange);
833
+ }
834
+ }
835
+ for (const [tableName, stagedMap] of stagedTables.entries()) {
836
+ userState.tables.set(tableName, stagedMap);
837
+ }
838
+ userState.currentSeq = stagedCurrentSeq;
839
+ userState.minSeq = stagedMinSeq;
840
+ userState.changelog.push(...stagedApplied);
841
+ if (userState.changelog.length > maxChangelog) {
842
+ const pruneCount = userState.changelog.length - maxChangelog;
843
+ userState.changelog.splice(0, pruneCount);
844
+ if (userState.changelog.length > 0) {
845
+ userState.minSeq = userState.changelog[0].seq;
846
+ }
847
+ }
848
+ return { applied: stagedApplied, newSeq: userState.currentSeq };
849
+ }
850
+ async getChangesSince(user, fromSeq) {
851
+ const userState = this.storage.getUserState(user.id, this.id);
852
+ const currentSeq = userState.currentSeq;
853
+ const minSeq = userState.minSeq;
854
+ if (fromSeq < minSeq && minSeq > 0 || fromSeq > currentSeq) {
855
+ return { changes: [], currentSeq, requiresSnapshot: true };
856
+ }
857
+ const changes = userState.changelog.filter((c) => c.seq > fromSeq);
858
+ return { changes, currentSeq, requiresSnapshot: false };
859
+ }
860
+ async getCurrentSeq(user) {
861
+ const userState = this.storage.getUserState(user.id, this.id);
862
+ return userState.currentSeq;
863
+ }
864
+ async delete() {
865
+ return this.storage.deleteApp(this.id);
866
+ }
867
+ deleteTable(name) {
868
+ const safeName = validateTableName(name);
869
+ const deleted = this.tables.delete(safeName);
870
+ this.storage.deleteTableInUserStates(this.id, safeName);
871
+ return deleted;
872
+ }
873
+ };
874
+
875
+ // src/server/storage/memory/storage.ts
876
+ var crypto2 = __toESM(require("crypto"), 1);
877
+
878
+ // src/server/storage/memory/user.ts
879
+ var UserMemoryStorage = class extends UserBaseStorage {
880
+ storage;
881
+ constructor(data, storage) {
882
+ super(data.id, data.username, data.createdAt);
883
+ this.storage = storage;
884
+ }
885
+ getSecret() {
886
+ return this.storage.secret;
887
+ }
888
+ getUserData() {
889
+ const data = this.storage.getUserData(this.id);
890
+ if (!data) {
891
+ throw new TetherServerError(
892
+ 1 /* NotFound */,
893
+ "User not found"
894
+ );
895
+ }
896
+ return data;
897
+ }
898
+ async verifyPassword(password) {
899
+ const data = this.getUserData();
900
+ return verifyUserPassword(password, data.passwordHash);
901
+ }
902
+ async changePassword(newPassword) {
903
+ const data = this.getUserData();
904
+ data.passwordHash = await hashUserPassword(newPassword);
905
+ }
906
+ async delete() {
907
+ return this.storage.deleteUser(this.id);
908
+ }
909
+ };
910
+
911
+ // src/server/storage/memory/storage.ts
912
+ var MemoryStorage = class extends BaseStorage {
913
+ backend = "memory";
914
+ apps = /* @__PURE__ */ new Map();
915
+ userStates = /* @__PURE__ */ new Map();
916
+ // key = `${appId}:${userId}`
917
+ users = /* @__PURE__ */ new Map();
918
+ // key = userId
919
+ usersByUsername = /* @__PURE__ */ new Map();
920
+ // username -> userId
921
+ secret;
922
+ options;
923
+ constructor(options = {}) {
924
+ super(options);
925
+ this.options = options;
926
+ this.secret = options.secret ?? crypto2.randomBytes(32).toString("hex");
927
+ }
928
+ getUserState(userId, appId) {
929
+ const safeAppId = validateAppId(appId);
930
+ const safeUserId = validateUserId(userId);
931
+ const key = `${safeAppId}:${safeUserId}`;
932
+ let state = this.userStates.get(key);
933
+ if (!state) {
934
+ state = {
935
+ currentSeq: 0,
936
+ minSeq: 0,
937
+ tables: /* @__PURE__ */ new Map(),
938
+ changelog: []
939
+ };
940
+ this.userStates.set(key, state);
941
+ }
942
+ return state;
943
+ }
944
+ deleteUserState(userId) {
945
+ const safeUserId = validateUserId(userId);
946
+ let deleted = false;
947
+ for (const key of Array.from(this.userStates.keys())) {
948
+ if (key.endsWith(`:${safeUserId}`)) {
949
+ this.userStates.delete(key);
950
+ deleted = true;
951
+ }
952
+ }
953
+ return deleted;
954
+ }
955
+ deleteAppUserStates(appId) {
956
+ const safeAppId = validateAppId(appId);
957
+ for (const key of Array.from(this.userStates.keys())) {
958
+ if (key.startsWith(`${safeAppId}:`)) {
959
+ this.userStates.delete(key);
960
+ }
961
+ }
962
+ }
963
+ deleteTableInUserStates(appId, tableName) {
964
+ const safeAppId = validateAppId(appId);
965
+ for (const [key, state] of this.userStates.entries()) {
966
+ if (key.startsWith(`${safeAppId}:`)) {
967
+ state.tables.delete(tableName);
968
+ }
969
+ }
970
+ }
971
+ async createApp(id) {
972
+ const safeId = validateAppId(id);
973
+ if (this.apps.has(safeId)) {
974
+ throw new TetherServerError(
975
+ 2 /* AlreadyExists */,
976
+ "Application already exists"
977
+ );
978
+ }
979
+ const app = new AppMemoryStorage(safeId, this);
980
+ this.apps.set(safeId, app);
981
+ return app;
982
+ }
983
+ async getApp(id) {
984
+ const safeId = validateAppId(id);
985
+ return this.apps.get(safeId);
986
+ }
987
+ async getApps() {
988
+ return Array.from(this.apps.values());
989
+ }
990
+ async createUser(username, password) {
991
+ const safeUsername = validateUsername(username);
992
+ const validPassword = validatePassword(password);
993
+ if (this.usersByUsername.has(safeUsername)) {
994
+ throw new TetherServerError(
995
+ 2 /* AlreadyExists */,
996
+ "Username is already registered"
997
+ );
998
+ }
999
+ const userId = crypto2.randomUUID();
1000
+ const passwordHash = await hashPassword(validPassword);
1001
+ const userData = {
1002
+ id: userId,
1003
+ username: safeUsername,
1004
+ passwordHash,
1005
+ createdAt: Date.now()
1006
+ };
1007
+ this.users.set(userId, userData);
1008
+ this.usersByUsername.set(safeUsername, userId);
1009
+ return new UserMemoryStorage(userData, this);
1010
+ }
1011
+ getUserData(userId) {
1012
+ return this.users.get(userId);
1013
+ }
1014
+ async getUser(id) {
1015
+ const safeUserId = validateUserId(id);
1016
+ const data = this.users.get(safeUserId);
1017
+ if (data) {
1018
+ return new UserMemoryStorage(data, this);
1019
+ }
1020
+ return void 0;
1021
+ }
1022
+ async getUserByUsername(username) {
1023
+ const safeUsername = normalizeUsername(username);
1024
+ if (!safeUsername) return void 0;
1025
+ const userId = this.usersByUsername.get(safeUsername);
1026
+ if (!userId) return void 0;
1027
+ const data = this.users.get(userId);
1028
+ if (data) {
1029
+ return new UserMemoryStorage(data, this);
1030
+ }
1031
+ return void 0;
1032
+ }
1033
+ async getUsers() {
1034
+ return Array.from(this.users.values()).map(
1035
+ (data) => new UserMemoryStorage(data, this)
1036
+ );
1037
+ }
1038
+ deleteUser(id) {
1039
+ const safeUserId = validateUserId(id);
1040
+ this.deleteUserState(safeUserId);
1041
+ const data = this.users.get(safeUserId);
1042
+ if (data) {
1043
+ this.usersByUsername.delete(data.username);
1044
+ this.users.delete(safeUserId);
1045
+ return true;
1046
+ }
1047
+ return false;
1048
+ }
1049
+ deleteApp(id) {
1050
+ const safeId = validateAppId(id);
1051
+ this.deleteAppUserStates(safeId);
1052
+ return this.apps.delete(safeId);
1053
+ }
1054
+ async checkpoint(appId) {
1055
+ throw new TetherServerError(
1056
+ 7 /* NotSupported */,
1057
+ `Checkpoint operation is not supported by memory storage${appId ? ` (app: ${appId})` : ""}`
1058
+ );
1059
+ }
1060
+ async vacuum(appId) {
1061
+ throw new TetherServerError(
1062
+ 7 /* NotSupported */,
1063
+ `Vacuum operation is not supported by memory storage${appId ? ` (app: ${appId})` : ""}`
1064
+ );
1065
+ }
1066
+ async prune(appId, keepCount) {
1067
+ const keep = keepCount ?? this.options.maxChangelogEntries ?? 1e3;
1068
+ const allApps = await this.getApps();
1069
+ const targetApps = filterTargetApps(allApps, appId);
1070
+ let totalPruned = 0;
1071
+ for (const app of targetApps) {
1072
+ for (const [key, state] of this.userStates.entries()) {
1073
+ if (key.startsWith(`${app.id}:`)) {
1074
+ if (state.changelog.length > keep) {
1075
+ const pruneCount = state.changelog.length - keep;
1076
+ state.changelog.splice(0, pruneCount);
1077
+ if (state.changelog.length > 0) {
1078
+ state.minSeq = state.changelog[0].seq;
1079
+ }
1080
+ totalPruned += pruneCount;
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+ return {
1086
+ action: "prune",
1087
+ backend: "memory",
1088
+ appId,
1089
+ affectedCount: totalPruned,
1090
+ message: `Prune completed successfully. Removed ${totalPruned} changelog record(s)`
1091
+ };
1092
+ }
1093
+ async close() {
1094
+ this.apps.clear();
1095
+ this.userStates.clear();
1096
+ this.users.clear();
1097
+ this.usersByUsername.clear();
1098
+ }
1099
+ };
1100
+
1101
+ // src/server/sync.ts
1102
+ var Sync = class {
1103
+ storage;
1104
+ maxConcurrentConnectionsPerUser;
1105
+ authTimeoutMs;
1106
+ rateLimiter;
1107
+ logger;
1108
+ userClients = /* @__PURE__ */ new Map();
1109
+ // key = `${appId}:${userId}`
1110
+ webSocketToClient = /* @__PURE__ */ new Map();
1111
+ pendingAuthTimers = /* @__PURE__ */ new Map();
1112
+ webSocketToIp = /* @__PURE__ */ new Map();
1113
+ /**
1114
+ * Initializes a new Sync coordinator instance.
1115
+ *
1116
+ * @param storage - Pluggable backend storage engine.
1117
+ * @param options - Configuration options for concurrency limits, auth timeout, and rate limiting.
1118
+ */
1119
+ constructor(storage, options = {}) {
1120
+ this.storage = storage;
1121
+ this.maxConcurrentConnectionsPerUser = options.maxConcurrentConnectionsPerUser ?? 20;
1122
+ this.authTimeoutMs = options.authTimeoutMs ?? 1e4;
1123
+ this.rateLimiter = options.rateLimiter ?? null;
1124
+ this.logger = options.logger ?? null;
1125
+ }
1126
+ /**
1127
+ * Total number of currently active authenticated WebSocket client connections.
1128
+ */
1129
+ get connectedClientsCount() {
1130
+ return this.webSocketToClient.size;
1131
+ }
1132
+ /**
1133
+ * Handles an incoming WebSocket connection, binding message, error, and disconnection events.
1134
+ *
1135
+ * @param webSocket - Active WebSocket connection.
1136
+ * @param clientIp - Remote client IP address.
1137
+ */
1138
+ handleConnection(webSocket, clientIp = "127.0.0.1") {
1139
+ this.webSocketToIp.set(webSocket, clientIp);
1140
+ if (this.rateLimiter && !this.rateLimiter.consume(clientIp)) {
1141
+ this.send(webSocket, {
1142
+ type: "auth_error" /* AuthError */,
1143
+ message: "Too many connection attempts"
1144
+ });
1145
+ webSocket.close();
1146
+ return;
1147
+ }
1148
+ if (this.authTimeoutMs > 0) {
1149
+ const timer = setTimeout(() => {
1150
+ if (!this.webSocketToClient.has(webSocket)) {
1151
+ this.send(webSocket, {
1152
+ type: "auth_error" /* AuthError */,
1153
+ message: "Authentication timeout"
1154
+ });
1155
+ webSocket.close();
1156
+ }
1157
+ }, this.authTimeoutMs);
1158
+ this.pendingAuthTimers.set(webSocket, timer);
1159
+ }
1160
+ let messageQueue = Promise.resolve();
1161
+ webSocket.on("message", (data) => {
1162
+ const client = this.webSocketToClient.get(webSocket);
1163
+ const userContext = client ? ` (app: "${client.appId}", user: "${client.user.id}", client: "${client.clientId}")` : "";
1164
+ messageQueue = messageQueue.then(async () => {
1165
+ try {
1166
+ const raw = typeof data === "string" ? data : data.toString();
1167
+ const msg = JSON.parse(raw);
1168
+ await this.handleMessage(webSocket, msg);
1169
+ } catch (err) {
1170
+ const message = err instanceof Error ? err.message : "Unknown server error";
1171
+ this.logger?.error(
1172
+ `[TetherServer.Sync] Error processing WebSocket message${userContext}:`,
1173
+ err
1174
+ );
1175
+ this.send(webSocket, {
1176
+ type: "error" /* Error */,
1177
+ message
1178
+ });
1179
+ }
1180
+ }).catch((err) => {
1181
+ this.logger?.error(
1182
+ `[TetherServer.Sync] Unhandled error in message queue${userContext}:`,
1183
+ err
1184
+ );
1185
+ });
1186
+ });
1187
+ webSocket.on("error", (err) => {
1188
+ const client = this.webSocketToClient.get(webSocket);
1189
+ const userContext = client ? ` (app: "${client.appId}", user: "${client.user.id}", client: "${client.clientId}")` : "";
1190
+ this.logger?.error(
1191
+ `[TetherServer.Sync] WebSocket connection error${userContext}:`,
1192
+ err
1193
+ );
1194
+ this.cleanupConnection(webSocket);
1195
+ });
1196
+ webSocket.on("close", () => {
1197
+ this.cleanupConnection(webSocket);
1198
+ });
1199
+ }
1200
+ /**
1201
+ * Routes and executes incoming client protocol messages.
1202
+ *
1203
+ * @param webSocket - The connection that sent the message.
1204
+ * @param msg - Parsed client protocol message.
1205
+ */
1206
+ async handleMessage(webSocket, msg) {
1207
+ if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
1208
+ throw new TetherServerError(
1209
+ 0 /* InvalidInput */,
1210
+ "Invalid message format"
1211
+ );
1212
+ }
1213
+ switch (msg.type) {
1214
+ case "auth" /* Auth */:
1215
+ await this.handleAuthMessage(webSocket, msg);
1216
+ break;
1217
+ case "change_batch" /* ChangeBatch */:
1218
+ await this.handleChangeBatchMessage(webSocket, msg);
1219
+ break;
1220
+ case "ping" /* Ping */:
1221
+ this.handlePingMessage(webSocket);
1222
+ break;
1223
+ default:
1224
+ throw new TetherServerError(
1225
+ 0 /* InvalidInput */,
1226
+ "Unsupported message type"
1227
+ );
1228
+ }
1229
+ }
1230
+ // -- Private Message Handlers ---------------------------------------------
1231
+ async handleAuthMessage(webSocket, msg) {
1232
+ const ip = this.webSocketToIp.get(webSocket) ?? "127.0.0.1";
1233
+ const authTimer = this.pendingAuthTimers.get(webSocket);
1234
+ if (authTimer) {
1235
+ clearTimeout(authTimer);
1236
+ this.pendingAuthTimers.delete(webSocket);
1237
+ }
1238
+ if (msg.protocolVersion !== PROTOCOL_VERSION) {
1239
+ this.rateLimiter?.recordFailure(ip);
1240
+ this.send(webSocket, {
1241
+ type: "auth_error" /* AuthError */,
1242
+ message: `Unsupported protocol version: expected ${PROTOCOL_VERSION}, got ${msg.protocolVersion}`
1243
+ });
1244
+ webSocket.close();
1245
+ return;
1246
+ }
1247
+ if (typeof msg.token !== "string" || !msg.token) {
1248
+ this.rateLimiter?.recordFailure(ip);
1249
+ this.send(webSocket, {
1250
+ type: "auth_error" /* AuthError */,
1251
+ message: "Missing or invalid authentication token"
1252
+ });
1253
+ webSocket.close();
1254
+ return;
1255
+ }
1256
+ const user = await this.storage.getUserByToken(msg.token);
1257
+ if (!user) {
1258
+ this.rateLimiter?.recordFailure(ip);
1259
+ this.send(webSocket, {
1260
+ type: "auth_error" /* AuthError */,
1261
+ message: "Invalid or expired authentication token"
1262
+ });
1263
+ webSocket.close();
1264
+ return;
1265
+ }
1266
+ if (typeof msg.appId !== "string" || !msg.appId) {
1267
+ this.send(webSocket, {
1268
+ type: "auth_error" /* AuthError */,
1269
+ message: "Missing required field: appId"
1270
+ });
1271
+ webSocket.close();
1272
+ return;
1273
+ }
1274
+ const appId = validateAppId(msg.appId);
1275
+ const app = await this.storage.getApp(appId);
1276
+ if (!app) {
1277
+ this.send(webSocket, {
1278
+ type: "auth_error" /* AuthError */,
1279
+ message: "Application not found"
1280
+ });
1281
+ webSocket.close();
1282
+ return;
1283
+ }
1284
+ const channelKey = `${appId}:${user.id}`;
1285
+ let set = this.userClients.get(channelKey);
1286
+ if (!set) {
1287
+ set = /* @__PURE__ */ new Set();
1288
+ this.userClients.set(channelKey, set);
1289
+ }
1290
+ if (set.size >= this.maxConcurrentConnectionsPerUser) {
1291
+ this.send(webSocket, {
1292
+ type: "auth_error" /* AuthError */,
1293
+ message: "Maximum concurrent connections exceeded for this user"
1294
+ });
1295
+ webSocket.close();
1296
+ return;
1297
+ }
1298
+ this.rateLimiter?.reset(ip);
1299
+ const clientId = validateIdentifier(
1300
+ msg.clientId ?? "client_anon",
1301
+ "clientId"
1302
+ );
1303
+ const existingClient = this.webSocketToClient.get(webSocket);
1304
+ if (existingClient) {
1305
+ const oldChannelKey = `${existingClient.appId}:${existingClient.user.id}`;
1306
+ const oldSet = this.userClients.get(oldChannelKey);
1307
+ if (oldSet) {
1308
+ oldSet.delete(existingClient);
1309
+ if (oldSet.size === 0) {
1310
+ this.userClients.delete(oldChannelKey);
1311
+ }
1312
+ }
1313
+ this.webSocketToClient.delete(webSocket);
1314
+ }
1315
+ const client = {
1316
+ webSocket,
1317
+ clientId,
1318
+ user,
1319
+ appId
1320
+ };
1321
+ this.webSocketToClient.set(webSocket, client);
1322
+ set.add(client);
1323
+ const currentSeq = await app.getCurrentSeq(user);
1324
+ const refreshedToken = await user.createToken();
1325
+ this.send(webSocket, {
1326
+ type: "auth_success" /* AuthSuccess */,
1327
+ protocolVersion: PROTOCOL_VERSION,
1328
+ userId: user.id,
1329
+ currentSeq,
1330
+ token: refreshedToken
1331
+ });
1332
+ await this.performSync(client, msg.lastSyncSeq);
1333
+ }
1334
+ async handleChangeBatchMessage(webSocket, msg) {
1335
+ const client = this.webSocketToClient.get(webSocket);
1336
+ if (!client) {
1337
+ this.send(webSocket, {
1338
+ type: "auth_error" /* AuthError */,
1339
+ message: "Not authenticated"
1340
+ });
1341
+ return;
1342
+ }
1343
+ if (!Array.isArray(msg.changes)) {
1344
+ throw new TetherServerError(
1345
+ 0 /* InvalidInput */,
1346
+ "Invalid change batch: changes must be an array"
1347
+ );
1348
+ }
1349
+ const maxBatchSize = this.storage.options?.maxBatchSizeBytes ?? 5 * 1024 * 1024;
1350
+ const batchBytes = calculateByteSize(msg.changes);
1351
+ if (batchBytes > maxBatchSize) {
1352
+ throw new TetherServerError(
1353
+ 5 /* LimitExceeded */,
1354
+ "Change batch exceeds maximum allowed size"
1355
+ );
1356
+ }
1357
+ const batchId = validateIdentifier(msg.batchId, "batchId");
1358
+ const app = await this.storage.getApp(client.appId);
1359
+ if (!app) {
1360
+ throw new TetherServerError(
1361
+ 1 /* NotFound */,
1362
+ "Application not found"
1363
+ );
1364
+ }
1365
+ const { applied, newSeq } = await app.applyChanges(
1366
+ client.user,
1367
+ msg.changes
1368
+ );
1369
+ this.send(webSocket, {
1370
+ type: "change_ack" /* ChangeAck */,
1371
+ batchId,
1372
+ appliedSeq: newSeq
1373
+ });
1374
+ if (applied.length > 0) {
1375
+ this.broadcastToAppUser(client.appId, client.user.id, client.clientId, {
1376
+ type: "broadcast_changes" /* BroadcastChanges */,
1377
+ fromClientId: client.clientId,
1378
+ changes: applied,
1379
+ seq: newSeq
1380
+ });
1381
+ }
1382
+ }
1383
+ handlePingMessage(webSocket) {
1384
+ this.send(webSocket, {
1385
+ type: "pong" /* Pong */
1386
+ });
1387
+ }
1388
+ // -- Private Helpers ------------------------------------------------------
1389
+ cleanupConnection(webSocket) {
1390
+ const authTimer = this.pendingAuthTimers.get(webSocket);
1391
+ if (authTimer) {
1392
+ clearTimeout(authTimer);
1393
+ this.pendingAuthTimers.delete(webSocket);
1394
+ }
1395
+ this.webSocketToIp.delete(webSocket);
1396
+ const client = this.webSocketToClient.get(webSocket);
1397
+ if (!client) return;
1398
+ this.webSocketToClient.delete(webSocket);
1399
+ const channelKey = `${client.appId}:${client.user.id}`;
1400
+ const set = this.userClients.get(channelKey);
1401
+ if (set) {
1402
+ set.delete(client);
1403
+ if (set.size === 0) {
1404
+ this.userClients.delete(channelKey);
1405
+ }
1406
+ }
1407
+ }
1408
+ send(webSocket, msg) {
1409
+ if (webSocket.readyState === 1) {
1410
+ webSocket.send(JSON.stringify(msg));
1411
+ }
1412
+ }
1413
+ async performSync(client, lastSyncSeq) {
1414
+ const app = await this.storage.getApp(client.appId);
1415
+ if (!app) {
1416
+ throw new TetherServerError(
1417
+ 1 /* NotFound */,
1418
+ "Application not found"
1419
+ );
1420
+ }
1421
+ const seq = lastSyncSeq ?? 0;
1422
+ if (seq === 0) {
1423
+ const snapshot = await this.getAppSnapshot(app, client.user);
1424
+ const currentSeq = await app.getCurrentSeq(client.user);
1425
+ this.send(client.webSocket, {
1426
+ type: "sync_snapshot" /* SyncSnapshot */,
1427
+ seq: currentSeq,
1428
+ snapshot
1429
+ });
1430
+ } else {
1431
+ const { changes, currentSeq, requiresSnapshot } = await app.getChangesSince(client.user, seq);
1432
+ if (requiresSnapshot) {
1433
+ const snapshot = await this.getAppSnapshot(app, client.user);
1434
+ this.send(client.webSocket, {
1435
+ type: "sync_snapshot" /* SyncSnapshot */,
1436
+ seq: currentSeq,
1437
+ snapshot
1438
+ });
1439
+ } else {
1440
+ this.send(client.webSocket, {
1441
+ type: "sync_diff" /* SyncDiff */,
1442
+ fromSeq: seq,
1443
+ toSeq: currentSeq,
1444
+ changes
1445
+ });
1446
+ }
1447
+ }
1448
+ }
1449
+ async getAppSnapshot(app, user) {
1450
+ const tables = await app.getTables();
1451
+ const snapshot = [];
1452
+ for (const table of tables) {
1453
+ const records = await table.getAllRecords(user);
1454
+ snapshot.push(...records);
1455
+ }
1456
+ return snapshot;
1457
+ }
1458
+ broadcastToAppUser(appId, userId, excludeClientId, msg) {
1459
+ const channelKey = `${appId}:${userId}`;
1460
+ const clients = this.userClients.get(channelKey);
1461
+ if (!clients) return;
1462
+ for (const client of clients) {
1463
+ if (client.clientId !== excludeClientId) {
1464
+ this.send(client.webSocket, msg);
1465
+ }
1466
+ }
1467
+ }
1468
+ };
1469
+
1470
+ // src/server/server.ts
1471
+ async function startServer(options = {}) {
1472
+ const port = options.port ?? (process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8080);
1473
+ const host = options.host ?? "0.0.0.0";
1474
+ const server = new TetherServer(options);
1475
+ const httpServer = await server.listen(port, host);
1476
+ const addr = httpServer.address();
1477
+ const boundPort = typeof addr === "object" && addr ? addr.port : port;
1478
+ return {
1479
+ server,
1480
+ httpServer,
1481
+ port: boundPort,
1482
+ host,
1483
+ close: async () => {
1484
+ await server.close();
1485
+ }
1486
+ };
1487
+ }
1488
+ var TetherServer = class {
1489
+ /** Underlying storage engine for users, apps, and tables. */
1490
+ storage;
1491
+ /** Real-time synchronization connection and broadcast coordinator. */
1492
+ sync;
1493
+ /** Base path for HTTP REST endpoints. */
1494
+ basePath;
1495
+ /** Path for WebSocket upgrade requests. */
1496
+ webSocketPath;
1497
+ trustProxy;
1498
+ allowRegistration;
1499
+ corsConfig;
1500
+ logger;
1501
+ ipLoginLimiter;
1502
+ userLoginLimiter;
1503
+ ipRegisterLimiter;
1504
+ _httpServer = null;
1505
+ _webSocketServer = null;
1506
+ lockHandle = null;
1507
+ /**
1508
+ * Initializes a new TetherServer instance.
1509
+ *
1510
+ * @param options - Configuration options for storage, endpoints, and rate limiting.
1511
+ */
1512
+ constructor(options = {}) {
1513
+ this.storage = options.storage ?? new MemoryStorage();
1514
+ this.basePath = normalizeBasePath(options.basePath ?? "");
1515
+ this.webSocketPath = options.webSocketPath ?? `${this.basePath}/sync`;
1516
+ this.allowRegistration = options.allowRegistration ?? true;
1517
+ this.trustProxy = options.trustProxy ?? false;
1518
+ this.corsConfig = options.cors === false ? null : typeof options.cors === "object" ? options.cors : {};
1519
+ this.logger = options.logger === false ? null : options.logger ?? console;
1520
+ const rateLimitConfig = options.rateLimiting ?? true;
1521
+ if (rateLimitConfig === false) {
1522
+ this.ipLoginLimiter = null;
1523
+ this.userLoginLimiter = null;
1524
+ this.ipRegisterLimiter = null;
1525
+ this.sync = new Sync(this.storage, {
1526
+ maxConcurrentConnectionsPerUser: 1e3,
1527
+ authTimeoutMs: 0,
1528
+ rateLimiter: null,
1529
+ logger: this.logger
1530
+ });
1531
+ } else {
1532
+ const opts = typeof rateLimitConfig === "object" ? rateLimitConfig : {};
1533
+ const windowMs = opts.windowMs ?? 6e4;
1534
+ const maxFailures = opts.maxFailures ?? 5;
1535
+ const initialBackoffMs = opts.initialBackoffMs ?? 1e3;
1536
+ const maxBackoffMs = opts.maxBackoffMs ?? 9e5;
1537
+ this.ipLoginLimiter = new RateLimiter({
1538
+ windowMs,
1539
+ maxRequests: opts.ipLoginMaxRequests ?? 100,
1540
+ maxFailures,
1541
+ initialBackoffMs,
1542
+ maxBackoffMs
1543
+ });
1544
+ this.userLoginLimiter = new RateLimiter({
1545
+ windowMs,
1546
+ maxRequests: opts.userLoginMaxRequests ?? 20,
1547
+ maxFailures,
1548
+ initialBackoffMs,
1549
+ maxBackoffMs
1550
+ });
1551
+ this.ipRegisterLimiter = new RateLimiter({
1552
+ windowMs,
1553
+ maxRequests: opts.ipRegisterMaxRequests ?? 100
1554
+ });
1555
+ const syncLimiter = new RateLimiter({
1556
+ windowMs,
1557
+ maxRequests: opts.ipSyncMaxRequests ?? 100,
1558
+ maxFailures,
1559
+ initialBackoffMs,
1560
+ maxBackoffMs
1561
+ });
1562
+ this.sync = new Sync(this.storage, {
1563
+ maxConcurrentConnectionsPerUser: opts.maxConcurrentConnectionsPerUser ?? 20,
1564
+ authTimeoutMs: opts.authTimeoutMs ?? 1e4,
1565
+ rateLimiter: syncLimiter,
1566
+ logger: this.logger
1567
+ });
1568
+ }
1569
+ }
1570
+ /**
1571
+ * Active Node.js HTTP server instance, or `null` if not listening.
1572
+ */
1573
+ get httpServer() {
1574
+ return this._httpServer;
1575
+ }
1576
+ /**
1577
+ * Active WebSocketServer instance, or `null` if not listening.
1578
+ */
1579
+ get webSocketServer() {
1580
+ return this._webSocketServer;
1581
+ }
1582
+ /**
1583
+ * Declares an application and its tables.
1584
+ * Registers the application and any declared tables if not already present.
1585
+ *
1586
+ * @param appId - Application identifier.
1587
+ * @param tables - Array of table names.
1588
+ */
1589
+ async declareApp(appId, tables = []) {
1590
+ let app = await this.storage.getApp(appId);
1591
+ if (!app) {
1592
+ app = await this.storage.createApp(appId);
1593
+ }
1594
+ for (const table of tables) {
1595
+ const existing = await app.getTable(table);
1596
+ if (!existing) {
1597
+ await app.createTable(table);
1598
+ }
1599
+ }
1600
+ }
1601
+ /**
1602
+ * Declares a user account with the specified username and password.
1603
+ * Creates the user if not already registered, or updates the existing user's password.
1604
+ *
1605
+ * @param username - Username for the account.
1606
+ * @param password - Plaintext password for the account.
1607
+ * @returns UserStorage handle for the declared user.
1608
+ */
1609
+ async declareUser(username, password) {
1610
+ const user = await this.storage.getUserByUsername(username);
1611
+ if (user) {
1612
+ await user.changePassword(password);
1613
+ return user;
1614
+ }
1615
+ return this.storage.createUser(username, password);
1616
+ }
1617
+ /**
1618
+ * Attaches WebSocket synchronization handling to an existing HTTP server.
1619
+ *
1620
+ * @param server - The HTTP server instance to attach to.
1621
+ */
1622
+ attach(server) {
1623
+ if (!this._webSocketServer) {
1624
+ this._webSocketServer = new import_ws.WebSocketServer({
1625
+ noServer: true,
1626
+ perMessageDeflate: {
1627
+ zlibDeflateOptions: {
1628
+ level: 6,
1629
+ memLevel: 8
1630
+ },
1631
+ threshold: 1024,
1632
+ clientNoContextTakeover: true,
1633
+ serverNoContextTakeover: true
1634
+ }
1635
+ });
1636
+ this._webSocketServer.on("connection", (ws, req) => {
1637
+ const ip = req ? this.getClientIp(req) : "127.0.0.1";
1638
+ this.sync.handleConnection(ws, ip);
1639
+ });
1640
+ }
1641
+ server.on("upgrade", (req, socket, head) => {
1642
+ const url = new URL(
1643
+ req.url ?? "",
1644
+ `http://${req.headers.host ?? "localhost"}`
1645
+ );
1646
+ if (url.pathname === this.webSocketPath) {
1647
+ this._webSocketServer?.handleUpgrade(req, socket, head, (ws) => {
1648
+ this._webSocketServer?.emit("connection", ws, req);
1649
+ });
1650
+ } else {
1651
+ socket.destroy();
1652
+ }
1653
+ });
1654
+ }
1655
+ /**
1656
+ * Starts the HTTP and WebSocket server listening on the specified port and host.
1657
+ *
1658
+ * @param port - Port number to bind. Defaults to 8080.
1659
+ * @param host - Host interface to bind. Defaults to '0.0.0.0'.
1660
+ * @returns The active Node.js HTTP server instance.
1661
+ */
1662
+ async listen(port = 8080, host = "0.0.0.0") {
1663
+ const storageBaseDir = this.storage.baseDir;
1664
+ const isMemory = this.storage.inMemory ?? this.storage instanceof MemoryStorage;
1665
+ if (storageBaseDir && !isMemory) {
1666
+ const status = await this.storage.getStatus();
1667
+ this.lockHandle = acquireServerLock(storageBaseDir, {
1668
+ port,
1669
+ host,
1670
+ backend: status.backend
1671
+ });
1672
+ }
1673
+ return new Promise((resolve6, reject) => {
1674
+ this._httpServer = http.createServer(async (req, res) => {
1675
+ const handled = await this.handleHttpRequest(req, res);
1676
+ if (!handled) {
1677
+ this.sendJson(res, 404, { error: "Not found" });
1678
+ }
1679
+ });
1680
+ this.attach(this._httpServer);
1681
+ this._httpServer.listen(port, host, () => {
1682
+ if (this._httpServer) {
1683
+ const addr = this._httpServer.address();
1684
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
1685
+ if (this.lockHandle && storageBaseDir && this.lockHandle.info.port !== actualPort) {
1686
+ this.lockHandle.release();
1687
+ this.lockHandle = acquireServerLock(storageBaseDir, {
1688
+ port: actualPort,
1689
+ host,
1690
+ backend: this.lockHandle.info.backend
1691
+ });
1692
+ }
1693
+ resolve6(this._httpServer);
1694
+ }
1695
+ });
1696
+ this._httpServer.on("error", (err) => {
1697
+ if (this.lockHandle) {
1698
+ this.lockHandle.release();
1699
+ this.lockHandle = null;
1700
+ }
1701
+ reject(err);
1702
+ });
1703
+ });
1704
+ }
1705
+ /**
1706
+ * Closes active HTTP server and WebSocket server listeners.
1707
+ */
1708
+ async close() {
1709
+ if (this.lockHandle) {
1710
+ this.lockHandle.release();
1711
+ this.lockHandle = null;
1712
+ }
1713
+ return new Promise((resolve6, reject) => {
1714
+ if (this._webSocketServer) {
1715
+ for (const client of this._webSocketServer.clients) {
1716
+ try {
1717
+ client.terminate();
1718
+ } catch {
1719
+ }
1720
+ }
1721
+ this._webSocketServer.close();
1722
+ this._webSocketServer = null;
1723
+ }
1724
+ if (this._httpServer) {
1725
+ try {
1726
+ this._httpServer.closeAllConnections?.();
1727
+ } catch {
1728
+ }
1729
+ this._httpServer.close((err) => {
1730
+ this._httpServer = null;
1731
+ if (err) reject(err);
1732
+ else resolve6();
1733
+ });
1734
+ } else {
1735
+ resolve6();
1736
+ }
1737
+ });
1738
+ }
1739
+ /**
1740
+ * Handles incoming HTTP requests for authentication and discovery endpoints.
1741
+ *
1742
+ * @param req - Incoming HTTP request.
1743
+ * @param res - Server HTTP response.
1744
+ * @returns `true` if the request was handled by TetherDB; `false` if the path did not match.
1745
+ */
1746
+ async handleHttpRequest(req, res) {
1747
+ const url = new URL(
1748
+ req.url ?? "/",
1749
+ `http://${req.headers.host ?? "localhost"}`
1750
+ );
1751
+ const method = req.method?.toUpperCase();
1752
+ if (method === "OPTIONS") {
1753
+ this.handleOptions(req, res);
1754
+ return true;
1755
+ }
1756
+ try {
1757
+ if (method === "GET" && url.pathname === `${this.basePath}/health`) {
1758
+ this.handleHealth(req, res);
1759
+ return true;
1760
+ }
1761
+ if (method === "GET" && url.pathname === `${this.basePath}/ready`) {
1762
+ await this.handleReady(req, res);
1763
+ return true;
1764
+ }
1765
+ if (method === "GET" && url.pathname === `${this.basePath}/metrics`) {
1766
+ await this.handleMetrics(req, res);
1767
+ return true;
1768
+ }
1769
+ if (this.allowRegistration && method === "POST" && url.pathname === `${this.basePath}/auth/register`) {
1770
+ await this.handleRegister(req, res);
1771
+ return true;
1772
+ }
1773
+ if (method === "POST" && url.pathname === `${this.basePath}/auth/login`) {
1774
+ await this.handleLogin(req, res);
1775
+ return true;
1776
+ }
1777
+ return false;
1778
+ } catch (err) {
1779
+ const status = getHttpStatusForError(err);
1780
+ if (status >= 500) {
1781
+ this.logger?.error("Error handling HTTP request:", err);
1782
+ } else {
1783
+ this.logger?.debug("Client error handling HTTP request:", err);
1784
+ }
1785
+ const msg = err instanceof Error ? err.message : "Internal server error";
1786
+ this.sendJson(res, status, { error: msg }, req);
1787
+ return true;
1788
+ }
1789
+ }
1790
+ // -- Private Helpers ------------------------------------------------------
1791
+ getCorsHeaders(req) {
1792
+ if (!this.corsConfig) return {};
1793
+ const headers = {
1794
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1795
+ "Access-Control-Allow-Headers": (this.corsConfig.allowedHeaders ?? ["Content-Type", "Authorization"]).join(", ")
1796
+ };
1797
+ if (this.corsConfig.exposedHeaders && this.corsConfig.exposedHeaders.length > 0) {
1798
+ headers["Access-Control-Expose-Headers"] = this.corsConfig.exposedHeaders.join(", ");
1799
+ }
1800
+ if (this.corsConfig.maxAge !== void 0) {
1801
+ headers["Access-Control-Max-Age"] = String(this.corsConfig.maxAge);
1802
+ }
1803
+ const reqOrigin = req?.headers.origin;
1804
+ const origin = this.corsConfig.origin ?? "*";
1805
+ if (origin === "*") {
1806
+ if (this.corsConfig.credentials) {
1807
+ if (reqOrigin) {
1808
+ headers["Access-Control-Allow-Origin"] = reqOrigin;
1809
+ headers.Vary = "Origin";
1810
+ }
1811
+ } else {
1812
+ headers["Access-Control-Allow-Origin"] = "*";
1813
+ }
1814
+ } else if (typeof origin === "string") {
1815
+ headers["Access-Control-Allow-Origin"] = origin;
1816
+ headers.Vary = "Origin";
1817
+ } else if (Array.isArray(origin)) {
1818
+ if (reqOrigin && origin.includes(reqOrigin)) {
1819
+ headers["Access-Control-Allow-Origin"] = reqOrigin;
1820
+ headers.Vary = "Origin";
1821
+ }
1822
+ } else if (origin === true && reqOrigin) {
1823
+ headers["Access-Control-Allow-Origin"] = reqOrigin;
1824
+ headers.Vary = "Origin";
1825
+ }
1826
+ if (this.corsConfig.credentials) {
1827
+ headers["Access-Control-Allow-Credentials"] = "true";
1828
+ }
1829
+ return headers;
1830
+ }
1831
+ sendJson(res, status, data, req) {
1832
+ res.writeHead(status, {
1833
+ "Content-Type": "application/json",
1834
+ ...this.getCorsHeaders(req)
1835
+ });
1836
+ res.end(JSON.stringify(data));
1837
+ }
1838
+ async readJsonBody(req) {
1839
+ return new Promise((resolve6, reject) => {
1840
+ let body = "";
1841
+ req.on("data", (chunk) => {
1842
+ body += chunk;
1843
+ if (body.length > 1024 * 1024) {
1844
+ reject(
1845
+ new TetherServerError(
1846
+ 5 /* LimitExceeded */,
1847
+ "Payload exceeds maximum allowed size"
1848
+ )
1849
+ );
1850
+ }
1851
+ });
1852
+ req.on("end", () => {
1853
+ try {
1854
+ resolve6(body ? JSON.parse(body) : {});
1855
+ } catch {
1856
+ reject(
1857
+ new TetherServerError(
1858
+ 0 /* InvalidInput */,
1859
+ "Invalid JSON payload"
1860
+ )
1861
+ );
1862
+ }
1863
+ });
1864
+ req.on("error", reject);
1865
+ });
1866
+ }
1867
+ handleOptions(req, res) {
1868
+ res.writeHead(204, this.getCorsHeaders(req));
1869
+ res.end();
1870
+ }
1871
+ handleHealth(req, res) {
1872
+ this.sendJson(
1873
+ res,
1874
+ 200,
1875
+ {
1876
+ status: "ok",
1877
+ uptime: process.uptime()
1878
+ },
1879
+ req
1880
+ );
1881
+ }
1882
+ async handleReady(req, res) {
1883
+ try {
1884
+ await this.storage.getApps();
1885
+ this.sendJson(res, 200, { status: "ready" }, req);
1886
+ } catch (err) {
1887
+ const message = err instanceof Error ? err.message : "Storage unavailable";
1888
+ this.logger?.error("Storage readiness error:", err);
1889
+ this.sendJson(res, 503, { status: "unready", error: message }, req);
1890
+ }
1891
+ }
1892
+ async handleMetrics(req, res) {
1893
+ const apps = await this.storage.getApps();
1894
+ this.sendJson(
1895
+ res,
1896
+ 200,
1897
+ {
1898
+ uptime: process.uptime(),
1899
+ connectedClients: this.sync.connectedClientsCount,
1900
+ appsCount: apps.length,
1901
+ memoryUsage: process.memoryUsage()
1902
+ },
1903
+ req
1904
+ );
1905
+ }
1906
+ async handleRegister(req, res) {
1907
+ const ip = this.getClientIp(req);
1908
+ if (this.ipRegisterLimiter && !this.ipRegisterLimiter.consume(ip)) {
1909
+ this.sendJson(res, 429, { error: "Too many registration requests" }, req);
1910
+ return;
1911
+ }
1912
+ const credentials = await this.readCredentials(req, res);
1913
+ if (!credentials) return;
1914
+ try {
1915
+ const user = await this.storage.createUser(
1916
+ credentials.username,
1917
+ credentials.password
1918
+ );
1919
+ const token = await user.createToken();
1920
+ this.sendJson(
1921
+ res,
1922
+ 201,
1923
+ {
1924
+ userId: user.id,
1925
+ username: user.username,
1926
+ token
1927
+ },
1928
+ req
1929
+ );
1930
+ } catch (err) {
1931
+ const status = getHttpStatusForError(err);
1932
+ if (status >= 500) {
1933
+ this.logger?.error("Registration error:", err);
1934
+ } else {
1935
+ this.logger?.debug("Client registration error:", err);
1936
+ }
1937
+ const msg = err instanceof Error ? err.message : "Registration error";
1938
+ this.sendJson(res, status, { error: msg }, req);
1939
+ }
1940
+ }
1941
+ async handleLogin(req, res) {
1942
+ const ip = this.getClientIp(req);
1943
+ if (this.ipLoginLimiter && !this.ipLoginLimiter.consume(ip)) {
1944
+ this.sendJson(res, 429, { error: "Too many login attempts" }, req);
1945
+ return;
1946
+ }
1947
+ const credentials = await this.readCredentials(req, res);
1948
+ if (!credentials) return;
1949
+ const userKey = `${ip}:${credentials.username}`;
1950
+ if (this.userLoginLimiter && !this.userLoginLimiter.consume(userKey)) {
1951
+ this.sendJson(
1952
+ res,
1953
+ 429,
1954
+ { error: "Too many login attempts for this account" },
1955
+ req
1956
+ );
1957
+ return;
1958
+ }
1959
+ const user = await this.storage.getUserByUsername(credentials.username);
1960
+ const valid = user ? await user.verifyPassword(credentials.password) : await verifyDummyPasswordHash(credentials.password);
1961
+ if (!user || !valid) {
1962
+ this.ipLoginLimiter?.recordFailure(ip);
1963
+ this.userLoginLimiter?.recordFailure(userKey);
1964
+ this.sendJson(
1965
+ res,
1966
+ 401,
1967
+ {
1968
+ error: "Invalid username or password"
1969
+ },
1970
+ req
1971
+ );
1972
+ return;
1973
+ }
1974
+ this.ipLoginLimiter?.reset(ip);
1975
+ this.userLoginLimiter?.reset(userKey);
1976
+ const token = await user.createToken();
1977
+ this.sendJson(
1978
+ res,
1979
+ 200,
1980
+ {
1981
+ userId: user.id,
1982
+ username: user.username,
1983
+ token
1984
+ },
1985
+ req
1986
+ );
1987
+ }
1988
+ async readCredentials(req, res) {
1989
+ const body = await this.readJsonBody(req);
1990
+ const { username, password } = body;
1991
+ const normUsername = normalizeUsername(username ?? "");
1992
+ const normPassword = normalizePassword(password ?? "");
1993
+ if (!normUsername || !normPassword) {
1994
+ this.sendJson(
1995
+ res,
1996
+ 400,
1997
+ {
1998
+ error: "Missing or invalid required field: username and password"
1999
+ },
2000
+ req
2001
+ );
2002
+ return null;
2003
+ }
2004
+ return { username: normUsername, password: normPassword };
2005
+ }
2006
+ getClientIp(req) {
2007
+ if (this.trustProxy) {
2008
+ const forwarded = req.headers["x-forwarded-for"];
2009
+ if (typeof forwarded === "string") {
2010
+ const first = forwarded.split(",")[0].trim();
2011
+ if (first) return first;
2012
+ }
2013
+ }
2014
+ return req.socket.remoteAddress ?? "127.0.0.1";
2015
+ }
2016
+ };
2017
+ function getHttpStatusForError(err) {
2018
+ if (err instanceof TetherServerError) {
2019
+ switch (err.code) {
2020
+ case 0 /* InvalidInput */:
2021
+ case 6 /* ConfigurationError */:
2022
+ return 400;
2023
+ case 3 /* Unauthorized */:
2024
+ case 4 /* AuthenticationFailed */:
2025
+ return 401;
2026
+ case 1 /* NotFound */:
2027
+ return 404;
2028
+ case 2 /* AlreadyExists */:
2029
+ return 409;
2030
+ case 5 /* LimitExceeded */:
2031
+ return 413;
2032
+ case 7 /* NotSupported */:
2033
+ return 501;
2034
+ case 8 /* InternalError */:
2035
+ return 500;
2036
+ }
2037
+ }
2038
+ return 500;
2039
+ }
2040
+
2041
+ // src/server/storage/file/storage.ts
2042
+ var crypto3 = __toESM(require("crypto"), 1);
2043
+ var fs4 = __toESM(require("fs/promises"), 1);
2044
+ var path4 = __toESM(require("path"), 1);
2045
+
2046
+ // src/server/storage/file/app.ts
2047
+ var fs3 = __toESM(require("fs/promises"), 1);
2048
+ var path3 = __toESM(require("path"), 1);
2049
+
2050
+ // src/server/storage/file/table.ts
2051
+ var TableFileStorage = class extends TableBaseStorage {
2052
+ async getRecord(user, id) {
2053
+ const safeUserId = validateUserId(user.id);
2054
+ const safeId = validateRecordId(id);
2055
+ const map = await this.app.readTableRecords(safeUserId, this.name);
2056
+ const record = map.get(safeId);
2057
+ if (!record || record.deleted) {
2058
+ return void 0;
2059
+ }
2060
+ return record;
2061
+ }
2062
+ async getAllRecords(user) {
2063
+ const safeUserId = validateUserId(user.id);
2064
+ const map = await this.app.readTableRecords(safeUserId, this.name);
2065
+ return filterActiveRecords(this.name, map.values());
2066
+ }
2067
+ async delete() {
2068
+ return this.app.deleteTable(this.name);
2069
+ }
2070
+ };
2071
+
2072
+ // src/server/storage/file/app.ts
2073
+ var AppFileStorage = class extends AppBaseStorage {
2074
+ storage;
2075
+ constructor(id, storage) {
2076
+ super(id);
2077
+ this.storage = storage;
2078
+ }
2079
+ get appDir() {
2080
+ return path3.join(this.storage.baseDir, this.id);
2081
+ }
2082
+ get manifestFile() {
2083
+ return path3.join(this.appDir, "manifest.json");
2084
+ }
2085
+ getUserDir(userId) {
2086
+ const safeUserId = validateUserId(userId);
2087
+ const bucket = getUserBucket(safeUserId);
2088
+ return path3.join(this.appDir, "users", bucket, safeUserId);
2089
+ }
2090
+ getUserMetaFile(userId) {
2091
+ return path3.join(this.getUserDir(userId), "meta.json");
2092
+ }
2093
+ getUserSyncFile(userId) {
2094
+ return path3.join(this.getUserDir(userId), "sync.jsonl");
2095
+ }
2096
+ getUserTablesDir(userId) {
2097
+ return path3.join(this.getUserDir(userId), "tables");
2098
+ }
2099
+ getUserTableFile(userId, tableName) {
2100
+ return path3.join(this.getUserTablesDir(userId), `${tableName}.json`);
2101
+ }
2102
+ async readManifest() {
2103
+ try {
2104
+ const content = await fs3.readFile(this.manifestFile, "utf-8");
2105
+ return JSON.parse(content);
2106
+ } catch {
2107
+ return {
2108
+ id: this.id,
2109
+ tables: [],
2110
+ createdAt: Date.now(),
2111
+ version: 1
2112
+ };
2113
+ }
2114
+ }
2115
+ async writeManifest(manifest) {
2116
+ await writeFileAtomic(this.manifestFile, JSON.stringify(manifest, null, 2));
2117
+ }
2118
+ async readUserMeta(userId) {
2119
+ try {
2120
+ const content = await fs3.readFile(this.getUserMetaFile(userId), "utf-8");
2121
+ return JSON.parse(content);
2122
+ } catch {
2123
+ return { currentSeq: 0, minSeq: 0 };
2124
+ }
2125
+ }
2126
+ async writeUserMeta(userId, meta) {
2127
+ await writeFileAtomic(
2128
+ this.getUserMetaFile(userId),
2129
+ JSON.stringify(meta, null, 2)
2130
+ );
2131
+ }
2132
+ async readUserSync(userId) {
2133
+ try {
2134
+ const content = await fs3.readFile(this.getUserSyncFile(userId), "utf-8");
2135
+ const lines = content.split("\n");
2136
+ const changes = [];
2137
+ for (const line of lines) {
2138
+ const trimmed = line.trim();
2139
+ if (trimmed) {
2140
+ changes.push(JSON.parse(trimmed));
2141
+ }
2142
+ }
2143
+ return changes;
2144
+ } catch {
2145
+ return [];
2146
+ }
2147
+ }
2148
+ async appendUserSync(userId, changes) {
2149
+ if (changes.length === 0) return;
2150
+ await fs3.mkdir(this.getUserDir(userId), { recursive: true });
2151
+ const content = `${changes.map((c) => JSON.stringify(c)).join("\n")}
2152
+ `;
2153
+ await fs3.appendFile(this.getUserSyncFile(userId), content, "utf-8");
2154
+ }
2155
+ async rewriteUserSync(userId, changes) {
2156
+ const content = changes.length > 0 ? `${changes.map((c) => JSON.stringify(c)).join("\n")}
2157
+ ` : "";
2158
+ await writeFileAtomic(this.getUserSyncFile(userId), content);
2159
+ }
2160
+ async readTableRecords(userId, tableName) {
2161
+ try {
2162
+ const content = await fs3.readFile(
2163
+ this.getUserTableFile(userId, tableName),
2164
+ "utf-8"
2165
+ );
2166
+ const list = JSON.parse(content);
2167
+ const map = /* @__PURE__ */ new Map();
2168
+ for (const rec of list) {
2169
+ map.set(rec.id, rec);
2170
+ }
2171
+ return map;
2172
+ } catch {
2173
+ return /* @__PURE__ */ new Map();
2174
+ }
2175
+ }
2176
+ async writeTableRecords(userId, tableName, records) {
2177
+ await writeFileAtomic(
2178
+ this.getUserTableFile(userId, tableName),
2179
+ JSON.stringify(Array.from(records.values()), null, 2)
2180
+ );
2181
+ }
2182
+ async createTable(name) {
2183
+ assertNoActiveServerLock(this.storage.baseDir);
2184
+ const safeName = validateTableName(name);
2185
+ const manifest = await this.readManifest();
2186
+ if (manifest.tables.includes(safeName)) {
2187
+ throw new TetherServerError(
2188
+ 2 /* AlreadyExists */,
2189
+ "Table already exists in this application"
2190
+ );
2191
+ }
2192
+ manifest.tables.push(safeName);
2193
+ manifest.tables.sort();
2194
+ await this.writeManifest(manifest);
2195
+ return new TableFileStorage(safeName, this);
2196
+ }
2197
+ async getTable(name) {
2198
+ const safeName = validateTableName(name);
2199
+ const manifest = await this.readManifest();
2200
+ if (manifest.tables.includes(safeName)) {
2201
+ return new TableFileStorage(safeName, this);
2202
+ }
2203
+ return void 0;
2204
+ }
2205
+ async getTables() {
2206
+ const manifest = await this.readManifest();
2207
+ return manifest.tables.map((name) => new TableFileStorage(name, this));
2208
+ }
2209
+ async applyChanges(user, changes) {
2210
+ assertNoActiveServerLock(this.storage.baseDir);
2211
+ return this.storage.withUserLock(user.id, this.id, async () => {
2212
+ const manifest = await this.readManifest();
2213
+ const registeredTables = new Set(manifest.tables);
2214
+ const safeUserId = validateUserId(user.id);
2215
+ const applied = [];
2216
+ const maxRecords = this.storage.options.maxRecordsPerTable ?? 1e4;
2217
+ const maxRecordSize = this.storage.options.maxRecordSizeBytes ?? 512 * 1024;
2218
+ const maxChangelog = this.storage.options.maxChangelogEntries ?? 1e3;
2219
+ const meta = await this.readUserMeta(safeUserId);
2220
+ const syncChanges = await this.readUserSync(safeUserId);
2221
+ const modifiedTables = /* @__PURE__ */ new Map();
2222
+ for (const change of changes) {
2223
+ const tableName = validateTableName(change.table);
2224
+ const recordId = validateRecordId(change.id);
2225
+ validateTimestamp(change.timestamp);
2226
+ if (!registeredTables.has(tableName)) {
2227
+ throw new TetherServerError(
2228
+ 1 /* NotFound */,
2229
+ "Table not found"
2230
+ );
2231
+ }
2232
+ let tableRecords = modifiedTables.get(tableName);
2233
+ if (!tableRecords) {
2234
+ tableRecords = await this.readTableRecords(safeUserId, tableName);
2235
+ modifiedTables.set(tableName, tableRecords);
2236
+ }
2237
+ if (change.op === "put" /* Put */ && !tableRecords.has(recordId) && tableRecords.size >= maxRecords) {
2238
+ throw new TetherServerError(
2239
+ 5 /* LimitExceeded */,
2240
+ "Table record limit reached"
2241
+ );
2242
+ }
2243
+ const payloadBytes = calculateByteSize(change.data);
2244
+ if (payloadBytes > maxRecordSize) {
2245
+ throw new TetherServerError(
2246
+ 5 /* LimitExceeded */,
2247
+ "Record payload exceeds maximum allowed size"
2248
+ );
2249
+ }
2250
+ const existing = tableRecords.get(recordId);
2251
+ const shouldApply = !existing || shouldOverwrite(change, existing);
2252
+ if (shouldApply) {
2253
+ meta.currentSeq++;
2254
+ const assignedSeq = meta.currentSeq;
2255
+ if (meta.minSeq === 0) {
2256
+ meta.minSeq = 1;
2257
+ }
2258
+ const { updatedRecord, appliedChange } = applyChangeToRecord(
2259
+ change,
2260
+ existing,
2261
+ assignedSeq
2262
+ );
2263
+ tableRecords.set(recordId, updatedRecord);
2264
+ applied.push(appliedChange);
2265
+ syncChanges.push(appliedChange);
2266
+ }
2267
+ }
2268
+ if (applied.length > 0) {
2269
+ for (const [tableName, records] of modifiedTables.entries()) {
2270
+ await this.writeTableRecords(safeUserId, tableName, records);
2271
+ }
2272
+ if (syncChanges.length > maxChangelog) {
2273
+ const pruneCount = syncChanges.length - maxChangelog;
2274
+ syncChanges.splice(0, pruneCount);
2275
+ if (syncChanges.length > 0) {
2276
+ meta.minSeq = syncChanges[0].seq;
2277
+ }
2278
+ await this.rewriteUserSync(safeUserId, syncChanges);
2279
+ } else {
2280
+ await this.appendUserSync(
2281
+ safeUserId,
2282
+ applied
2283
+ );
2284
+ }
2285
+ await this.writeUserMeta(safeUserId, meta);
2286
+ }
2287
+ return { applied, newSeq: meta.currentSeq };
2288
+ });
2289
+ }
2290
+ async getChangesSince(user, fromSeq) {
2291
+ const safeUserId = validateUserId(user.id);
2292
+ const meta = await this.readUserMeta(safeUserId);
2293
+ const currentSeq = meta.currentSeq;
2294
+ const minSeq = meta.minSeq;
2295
+ if (fromSeq < minSeq && minSeq > 0 || fromSeq > currentSeq) {
2296
+ return { changes: [], currentSeq, requiresSnapshot: true };
2297
+ }
2298
+ const syncChanges = await this.readUserSync(safeUserId);
2299
+ const changes = syncChanges.filter((c) => c.seq > fromSeq);
2300
+ return { changes, currentSeq, requiresSnapshot: false };
2301
+ }
2302
+ async getCurrentSeq(user) {
2303
+ const safeUserId = validateUserId(user.id);
2304
+ const meta = await this.readUserMeta(safeUserId);
2305
+ return meta.currentSeq;
2306
+ }
2307
+ async delete() {
2308
+ return this.storage.deleteApp(this.id);
2309
+ }
2310
+ async deleteTable(name) {
2311
+ assertNoActiveServerLock(this.storage.baseDir);
2312
+ const safeName = validateTableName(name);
2313
+ const manifest = await this.readManifest();
2314
+ const idx = manifest.tables.indexOf(safeName);
2315
+ if (idx === -1) return false;
2316
+ manifest.tables.splice(idx, 1);
2317
+ await this.writeManifest(manifest);
2318
+ const usersRoot = path3.join(this.appDir, "users");
2319
+ try {
2320
+ const buckets = await fs3.readdir(usersRoot, { withFileTypes: true });
2321
+ for (const bucket of buckets) {
2322
+ if (bucket.isDirectory()) {
2323
+ const bucketDir = path3.join(usersRoot, bucket.name);
2324
+ const users = await fs3.readdir(bucketDir, { withFileTypes: true });
2325
+ for (const user of users) {
2326
+ if (user.isDirectory()) {
2327
+ const tableFile = path3.join(
2328
+ bucketDir,
2329
+ user.name,
2330
+ "tables",
2331
+ `${safeName}.json`
2332
+ );
2333
+ try {
2334
+ await fs3.rm(tableFile, { force: true });
2335
+ } catch {
2336
+ }
2337
+ }
2338
+ }
2339
+ }
2340
+ }
2341
+ } catch {
2342
+ }
2343
+ return true;
2344
+ }
2345
+ };
2346
+
2347
+ // src/server/storage/file/user.ts
2348
+ var UserFileStorage = class extends UserBaseStorage {
2349
+ storage;
2350
+ constructor(data, storage) {
2351
+ super(data.id, data.username, data.createdAt);
2352
+ this.storage = storage;
2353
+ }
2354
+ getSecret() {
2355
+ return this.storage.secret;
2356
+ }
2357
+ async verifyPassword(password) {
2358
+ const user = await this.storage.findUserDataById(this.id);
2359
+ return verifyUserPassword(password, user?.passwordHash);
2360
+ }
2361
+ async changePassword(newPassword) {
2362
+ const newHash = await hashUserPassword(newPassword);
2363
+ await this.storage.updateUserData(this.id, { passwordHash: newHash });
2364
+ }
2365
+ async delete() {
2366
+ return this.storage.deleteUser(this.id);
2367
+ }
2368
+ };
2369
+
2370
+ // src/server/storage/file/storage.ts
2371
+ var FileStorage = class extends BaseStorage {
2372
+ backend = "file";
2373
+ baseDir;
2374
+ options;
2375
+ secret;
2376
+ userLocks = /* @__PURE__ */ new Map();
2377
+ constructor(options = {}) {
2378
+ super(options);
2379
+ this.options = options;
2380
+ this.baseDir = path4.resolve(options.baseDir ?? ".data");
2381
+ this.secret = options.secret ?? getOrCreateKeyfileSecret(this.baseDir);
2382
+ }
2383
+ getBaseDir() {
2384
+ return this.baseDir;
2385
+ }
2386
+ get usersFile() {
2387
+ return path4.join(this.baseDir, "users.json");
2388
+ }
2389
+ get appsFile() {
2390
+ return path4.join(this.baseDir, "apps.json");
2391
+ }
2392
+ async withLock(key, fn) {
2393
+ const prevLock = this.userLocks.get(key) ?? Promise.resolve();
2394
+ let releaseLock;
2395
+ const currentLock = new Promise((resolve6) => {
2396
+ releaseLock = resolve6;
2397
+ });
2398
+ this.userLocks.set(
2399
+ key,
2400
+ prevLock.then(() => currentLock)
2401
+ );
2402
+ try {
2403
+ await prevLock;
2404
+ return await fn();
2405
+ } finally {
2406
+ releaseLock();
2407
+ if (this.userLocks.get(key) === currentLock) {
2408
+ this.userLocks.delete(key);
2409
+ }
2410
+ }
2411
+ }
2412
+ async withUserLock(userId, appId, fn) {
2413
+ return this.withLock(`${appId}:${userId}`, fn);
2414
+ }
2415
+ async readUsersFile() {
2416
+ try {
2417
+ const content = await fs4.readFile(this.usersFile, "utf-8");
2418
+ const list = JSON.parse(content);
2419
+ const map = /* @__PURE__ */ new Map();
2420
+ for (const u of list) {
2421
+ map.set(u.id, u);
2422
+ }
2423
+ return map;
2424
+ } catch {
2425
+ return /* @__PURE__ */ new Map();
2426
+ }
2427
+ }
2428
+ async writeUsersFile(users) {
2429
+ await writeFileAtomic(
2430
+ this.usersFile,
2431
+ JSON.stringify(Array.from(users.values()), null, 2)
2432
+ );
2433
+ }
2434
+ async readAppsFile() {
2435
+ try {
2436
+ const content = await fs4.readFile(this.appsFile, "utf-8");
2437
+ const list = JSON.parse(content);
2438
+ const map = /* @__PURE__ */ new Map();
2439
+ for (const a of list) {
2440
+ map.set(a.id, a);
2441
+ }
2442
+ return map;
2443
+ } catch {
2444
+ return /* @__PURE__ */ new Map();
2445
+ }
2446
+ }
2447
+ async writeAppsFile(apps) {
2448
+ await writeFileAtomic(
2449
+ this.appsFile,
2450
+ JSON.stringify(Array.from(apps.values()), null, 2)
2451
+ );
2452
+ }
2453
+ async findUserDataById(id) {
2454
+ const users = await this.readUsersFile();
2455
+ return users.get(id);
2456
+ }
2457
+ async updateUserData(id, update) {
2458
+ assertNoActiveServerLock(this.baseDir);
2459
+ return this.withLock("__users__", async () => {
2460
+ const users = await this.readUsersFile();
2461
+ const existing = users.get(id);
2462
+ if (!existing) {
2463
+ throw new TetherServerError(
2464
+ 1 /* NotFound */,
2465
+ "User not found"
2466
+ );
2467
+ }
2468
+ users.set(id, { ...existing, ...update });
2469
+ await this.writeUsersFile(users);
2470
+ });
2471
+ }
2472
+ async createApp(id) {
2473
+ assertNoActiveServerLock(this.baseDir);
2474
+ const safeId = validateAppId(id);
2475
+ return this.withLock("__apps__", async () => {
2476
+ const apps = await this.readAppsFile();
2477
+ if (apps.has(safeId)) {
2478
+ throw new TetherServerError(
2479
+ 2 /* AlreadyExists */,
2480
+ "Application already exists"
2481
+ );
2482
+ }
2483
+ const appDir = path4.join(this.baseDir, safeId);
2484
+ await fs4.mkdir(appDir, { recursive: true });
2485
+ const now = Date.now();
2486
+ apps.set(safeId, { id: safeId, createdAt: now });
2487
+ await this.writeAppsFile(apps);
2488
+ const manifestFile = path4.join(appDir, "manifest.json");
2489
+ await writeFileAtomic(
2490
+ manifestFile,
2491
+ JSON.stringify(
2492
+ {
2493
+ id: safeId,
2494
+ tables: [],
2495
+ createdAt: now,
2496
+ version: 1
2497
+ },
2498
+ null,
2499
+ 2
2500
+ )
2501
+ );
2502
+ return new AppFileStorage(safeId, this);
2503
+ });
2504
+ }
2505
+ async getApp(id) {
2506
+ const safeId = validateAppId(id);
2507
+ const apps = await this.readAppsFile();
2508
+ if (apps.has(safeId)) {
2509
+ return new AppFileStorage(safeId, this);
2510
+ }
2511
+ const manifestFile = path4.join(this.baseDir, safeId, "manifest.json");
2512
+ try {
2513
+ await fs4.stat(manifestFile);
2514
+ apps.set(safeId, { id: safeId, createdAt: Date.now() });
2515
+ await this.writeAppsFile(apps);
2516
+ return new AppFileStorage(safeId, this);
2517
+ } catch {
2518
+ return void 0;
2519
+ }
2520
+ }
2521
+ async getApps() {
2522
+ const apps = await this.readAppsFile();
2523
+ return Array.from(apps.values()).sort((a, b) => a.id.localeCompare(b.id)).map((a) => new AppFileStorage(a.id, this));
2524
+ }
2525
+ async createUser(username, password) {
2526
+ assertNoActiveServerLock(this.baseDir);
2527
+ const safeUsername = validateUsername(username);
2528
+ const validPassword = validatePassword(password);
2529
+ const passwordHash = await hashPassword(validPassword);
2530
+ return this.withLock("__users__", async () => {
2531
+ const users = await this.readUsersFile();
2532
+ for (const u of users.values()) {
2533
+ if (u.username.toLowerCase() === safeUsername.toLowerCase()) {
2534
+ throw new TetherServerError(
2535
+ 2 /* AlreadyExists */,
2536
+ "Username is already registered"
2537
+ );
2538
+ }
2539
+ }
2540
+ const userId = crypto3.randomUUID();
2541
+ const userData = {
2542
+ id: userId,
2543
+ username: safeUsername,
2544
+ passwordHash,
2545
+ createdAt: Date.now()
2546
+ };
2547
+ users.set(userId, userData);
2548
+ await this.writeUsersFile(users);
2549
+ return new UserFileStorage(userData, this);
2550
+ });
2551
+ }
2552
+ async getUser(id) {
2553
+ const safeUserId = validateUserId(id);
2554
+ const data = await this.findUserDataById(safeUserId);
2555
+ if (data) {
2556
+ return new UserFileStorage(data, this);
2557
+ }
2558
+ return void 0;
2559
+ }
2560
+ async getUserByUsername(username) {
2561
+ const safeUsername = normalizeUsername(username);
2562
+ if (!safeUsername) return void 0;
2563
+ const users = await this.readUsersFile();
2564
+ for (const u of users.values()) {
2565
+ if (u.username.toLowerCase() === safeUsername) {
2566
+ return new UserFileStorage(u, this);
2567
+ }
2568
+ }
2569
+ return void 0;
2570
+ }
2571
+ async getUsers() {
2572
+ const users = await this.readUsersFile();
2573
+ return Array.from(users.values()).map(
2574
+ (data) => new UserFileStorage(data, this)
2575
+ );
2576
+ }
2577
+ async deleteUser(id) {
2578
+ assertNoActiveServerLock(this.baseDir);
2579
+ const safeUserId = validateUserId(id);
2580
+ return this.withLock("__users__", async () => {
2581
+ let deleted = false;
2582
+ const apps = await this.getApps();
2583
+ const bucket = getUserBucket(safeUserId);
2584
+ for (const app of apps) {
2585
+ const userDir = path4.join(
2586
+ this.baseDir,
2587
+ app.id,
2588
+ "users",
2589
+ bucket,
2590
+ safeUserId
2591
+ );
2592
+ try {
2593
+ await fs4.rm(userDir, { recursive: true, force: true });
2594
+ deleted = true;
2595
+ } catch {
2596
+ }
2597
+ }
2598
+ const users = await this.readUsersFile();
2599
+ if (users.has(safeUserId)) {
2600
+ users.delete(safeUserId);
2601
+ await this.writeUsersFile(users);
2602
+ deleted = true;
2603
+ }
2604
+ return deleted;
2605
+ });
2606
+ }
2607
+ async deleteApp(id) {
2608
+ assertNoActiveServerLock(this.baseDir);
2609
+ const safeId = validateAppId(id);
2610
+ return this.withLock("__apps__", async () => {
2611
+ const apps = await this.readAppsFile();
2612
+ let deleted = false;
2613
+ if (apps.has(safeId)) {
2614
+ apps.delete(safeId);
2615
+ await this.writeAppsFile(apps);
2616
+ deleted = true;
2617
+ }
2618
+ const appDir = path4.join(this.baseDir, safeId);
2619
+ try {
2620
+ await fs4.rm(appDir, { recursive: true, force: true });
2621
+ deleted = true;
2622
+ } catch {
2623
+ }
2624
+ return deleted;
2625
+ });
2626
+ }
2627
+ async checkpoint(appId) {
2628
+ throw new TetherServerError(
2629
+ 7 /* NotSupported */,
2630
+ `Checkpoint operation is not supported by file storage${appId ? ` (app: ${appId})` : ""}`
2631
+ );
2632
+ }
2633
+ async vacuum(appId) {
2634
+ throw new TetherServerError(
2635
+ 7 /* NotSupported */,
2636
+ `Vacuum operation is not supported by file storage${appId ? ` (app: ${appId})` : ""}`
2637
+ );
2638
+ }
2639
+ async prune(appId, keepCount) {
2640
+ const keep = keepCount ?? this.options.maxChangelogEntries ?? 1e3;
2641
+ const allApps = await this.getApps();
2642
+ const targetApps = filterTargetApps(allApps, appId);
2643
+ const users = await this.getUsers();
2644
+ let totalPruned = 0;
2645
+ for (const app of targetApps) {
2646
+ for (const user of users) {
2647
+ await this.withUserLock(user.id, app.id, async () => {
2648
+ const userDir = path4.join(
2649
+ this.baseDir,
2650
+ app.id,
2651
+ "users",
2652
+ getUserBucket(user.id),
2653
+ user.id
2654
+ );
2655
+ const syncFile = path4.join(userDir, "sync.jsonl");
2656
+ const metaFile = path4.join(userDir, "meta.json");
2657
+ try {
2658
+ const content = await fs4.readFile(syncFile, "utf-8");
2659
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
2660
+ if (lines.length > keep) {
2661
+ const pruneCount = lines.length - keep;
2662
+ const keptLines = lines.slice(pruneCount);
2663
+ const firstKept = JSON.parse(keptLines[0]);
2664
+ await writeFileAtomic(syncFile, `${keptLines.join("\n")}
2665
+ `);
2666
+ try {
2667
+ const metaContent = await fs4.readFile(metaFile, "utf-8");
2668
+ const meta = JSON.parse(metaContent);
2669
+ meta.minSeq = firstKept.seq;
2670
+ await writeFileAtomic(metaFile, JSON.stringify(meta, null, 2));
2671
+ } catch {
2672
+ }
2673
+ totalPruned += pruneCount;
2674
+ }
2675
+ } catch {
2676
+ }
2677
+ });
2678
+ }
2679
+ }
2680
+ return {
2681
+ action: "prune",
2682
+ backend: "file",
2683
+ appId,
2684
+ affectedCount: totalPruned,
2685
+ message: `Prune completed successfully. Removed ${totalPruned} changelog record(s)`
2686
+ };
2687
+ }
2688
+ async close() {
2689
+ this.userLocks.clear();
2690
+ }
2691
+ };
2692
+ function assertNoActiveServerLock(baseDir) {
2693
+ const lock = readServerLock(baseDir);
2694
+ if (lock && lock.pid !== process.pid) {
2695
+ throw new TetherServerError(
2696
+ 7 /* NotSupported */,
2697
+ `Cannot modify file storage directly while server is running (PID ${lock.pid})`
2698
+ );
2699
+ }
2700
+ }
2701
+ async function writeFileAtomic(filePath, content) {
2702
+ const dir = path4.dirname(filePath);
2703
+ await fs4.mkdir(dir, { recursive: true });
2704
+ const tmpPath = `${filePath}.${crypto3.randomBytes(6).toString("hex")}.tmp`;
2705
+ await fs4.writeFile(tmpPath, content, "utf-8");
2706
+ await fs4.rename(tmpPath, filePath);
2707
+ }
2708
+
2709
+ // src/server/storage/sqlite/storage.ts
2710
+ var crypto4 = __toESM(require("crypto"), 1);
2711
+ var fs5 = __toESM(require("fs"), 1);
2712
+ var import_node_module = require("module");
2713
+ var path5 = __toESM(require("path"), 1);
2714
+
2715
+ // src/server/storage/sqlite/table.ts
2716
+ var TableSqliteStorage = class extends TableBaseStorage {
2717
+ async delete() {
2718
+ return this.app.deleteTable(this.name);
2719
+ }
2720
+ parseData(raw) {
2721
+ if (raw === null || raw === void 0) return null;
2722
+ try {
2723
+ return JSON.parse(raw);
2724
+ } catch {
2725
+ return raw;
2726
+ }
2727
+ }
2728
+ async getRecord(user, id) {
2729
+ const safeUserId = validateUserId(user.id);
2730
+ const safeId = validateRecordId(id);
2731
+ const handle = this.app.getUserDb(safeUserId);
2732
+ const row = handle.stmtGetRecord.get(this.name, safeId);
2733
+ if (!row) return void 0;
2734
+ return {
2735
+ id: row.id,
2736
+ version: row.version,
2737
+ timestamp: row.timestamp,
2738
+ clientId: row.client_id ?? "",
2739
+ deleted: Boolean(row.deleted),
2740
+ data: this.parseData(row.data)
2741
+ };
2742
+ }
2743
+ async getAllRecords(user) {
2744
+ const safeUserId = validateUserId(user.id);
2745
+ const handle = this.app.getUserDb(safeUserId);
2746
+ const rows = handle.stmtGetSnapshotByTable.all(
2747
+ this.name
2748
+ );
2749
+ return rows.map((row) => ({
2750
+ table: row.table_name,
2751
+ id: row.id,
2752
+ version: row.version,
2753
+ timestamp: row.timestamp,
2754
+ clientId: row.client_id ?? "",
2755
+ data: this.parseData(row.data)
2756
+ }));
2757
+ }
2758
+ };
2759
+
2760
+ // src/server/storage/sqlite/app.ts
2761
+ var AppSqliteStorage = class extends AppBaseStorage {
2762
+ storage;
2763
+ constructor(id, storage) {
2764
+ super(id);
2765
+ this.storage = storage;
2766
+ }
2767
+ getUserDb(userId) {
2768
+ return this.storage.getUserAppDb(this.id, userId);
2769
+ }
2770
+ parseData(raw) {
2771
+ if (raw === null || raw === void 0) return null;
2772
+ try {
2773
+ return JSON.parse(raw);
2774
+ } catch {
2775
+ return raw;
2776
+ }
2777
+ }
2778
+ serializeData(data) {
2779
+ if (data === null || data === void 0) return null;
2780
+ if (typeof data === "string") return data;
2781
+ return JSON.stringify(data);
2782
+ }
2783
+ async createTable(name) {
2784
+ const safeName = validateTableName(name);
2785
+ const appsDb = this.storage.getAppsDb();
2786
+ const existing = appsDb.stmtFindTable.get(this.id, safeName);
2787
+ if (existing) {
2788
+ throw new TetherServerError(
2789
+ 2 /* AlreadyExists */,
2790
+ "Table already exists in this application"
2791
+ );
2792
+ }
2793
+ appsDb.stmtInsertTable.run(this.id, safeName, Date.now());
2794
+ return new TableSqliteStorage(safeName, this);
2795
+ }
2796
+ async getTable(name) {
2797
+ const safeName = validateTableName(name);
2798
+ const appsDb = this.storage.getAppsDb();
2799
+ const row = appsDb.stmtFindTable.get(this.id, safeName);
2800
+ if (row) {
2801
+ return new TableSqliteStorage(safeName, this);
2802
+ }
2803
+ return void 0;
2804
+ }
2805
+ async getTables() {
2806
+ const appsDb = this.storage.getAppsDb();
2807
+ const rows = appsDb.stmtListTables.all(this.id);
2808
+ return rows.map((r) => new TableSqliteStorage(r.name, this));
2809
+ }
2810
+ async applyChanges(user, changes) {
2811
+ const safeUserId = validateUserId(user.id);
2812
+ const appsDb = this.storage.getAppsDb();
2813
+ const handle = this.getUserDb(safeUserId);
2814
+ const applied = [];
2815
+ const maxRecords = this.storage.options.maxRecordsPerTable ?? 1e4;
2816
+ const maxRecordSize = this.storage.options.maxRecordSizeBytes ?? 512 * 1024;
2817
+ const maxChangelog = this.storage.options.maxChangelogEntries ?? 1e3;
2818
+ handle.db.exec("BEGIN IMMEDIATE TRANSACTION");
2819
+ try {
2820
+ const metaRow = handle.stmtGetMeta.get();
2821
+ let currentSeq = metaRow?.current_seq ?? 0;
2822
+ let minSeq = metaRow?.min_seq ?? 0;
2823
+ const checkedTables = /* @__PURE__ */ new Set();
2824
+ for (const change of changes) {
2825
+ const tableName = validateTableName(change.table);
2826
+ const recordId = validateRecordId(change.id);
2827
+ validateTimestamp(change.timestamp);
2828
+ if (!checkedTables.has(tableName)) {
2829
+ const tableExists = appsDb.stmtFindTable.get(this.id, tableName);
2830
+ if (!tableExists) {
2831
+ throw new TetherServerError(
2832
+ 1 /* NotFound */,
2833
+ "Table not found"
2834
+ );
2835
+ }
2836
+ checkedTables.add(tableName);
2837
+ }
2838
+ const existingRecord = handle.stmtGetRecordForUpdate.get(
2839
+ tableName,
2840
+ recordId
2841
+ );
2842
+ if (change.op === "put" /* Put */ && !existingRecord) {
2843
+ const countRow = handle.stmtCountTableRecords.get(tableName);
2844
+ const tableCount = countRow?.count ?? 0;
2845
+ if (tableCount >= maxRecords) {
2846
+ throw new TetherServerError(
2847
+ 5 /* LimitExceeded */,
2848
+ "Table record limit reached"
2849
+ );
2850
+ }
2851
+ }
2852
+ const payloadBytes = calculateByteSize(change.data);
2853
+ if (payloadBytes > maxRecordSize) {
2854
+ throw new TetherServerError(
2855
+ 5 /* LimitExceeded */,
2856
+ "Record payload exceeds maximum allowed size"
2857
+ );
2858
+ }
2859
+ const shouldApply = !existingRecord || shouldOverwrite(
2860
+ {
2861
+ timestamp: change.timestamp,
2862
+ clientId: change.clientId,
2863
+ version: change.version
2864
+ },
2865
+ {
2866
+ timestamp: existingRecord.timestamp,
2867
+ clientId: existingRecord.client_id ?? "",
2868
+ version: existingRecord.version
2869
+ }
2870
+ );
2871
+ if (shouldApply) {
2872
+ currentSeq++;
2873
+ const assignedSeq = currentSeq;
2874
+ if (minSeq === 0) {
2875
+ minSeq = 1;
2876
+ }
2877
+ const isDeleted = change.op === "delete" /* Delete */;
2878
+ const nextVersion = (existingRecord?.version ?? 0) + 1;
2879
+ const serializedData = isDeleted ? null : this.serializeData(change.data);
2880
+ if (existingRecord) {
2881
+ handle.stmtUpdateRecord.run(
2882
+ nextVersion,
2883
+ change.timestamp,
2884
+ change.clientId ?? "",
2885
+ isDeleted ? 1 : 0,
2886
+ serializedData,
2887
+ tableName,
2888
+ recordId
2889
+ );
2890
+ } else {
2891
+ handle.stmtInsertRecord.run(
2892
+ tableName,
2893
+ recordId,
2894
+ nextVersion,
2895
+ change.timestamp,
2896
+ change.clientId ?? "",
2897
+ isDeleted ? 1 : 0,
2898
+ serializedData
2899
+ );
2900
+ }
2901
+ handle.stmtInsertChangelog.run(
2902
+ assignedSeq,
2903
+ tableName,
2904
+ recordId,
2905
+ change.op,
2906
+ nextVersion,
2907
+ change.timestamp,
2908
+ change.clientId ?? "",
2909
+ isDeleted ? 1 : 0,
2910
+ serializedData
2911
+ );
2912
+ applied.push({
2913
+ seq: assignedSeq,
2914
+ table: tableName,
2915
+ id: recordId,
2916
+ op: change.op,
2917
+ version: nextVersion,
2918
+ timestamp: change.timestamp,
2919
+ clientId: change.clientId,
2920
+ data: isDeleted ? void 0 : change.data
2921
+ });
2922
+ }
2923
+ }
2924
+ if (currentSeq - minSeq + 1 > maxChangelog) {
2925
+ const newMinSeq = currentSeq - maxChangelog + 1;
2926
+ handle.stmtPruneChangelog.run(newMinSeq);
2927
+ minSeq = newMinSeq;
2928
+ }
2929
+ if (applied.length > 0) {
2930
+ handle.stmtSetMeta.run(currentSeq, minSeq);
2931
+ }
2932
+ handle.db.exec("COMMIT");
2933
+ return { applied, newSeq: currentSeq };
2934
+ } catch (err) {
2935
+ handle.db.exec("ROLLBACK");
2936
+ throw err;
2937
+ }
2938
+ }
2939
+ async getChangesSince(user, fromSeq) {
2940
+ const safeUserId = validateUserId(user.id);
2941
+ const handle = this.getUserDb(safeUserId);
2942
+ const metaRow = handle.stmtGetMeta.get();
2943
+ const currentSeq = metaRow?.current_seq ?? 0;
2944
+ const minSeq = metaRow?.min_seq ?? 0;
2945
+ if (fromSeq < minSeq && minSeq > 0 || fromSeq > currentSeq) {
2946
+ return { changes: [], currentSeq, requiresSnapshot: true };
2947
+ }
2948
+ const rows = handle.stmtGetChangelogSince.all(
2949
+ fromSeq
2950
+ );
2951
+ const changes = rows.map((row) => ({
2952
+ seq: row.seq,
2953
+ table: row.table_name,
2954
+ id: row.id,
2955
+ op: row.op === "delete" /* Delete */ ? "delete" /* Delete */ : "put" /* Put */,
2956
+ version: row.version,
2957
+ timestamp: row.timestamp,
2958
+ clientId: row.client_id ?? "",
2959
+ data: row.op === "delete" /* Delete */ ? void 0 : this.parseData(row.data)
2960
+ }));
2961
+ return { changes, currentSeq, requiresSnapshot: false };
2962
+ }
2963
+ async getCurrentSeq(user) {
2964
+ const safeUserId = validateUserId(user.id);
2965
+ const handle = this.getUserDb(safeUserId);
2966
+ const metaRow = handle.stmtGetMeta.get();
2967
+ return metaRow?.current_seq ?? 0;
2968
+ }
2969
+ async delete() {
2970
+ return this.storage.deleteApp(this.id);
2971
+ }
2972
+ deleteTable(name) {
2973
+ return this.storage.deleteTable(this.id, name);
2974
+ }
2975
+ };
2976
+
2977
+ // src/server/storage/sqlite/user.ts
2978
+ var UserSqliteStorage = class extends UserBaseStorage {
2979
+ storage;
2980
+ constructor(data, storage) {
2981
+ super(data.id, data.username, data.createdAt);
2982
+ this.storage = storage;
2983
+ }
2984
+ getSecret() {
2985
+ return this.storage.secret;
2986
+ }
2987
+ async verifyPassword(password) {
2988
+ const user = this.storage.findUserDataById(this.id);
2989
+ return verifyUserPassword(password, user?.passwordHash);
2990
+ }
2991
+ async changePassword(newPassword) {
2992
+ const newHash = await hashUserPassword(newPassword);
2993
+ this.storage.updateUserData(this.id, newHash);
2994
+ }
2995
+ async delete() {
2996
+ return this.storage.deleteUser(this.id);
2997
+ }
2998
+ };
2999
+
3000
+ // src/server/storage/sqlite/storage.ts
3001
+ var SqliteStorage = class extends BaseStorage {
3002
+ backend = "sqlite";
3003
+ baseDir;
3004
+ inMemory;
3005
+ maxOpenDatabases;
3006
+ options;
3007
+ secret;
3008
+ usersHandle = null;
3009
+ appsHandle = null;
3010
+ userAppDbs = /* @__PURE__ */ new Map();
3011
+ constructor(options = {}) {
3012
+ super(options);
3013
+ this.options = options;
3014
+ this.inMemory = Boolean(
3015
+ options.inMemory || options.baseDir === ":memory:" || !options.baseDir && options.inMemory
3016
+ );
3017
+ this.maxOpenDatabases = options.maxOpenDatabases ?? 100;
3018
+ this.baseDir = path5.resolve(options.baseDir ?? ".data");
3019
+ this.secret = options.secret ?? (this.inMemory ? crypto4.randomBytes(32).toString("hex") : getOrCreateKeyfileSecret(this.baseDir));
3020
+ if (!this.inMemory) {
3021
+ try {
3022
+ fs5.mkdirSync(this.baseDir, { recursive: true });
3023
+ } catch {
3024
+ }
3025
+ }
3026
+ }
3027
+ getBaseDir() {
3028
+ return this.inMemory ? ":memory:" : this.baseDir;
3029
+ }
3030
+ getUsersDb() {
3031
+ if (this.usersHandle) return this.usersHandle;
3032
+ const require2 = (0, import_node_module.createRequire)(importMetaUrl);
3033
+ const { DatabaseSync } = require2("node:sqlite");
3034
+ const dbPath = this.inMemory ? ":memory:" : path5.join(this.baseDir, "users.sqlite");
3035
+ const db = new DatabaseSync(dbPath);
3036
+ db.exec("PRAGMA busy_timeout = 5000;");
3037
+ db.exec("PRAGMA foreign_keys = ON;");
3038
+ if (!this.inMemory) {
3039
+ db.exec("PRAGMA journal_mode = WAL;");
3040
+ db.exec("PRAGMA synchronous = NORMAL;");
3041
+ }
3042
+ initUsersSchema(db);
3043
+ this.usersHandle = {
3044
+ db,
3045
+ stmtFindById: db.prepare(
3046
+ "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"
3047
+ ),
3048
+ stmtFindByUsername: db.prepare(
3049
+ "SELECT id, username, password_hash, created_at FROM users WHERE username = ?"
3050
+ ),
3051
+ stmtInsertUser: db.prepare(
3052
+ "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)"
3053
+ ),
3054
+ stmtUpdatePassword: db.prepare(
3055
+ "UPDATE users SET password_hash = ? WHERE id = ?"
3056
+ ),
3057
+ stmtDeleteUser: db.prepare("DELETE FROM users WHERE id = ?"),
3058
+ stmtListUsers: db.prepare(
3059
+ "SELECT id, username, password_hash, created_at FROM users ORDER BY created_at ASC"
3060
+ )
3061
+ };
3062
+ return this.usersHandle;
3063
+ }
3064
+ getAppsDb() {
3065
+ if (this.appsHandle) return this.appsHandle;
3066
+ const require2 = (0, import_node_module.createRequire)(importMetaUrl);
3067
+ const { DatabaseSync } = require2("node:sqlite");
3068
+ const dbPath = this.inMemory ? ":memory:" : path5.join(this.baseDir, "apps.sqlite");
3069
+ const db = new DatabaseSync(dbPath);
3070
+ db.exec("PRAGMA busy_timeout = 5000;");
3071
+ db.exec("PRAGMA foreign_keys = ON;");
3072
+ if (!this.inMemory) {
3073
+ db.exec("PRAGMA journal_mode = WAL;");
3074
+ db.exec("PRAGMA synchronous = NORMAL;");
3075
+ }
3076
+ initAppsSchema(db);
3077
+ this.appsHandle = {
3078
+ db,
3079
+ stmtFindApp: db.prepare("SELECT id, created_at FROM apps WHERE id = ?"),
3080
+ stmtInsertApp: db.prepare(
3081
+ "INSERT INTO apps (id, created_at) VALUES (?, ?)"
3082
+ ),
3083
+ stmtListApps: db.prepare("SELECT id FROM apps ORDER BY id ASC"),
3084
+ stmtDeleteApp: db.prepare("DELETE FROM apps WHERE id = ?"),
3085
+ stmtFindTable: db.prepare(
3086
+ "SELECT name, created_at FROM tables WHERE app_id = ? AND name = ?"
3087
+ ),
3088
+ stmtInsertTable: db.prepare(
3089
+ "INSERT INTO tables (app_id, name, created_at) VALUES (?, ?, ?)"
3090
+ ),
3091
+ stmtListTables: db.prepare(
3092
+ "SELECT name FROM tables WHERE app_id = ? ORDER BY name ASC"
3093
+ ),
3094
+ stmtDeleteTable: db.prepare(
3095
+ "DELETE FROM tables WHERE app_id = ? AND name = ?"
3096
+ ),
3097
+ stmtDeleteAppTables: db.prepare("DELETE FROM tables WHERE app_id = ?")
3098
+ };
3099
+ return this.appsHandle;
3100
+ }
3101
+ getUserAppDb(appId, userId) {
3102
+ const safeAppId = validateAppId(appId);
3103
+ const safeUserId = validateUserId(userId);
3104
+ const cacheKey = `${safeAppId}:${safeUserId}`;
3105
+ let handle = this.userAppDbs.get(cacheKey);
3106
+ if (handle) {
3107
+ this.userAppDbs.delete(cacheKey);
3108
+ this.userAppDbs.set(cacheKey, handle);
3109
+ return handle;
3110
+ }
3111
+ if (!this.inMemory && this.userAppDbs.size >= this.maxOpenDatabases) {
3112
+ const oldestKey = this.userAppDbs.keys().next().value;
3113
+ if (oldestKey) {
3114
+ const oldestHandle = this.userAppDbs.get(oldestKey);
3115
+ try {
3116
+ oldestHandle?.db.close();
3117
+ } catch {
3118
+ }
3119
+ this.userAppDbs.delete(oldestKey);
3120
+ }
3121
+ }
3122
+ const require2 = (0, import_node_module.createRequire)(importMetaUrl);
3123
+ const { DatabaseSync } = require2("node:sqlite");
3124
+ let dbPath;
3125
+ if (this.inMemory) {
3126
+ dbPath = ":memory:";
3127
+ } else {
3128
+ const bucket = getUserBucket(safeUserId);
3129
+ const userDir = path5.join(this.baseDir, safeAppId, bucket);
3130
+ try {
3131
+ fs5.mkdirSync(userDir, { recursive: true });
3132
+ } catch {
3133
+ }
3134
+ dbPath = path5.join(userDir, `${safeUserId}.sqlite`);
3135
+ }
3136
+ const db = new DatabaseSync(dbPath);
3137
+ db.exec("PRAGMA busy_timeout = 5000;");
3138
+ db.exec("PRAGMA foreign_keys = ON;");
3139
+ if (!this.inMemory) {
3140
+ db.exec("PRAGMA journal_mode = WAL;");
3141
+ db.exec("PRAGMA synchronous = NORMAL;");
3142
+ }
3143
+ initUserAppSchema(db);
3144
+ handle = {
3145
+ db,
3146
+ stmtGetRecord: db.prepare(
3147
+ "SELECT table_name, id, version, timestamp, client_id, deleted, data FROM records WHERE table_name = ? AND id = ? AND deleted = 0"
3148
+ ),
3149
+ stmtGetRecordForUpdate: db.prepare(
3150
+ "SELECT table_name, id, version, timestamp, client_id, deleted, data FROM records WHERE table_name = ? AND id = ?"
3151
+ ),
3152
+ stmtGetSnapshot: db.prepare(
3153
+ "SELECT table_name, id, version, timestamp, client_id, deleted, data FROM records WHERE deleted = 0"
3154
+ ),
3155
+ stmtGetSnapshotByTable: db.prepare(
3156
+ "SELECT table_name, id, version, timestamp, client_id, deleted, data FROM records WHERE table_name = ? AND deleted = 0"
3157
+ ),
3158
+ stmtInsertRecord: db.prepare(
3159
+ "INSERT INTO records (table_name, id, version, timestamp, client_id, deleted, data) VALUES (?, ?, ?, ?, ?, ?, ?)"
3160
+ ),
3161
+ stmtUpdateRecord: db.prepare(
3162
+ "UPDATE records SET version = ?, timestamp = ?, client_id = ?, deleted = ?, data = ? WHERE table_name = ? AND id = ?"
3163
+ ),
3164
+ stmtGetMeta: db.prepare(
3165
+ "SELECT current_seq, min_seq FROM user_meta LIMIT 1"
3166
+ ),
3167
+ stmtSetMeta: db.prepare(
3168
+ "INSERT OR REPLACE INTO user_meta (rowid, current_seq, min_seq) VALUES (1, ?, ?)"
3169
+ ),
3170
+ stmtInsertChangelog: db.prepare(
3171
+ "INSERT INTO changelog (seq, table_name, id, op, version, timestamp, client_id, deleted, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
3172
+ ),
3173
+ stmtGetChangelogSince: db.prepare(
3174
+ "SELECT seq, table_name, id, op, version, timestamp, client_id, deleted, data FROM changelog WHERE seq > ? ORDER BY seq ASC"
3175
+ ),
3176
+ stmtPruneChangelog: db.prepare("DELETE FROM changelog WHERE seq < ?"),
3177
+ stmtCountTableRecords: db.prepare(
3178
+ "SELECT COUNT(*) as count FROM records WHERE table_name = ? AND deleted = 0"
3179
+ ),
3180
+ stmtDeleteTableRecords: db.prepare(
3181
+ "DELETE FROM records WHERE table_name = ?"
3182
+ ),
3183
+ stmtDeleteTableChangelog: db.prepare(
3184
+ "DELETE FROM changelog WHERE table_name = ?"
3185
+ )
3186
+ };
3187
+ this.userAppDbs.set(cacheKey, handle);
3188
+ return handle;
3189
+ }
3190
+ findUserDataById(id) {
3191
+ const usersDb = this.getUsersDb();
3192
+ const row = usersDb.stmtFindById.get(id);
3193
+ if (!row) return void 0;
3194
+ return {
3195
+ id: row.id,
3196
+ username: row.username,
3197
+ passwordHash: row.password_hash,
3198
+ createdAt: row.created_at
3199
+ };
3200
+ }
3201
+ updateUserData(id, passwordHash) {
3202
+ const usersDb = this.getUsersDb();
3203
+ usersDb.stmtUpdatePassword.run(passwordHash, id);
3204
+ }
3205
+ async createApp(id) {
3206
+ const safeId = validateAppId(id);
3207
+ const appsDb = this.getAppsDb();
3208
+ const existing = appsDb.stmtFindApp.get(safeId);
3209
+ if (existing) {
3210
+ throw new TetherServerError(
3211
+ 2 /* AlreadyExists */,
3212
+ "Application already exists"
3213
+ );
3214
+ }
3215
+ appsDb.stmtInsertApp.run(safeId, Date.now());
3216
+ if (!this.inMemory) {
3217
+ const appDir = path5.join(this.baseDir, safeId);
3218
+ try {
3219
+ fs5.mkdirSync(appDir, { recursive: true });
3220
+ } catch {
3221
+ }
3222
+ }
3223
+ return new AppSqliteStorage(safeId, this);
3224
+ }
3225
+ async getApp(id) {
3226
+ const safeId = validateAppId(id);
3227
+ const appsDb = this.getAppsDb();
3228
+ const existing = appsDb.stmtFindApp.get(safeId);
3229
+ if (existing) {
3230
+ return new AppSqliteStorage(safeId, this);
3231
+ }
3232
+ return void 0;
3233
+ }
3234
+ async getApps() {
3235
+ const appsDb = this.getAppsDb();
3236
+ const rows = appsDb.stmtListApps.all();
3237
+ return rows.map((r) => new AppSqliteStorage(r.id, this));
3238
+ }
3239
+ async createUser(username, password) {
3240
+ const safeUsername = validateUsername(username);
3241
+ const validPassword = validatePassword(password);
3242
+ const usersDb = this.getUsersDb();
3243
+ const existing = usersDb.stmtFindByUsername.get(safeUsername);
3244
+ if (existing) {
3245
+ throw new TetherServerError(
3246
+ 2 /* AlreadyExists */,
3247
+ "Username is already registered"
3248
+ );
3249
+ }
3250
+ const userId = crypto4.randomUUID();
3251
+ const passwordHash = await hashPassword(validPassword);
3252
+ const createdAt = Date.now();
3253
+ usersDb.stmtInsertUser.run(userId, safeUsername, passwordHash, createdAt);
3254
+ const userData = {
3255
+ id: userId,
3256
+ username: safeUsername,
3257
+ passwordHash,
3258
+ createdAt
3259
+ };
3260
+ return new UserSqliteStorage(userData, this);
3261
+ }
3262
+ async getUser(id) {
3263
+ const safeUserId = validateUserId(id);
3264
+ const data = this.findUserDataById(safeUserId);
3265
+ if (data) {
3266
+ return new UserSqliteStorage(data, this);
3267
+ }
3268
+ return void 0;
3269
+ }
3270
+ async getUserByUsername(username) {
3271
+ const safeUsername = normalizeUsername(username);
3272
+ if (!safeUsername) return void 0;
3273
+ const usersDb = this.getUsersDb();
3274
+ const row = usersDb.stmtFindByUsername.get(safeUsername);
3275
+ if (!row) return void 0;
3276
+ const data = {
3277
+ id: row.id,
3278
+ username: row.username,
3279
+ passwordHash: row.password_hash,
3280
+ createdAt: row.created_at
3281
+ };
3282
+ return new UserSqliteStorage(data, this);
3283
+ }
3284
+ async getUsers() {
3285
+ const usersDb = this.getUsersDb();
3286
+ const rows = usersDb.stmtListUsers.all();
3287
+ return rows.map(
3288
+ (r) => new UserSqliteStorage(
3289
+ {
3290
+ id: r.id,
3291
+ username: r.username,
3292
+ passwordHash: r.password_hash,
3293
+ createdAt: r.created_at
3294
+ },
3295
+ this
3296
+ )
3297
+ );
3298
+ }
3299
+ deleteUser(id) {
3300
+ const safeUserId = validateUserId(id);
3301
+ let deleted = false;
3302
+ for (const [key, handle] of Array.from(this.userAppDbs.entries())) {
3303
+ if (key.endsWith(`:${safeUserId}`)) {
3304
+ try {
3305
+ handle.db.close();
3306
+ } catch {
3307
+ }
3308
+ this.userAppDbs.delete(key);
3309
+ }
3310
+ }
3311
+ if (!this.inMemory) {
3312
+ const bucket = getUserBucket(safeUserId);
3313
+ const apps = this.getAppsDb().stmtListApps.all();
3314
+ for (const app of apps) {
3315
+ const userDbFile = path5.join(
3316
+ this.baseDir,
3317
+ app.id,
3318
+ bucket,
3319
+ `${safeUserId}.sqlite`
3320
+ );
3321
+ const walFile = `${userDbFile}-wal`;
3322
+ const shmFile = `${userDbFile}-shm`;
3323
+ try {
3324
+ fs5.unlinkSync(userDbFile);
3325
+ deleted = true;
3326
+ } catch {
3327
+ }
3328
+ try {
3329
+ fs5.unlinkSync(walFile);
3330
+ } catch {
3331
+ }
3332
+ try {
3333
+ fs5.unlinkSync(shmFile);
3334
+ } catch {
3335
+ }
3336
+ }
3337
+ }
3338
+ const usersDb = this.getUsersDb();
3339
+ const res = usersDb.stmtDeleteUser.run(safeUserId);
3340
+ if (res.changes > 0) deleted = true;
3341
+ return deleted;
3342
+ }
3343
+ deleteApp(id) {
3344
+ const safeId = validateAppId(id);
3345
+ let deleted = false;
3346
+ for (const [key, handle] of Array.from(this.userAppDbs.entries())) {
3347
+ if (key.startsWith(`${safeId}:`)) {
3348
+ try {
3349
+ handle.db.close();
3350
+ } catch {
3351
+ }
3352
+ this.userAppDbs.delete(key);
3353
+ }
3354
+ }
3355
+ const appsDb = this.getAppsDb();
3356
+ appsDb.stmtDeleteAppTables.run(safeId);
3357
+ const res = appsDb.stmtDeleteApp.run(safeId);
3358
+ if (res.changes > 0) deleted = true;
3359
+ if (!this.inMemory) {
3360
+ const appDir = path5.join(this.baseDir, safeId);
3361
+ try {
3362
+ fs5.rmSync(appDir, { recursive: true, force: true });
3363
+ deleted = true;
3364
+ } catch {
3365
+ }
3366
+ }
3367
+ return deleted;
3368
+ }
3369
+ deleteTable(appId, tableName) {
3370
+ const safeAppId = validateAppId(appId);
3371
+ const appsDb = this.getAppsDb();
3372
+ const res = appsDb.stmtDeleteTable.run(safeAppId, tableName);
3373
+ if (res.changes === 0) return false;
3374
+ for (const [key, handle] of this.userAppDbs.entries()) {
3375
+ if (key.startsWith(`${safeAppId}:`)) {
3376
+ try {
3377
+ handle.stmtDeleteTableRecords.run(tableName);
3378
+ handle.stmtDeleteTableChangelog.run(tableName);
3379
+ } catch {
3380
+ }
3381
+ }
3382
+ }
3383
+ return true;
3384
+ }
3385
+ async checkpoint(appId) {
3386
+ const allApps = await this.getApps();
3387
+ const targetApps = filterTargetApps(allApps, appId);
3388
+ const users = await this.getUsers();
3389
+ const count = this.executeOnAllDbs(
3390
+ "PRAGMA wal_checkpoint(TRUNCATE);",
3391
+ targetApps,
3392
+ users
3393
+ );
3394
+ return {
3395
+ action: "checkpoint",
3396
+ backend: "sqlite",
3397
+ appId,
3398
+ affectedCount: count,
3399
+ message: `Checkpoint completed successfully across ${count} database(s)`
3400
+ };
3401
+ }
3402
+ async vacuum(appId) {
3403
+ const allApps = await this.getApps();
3404
+ const targetApps = filterTargetApps(allApps, appId);
3405
+ const users = await this.getUsers();
3406
+ const count = this.executeOnAllDbs("VACUUM;", targetApps, users);
3407
+ return {
3408
+ action: "vacuum",
3409
+ backend: "sqlite",
3410
+ appId,
3411
+ affectedCount: count,
3412
+ message: `Vacuum completed successfully across ${count} database(s)`
3413
+ };
3414
+ }
3415
+ executeOnAllDbs(sql, targetApps, users) {
3416
+ let count = 0;
3417
+ this.getUsersDb().db.exec(sql);
3418
+ count++;
3419
+ this.getAppsDb().db.exec(sql);
3420
+ count++;
3421
+ for (const app of targetApps) {
3422
+ for (const user of users) {
3423
+ if (!this.inMemory) {
3424
+ const bucket = getUserBucket(user.id);
3425
+ const dbPath = path5.join(
3426
+ this.baseDir,
3427
+ app.id,
3428
+ bucket,
3429
+ `${user.id}.sqlite`
3430
+ );
3431
+ if (!fs5.existsSync(dbPath)) continue;
3432
+ }
3433
+ const handle = this.getUserAppDb(app.id, user.id);
3434
+ handle.db.exec(sql);
3435
+ count++;
3436
+ }
3437
+ }
3438
+ return count;
3439
+ }
3440
+ async prune(appId, keepCount) {
3441
+ const keep = keepCount ?? this.options.maxChangelogEntries ?? 1e3;
3442
+ const allApps = await this.getApps();
3443
+ const targetApps = filterTargetApps(allApps, appId);
3444
+ const users = await this.getUsers();
3445
+ let totalPruned = 0;
3446
+ for (const app of targetApps) {
3447
+ for (const user of users) {
3448
+ if (!this.inMemory) {
3449
+ const bucket = getUserBucket(user.id);
3450
+ const dbPath = path5.join(
3451
+ this.baseDir,
3452
+ app.id,
3453
+ bucket,
3454
+ `${user.id}.sqlite`
3455
+ );
3456
+ if (!fs5.existsSync(dbPath)) continue;
3457
+ }
3458
+ const handle = this.getUserAppDb(app.id, user.id);
3459
+ const metaRow = handle.stmtGetMeta.get();
3460
+ if (!metaRow || metaRow.current_seq === 0) continue;
3461
+ const currentSeq = metaRow.current_seq;
3462
+ const newMinSeq = Math.max(1, currentSeq - keep + 1);
3463
+ if (newMinSeq > metaRow.min_seq) {
3464
+ const res = handle.stmtPruneChangelog.run(newMinSeq);
3465
+ totalPruned += res.changes;
3466
+ handle.stmtSetMeta.run(currentSeq, newMinSeq);
3467
+ }
3468
+ }
3469
+ }
3470
+ return {
3471
+ action: "prune",
3472
+ backend: "sqlite",
3473
+ appId,
3474
+ affectedCount: totalPruned,
3475
+ message: `Prune completed successfully. Removed ${totalPruned} changelog record(s)`
3476
+ };
3477
+ }
3478
+ async close() {
3479
+ if (this.usersHandle) {
3480
+ try {
3481
+ this.usersHandle.db.close();
3482
+ } catch {
3483
+ }
3484
+ this.usersHandle = null;
3485
+ }
3486
+ if (this.appsHandle) {
3487
+ try {
3488
+ this.appsHandle.db.close();
3489
+ } catch {
3490
+ }
3491
+ this.appsHandle = null;
3492
+ }
3493
+ for (const handle of this.userAppDbs.values()) {
3494
+ try {
3495
+ handle.db.close();
3496
+ } catch {
3497
+ }
3498
+ }
3499
+ this.userAppDbs.clear();
3500
+ }
3501
+ };
3502
+ var USERS_DB_SCHEMA_VERSION = 1;
3503
+ var APPS_DB_SCHEMA_VERSION = 1;
3504
+ var USER_APP_DB_SCHEMA_VERSION = 1;
3505
+ function getSchemaVersion(db) {
3506
+ const row = db.prepare("PRAGMA user_version;").get();
3507
+ return row?.user_version ?? 0;
3508
+ }
3509
+ function setSchemaVersion(db, version) {
3510
+ db.exec(`PRAGMA user_version = ${version};`);
3511
+ }
3512
+ function initUsersSchema(db) {
3513
+ const currentVersion = getSchemaVersion(db);
3514
+ if (currentVersion < 1) {
3515
+ db.exec(`
3516
+ CREATE TABLE IF NOT EXISTS users (
3517
+ id TEXT PRIMARY KEY,
3518
+ username TEXT UNIQUE NOT NULL,
3519
+ password_hash TEXT,
3520
+ created_at INTEGER NOT NULL
3521
+ );
3522
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
3523
+ `);
3524
+ setSchemaVersion(db, USERS_DB_SCHEMA_VERSION);
3525
+ }
3526
+ }
3527
+ function initAppsSchema(db) {
3528
+ const currentVersion = getSchemaVersion(db);
3529
+ if (currentVersion < 1) {
3530
+ db.exec(`
3531
+ CREATE TABLE IF NOT EXISTS apps (
3532
+ id TEXT PRIMARY KEY,
3533
+ created_at INTEGER NOT NULL
3534
+ );
3535
+ CREATE TABLE IF NOT EXISTS tables (
3536
+ app_id TEXT NOT NULL,
3537
+ name TEXT NOT NULL,
3538
+ created_at INTEGER NOT NULL,
3539
+ PRIMARY KEY (app_id, name)
3540
+ );
3541
+ CREATE INDEX IF NOT EXISTS idx_tables_app ON tables (app_id);
3542
+ `);
3543
+ setSchemaVersion(db, APPS_DB_SCHEMA_VERSION);
3544
+ }
3545
+ }
3546
+ function initUserAppSchema(db) {
3547
+ const currentVersion = getSchemaVersion(db);
3548
+ if (currentVersion < 1) {
3549
+ db.exec(`
3550
+ CREATE TABLE IF NOT EXISTS records (
3551
+ table_name TEXT NOT NULL,
3552
+ id TEXT NOT NULL,
3553
+ version INTEGER NOT NULL,
3554
+ timestamp INTEGER NOT NULL,
3555
+ client_id TEXT NOT NULL,
3556
+ deleted INTEGER NOT NULL,
3557
+ data TEXT,
3558
+ PRIMARY KEY (table_name, id)
3559
+ );
3560
+
3561
+ CREATE INDEX IF NOT EXISTS idx_records_lookup
3562
+ ON records (table_name, deleted);
3563
+
3564
+ CREATE TABLE IF NOT EXISTS changelog (
3565
+ seq INTEGER PRIMARY KEY,
3566
+ table_name TEXT NOT NULL,
3567
+ id TEXT NOT NULL,
3568
+ op TEXT NOT NULL,
3569
+ version INTEGER NOT NULL,
3570
+ timestamp INTEGER NOT NULL,
3571
+ client_id TEXT NOT NULL,
3572
+ deleted INTEGER NOT NULL,
3573
+ data TEXT
3574
+ );
3575
+
3576
+ CREATE INDEX IF NOT EXISTS idx_changelog_sync
3577
+ ON changelog (seq);
3578
+
3579
+ CREATE TABLE IF NOT EXISTS user_meta (
3580
+ current_seq INTEGER NOT NULL,
3581
+ min_seq INTEGER NOT NULL
3582
+ );
3583
+ `);
3584
+ setSchemaVersion(db, USER_APP_DB_SCHEMA_VERSION);
3585
+ }
3586
+ }
3587
+
3588
+ // src/cli/args.ts
3589
+ function parseCliArgs(args) {
3590
+ const positionalArgs = [];
3591
+ let port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8080;
3592
+ let host = process.env.HOST ?? "0.0.0.0";
3593
+ let backend = "memory";
3594
+ let dir = ".data";
3595
+ for (let i = 0; i < args.length; i++) {
3596
+ const arg = args[i];
3597
+ if (arg.startsWith("--port=")) port = Number.parseInt(arg.slice(7), 10);
3598
+ else if (arg === "-p") port = Number.parseInt(args[++i] ?? "", 10);
3599
+ else if (arg.startsWith("--host=")) host = arg.slice(7);
3600
+ else if (arg === "-H") host = args[++i] ?? host;
3601
+ else if (arg === "--memory") backend = "memory";
3602
+ else if (arg === "--file" || arg.startsWith("--file=")) {
3603
+ backend = "file";
3604
+ if (arg.startsWith("--file=")) dir = arg.slice(7);
3605
+ } else if (arg === "--sqlite" || arg.startsWith("--sqlite=")) {
3606
+ backend = "sqlite";
3607
+ if (arg.startsWith("--sqlite=")) dir = arg.slice(9);
3608
+ } else if (arg.startsWith("-")) {
3609
+ throw new TetherServerError(
3610
+ 6 /* ConfigurationError */,
3611
+ `Unknown or invalid option: "${arg}"`
3612
+ );
3613
+ } else {
3614
+ positionalArgs.push(arg);
3615
+ }
3616
+ }
3617
+ return {
3618
+ command: positionalArgs[0] ?? "serve",
3619
+ positionalArgs,
3620
+ port,
3621
+ host,
3622
+ backend,
3623
+ dir
3624
+ };
3625
+ }
3626
+
3627
+ // src/cli/backend.ts
3628
+ var path6 = __toESM(require("path"), 1);
3629
+ function createBackend(backend = "memory", baseDir = ".data", options) {
3630
+ const resolvedDir = path6.resolve(baseDir);
3631
+ switch (backend) {
3632
+ case "memory":
3633
+ return new MemoryStorage(options);
3634
+ case "sqlite":
3635
+ return new SqliteStorage({ baseDir: resolvedDir, ...options });
3636
+ case "file":
3637
+ return new FileStorage({ baseDir: resolvedDir, ...options });
3638
+ default:
3639
+ throw new TetherServerError(
3640
+ 6 /* ConfigurationError */,
3641
+ `Unknown backend type: "${backend}". Expected 'memory', 'file', or 'sqlite'`
3642
+ );
3643
+ }
3644
+ }
3645
+
3646
+ // src/cli/commands/apps.ts
3647
+ async function handleAppsCommand(storage, [, action = "list", appId]) {
3648
+ if (action === "list") {
3649
+ const apps = await storage.getApps();
3650
+ if (!apps.length) return console.log("No applications found.");
3651
+ console.log(`Applications (${apps.length}):`);
3652
+ for (const app of apps) {
3653
+ const tables = await app.getTables();
3654
+ const tableList = tables.length > 0 ? tables.map((t) => t.name).join(", ") : "no tables";
3655
+ console.log(` \u2022 ${app.id} (tables: ${tableList})`);
3656
+ }
3657
+ } else if (action === "add") {
3658
+ if (!appId) {
3659
+ throw new TetherServerError(
3660
+ 6 /* ConfigurationError */,
3661
+ "Missing application ID"
3662
+ );
3663
+ }
3664
+ if (await storage.getApp(appId)) {
3665
+ return console.log(`Application already exists: ${appId}`);
3666
+ }
3667
+ await storage.createApp(appId);
3668
+ console.log(`Created application: ${appId}`);
3669
+ } else if (action === "rm") {
3670
+ if (!appId) {
3671
+ throw new TetherServerError(
3672
+ 6 /* ConfigurationError */,
3673
+ "Missing application ID"
3674
+ );
3675
+ }
3676
+ const app = await storage.getApp(appId);
3677
+ if (!app) return console.log(`Application not found: ${appId}`);
3678
+ await app.delete();
3679
+ console.log(`Deleted application: ${appId}`);
3680
+ } else {
3681
+ throw new TetherServerError(
3682
+ 6 /* ConfigurationError */,
3683
+ `Unknown apps action: "${action}"`
3684
+ );
3685
+ }
3686
+ }
3687
+
3688
+ // src/cli/commands/help.ts
3689
+ function printHelp() {
3690
+ console.log(`
3691
+ TetherDB CLI
3692
+
3693
+ Usage:
3694
+ npx tetherdb [command] [options]
3695
+ tetherdb [command] [options]
3696
+
3697
+ Commands:
3698
+ serve (default) Start HTTP and WebSocket synchronization server
3699
+ status [appid] Display storage and database statistics
3700
+ maintenance checkpoint [appid] Truncate WAL files for SQLite databases
3701
+ maintenance vacuum [appid] Reclaim disk space and defragment database files
3702
+ maintenance prune [appid] [keepCount] Prune changelogs older than retention threshold
3703
+ apps [list] List all applications
3704
+ apps add <appid> Register a new application
3705
+ apps rm <appid> Delete an application and its data
3706
+ tables [list] <appid> List tables for an application
3707
+ tables add <appid> <table1> [table2...] Add one or more tables to an application
3708
+ tables rm <appid> <table1> [table2...] Remove table(s) from an application
3709
+ users [list] List all registered user accounts
3710
+ users add <username> <password> Register a new user account
3711
+ users rm <userid> Delete a user account and associated data
3712
+
3713
+ Server Options (for 'serve'):
3714
+ --port=<number>, -p <number> Port number to bind (default: 8080 or PORT env)
3715
+ --host=<string>, -H <string> Host address to bind (default: 0.0.0.0 or HOST env)
3716
+
3717
+ Backend Options (for all commands):
3718
+ --memory Run in-memory without disk persistence (default)
3719
+ --file[=<dir>] Use filesystem directory for auth and data (default: .data)
3720
+ --sqlite[=<dir>] Use SQLite databases for auth and data (default: .data)
3721
+ --help, -h Show this help message
3722
+ `);
3723
+ }
3724
+
3725
+ // src/cli/commands/maintenance.ts
3726
+ async function handleMaintenanceCommand(storage, positionalArgs) {
3727
+ const action = positionalArgs[1];
3728
+ const appId = positionalArgs[2];
3729
+ if (!action) {
3730
+ throw new TetherServerError(
3731
+ 0 /* InvalidInput */,
3732
+ 'Missing maintenance action. Expected "checkpoint", "vacuum", or "prune"'
3733
+ );
3734
+ }
3735
+ switch (action) {
3736
+ case "checkpoint": {
3737
+ const result = await storage.checkpoint(appId);
3738
+ console.log(result.message);
3739
+ break;
3740
+ }
3741
+ case "vacuum": {
3742
+ const result = await storage.vacuum(appId);
3743
+ console.log(result.message);
3744
+ break;
3745
+ }
3746
+ case "prune": {
3747
+ const keepStr = positionalArgs[3];
3748
+ const keepCount = keepStr ? Number.parseInt(keepStr, 10) : void 0;
3749
+ const result = await storage.prune(appId, keepCount);
3750
+ console.log(result.message);
3751
+ break;
3752
+ }
3753
+ default:
3754
+ throw new TetherServerError(
3755
+ 0 /* InvalidInput */,
3756
+ `Unknown maintenance action: "${action}". Expected "checkpoint", "vacuum", or "prune"`
3757
+ );
3758
+ }
3759
+ }
3760
+
3761
+ // src/cli/commands/serve.ts
3762
+ var path7 = __toESM(require("path"), 1);
3763
+ async function handleServeCommand(storage, backend, dir, port, host) {
3764
+ const running = await startServer({ port, host, storage });
3765
+ const hostLabel = running.host === "0.0.0.0" ? "localhost" : running.host;
3766
+ const storageInfo = backend === "memory" ? "in-memory (ephemeral)" : `${backend} (${path7.resolve(dir)})`;
3767
+ console.log(
3768
+ `TetherDB server listening at: http://${hostLabel}:${running.port}${running.server.basePath}`
3769
+ );
3770
+ console.log(
3771
+ `WebSocket sync endpoint: ws://${hostLabel}:${running.port}${running.server.webSocketPath}`
3772
+ );
3773
+ console.log(`Storage backend: ${storageInfo}`);
3774
+ const shutdown = async () => {
3775
+ console.log("Stopping TetherDB server...");
3776
+ try {
3777
+ await running.close();
3778
+ await storage.close?.();
3779
+ } finally {
3780
+ process.exit(0);
3781
+ }
3782
+ };
3783
+ process.once("SIGINT", shutdown);
3784
+ process.once("SIGTERM", shutdown);
3785
+ return running;
3786
+ }
3787
+
3788
+ // src/cli/commands/status.ts
3789
+ async function handleStatusCommand(storage, positionalArgs) {
3790
+ const appId = positionalArgs[1];
3791
+ const status = await storage.getStatus(appId);
3792
+ console.log("TetherDB Storage Status:");
3793
+ console.log(` Backend: ${status.backend}`);
3794
+ if (status.baseDir) {
3795
+ console.log(` Directory: ${status.baseDir}`);
3796
+ }
3797
+ if (status.baseDir && status.backend !== "memory") {
3798
+ const lock = readServerLock(status.baseDir);
3799
+ if (lock) {
3800
+ console.log(
3801
+ ` Server: Running (PID: ${lock.pid}, Port: ${lock.port}, Host: ${lock.host})`
3802
+ );
3803
+ } else {
3804
+ console.log(" Server: Stopped");
3805
+ }
3806
+ }
3807
+ console.log(` Users: ${status.usersCount}`);
3808
+ console.log(` Total Apps: ${status.appsCount}`);
3809
+ if (status.apps && status.apps.length > 0) {
3810
+ console.log("\nApplications:");
3811
+ for (const app of status.apps) {
3812
+ console.log(` - App: ${app.id}`);
3813
+ console.log(
3814
+ ` Tables (${app.tables.length}): ${app.tables.length > 0 ? app.tables.join(", ") : "(none)"}`
3815
+ );
3816
+ }
3817
+ } else if (appId) {
3818
+ console.log(`
3819
+ No tables found for application "${appId}".`);
3820
+ }
3821
+ }
3822
+
3823
+ // src/cli/commands/tables.ts
3824
+ async function handleTablesCommand(storage, args) {
3825
+ const action = args[1] ?? "list";
3826
+ if (action === "add" || action === "rm") {
3827
+ const appId = args[2];
3828
+ if (!appId) {
3829
+ throw new TetherServerError(
3830
+ 6 /* ConfigurationError */,
3831
+ "Missing application ID"
3832
+ );
3833
+ }
3834
+ const tableNames = args.slice(3);
3835
+ if (!tableNames.length) {
3836
+ throw new TetherServerError(
3837
+ 6 /* ConfigurationError */,
3838
+ "Missing table name"
3839
+ );
3840
+ }
3841
+ const app = await storage.getApp(appId);
3842
+ if (!app) {
3843
+ throw new TetherServerError(
3844
+ 1 /* NotFound */,
3845
+ `Application "${appId}" not found`
3846
+ );
3847
+ }
3848
+ for (const tableName of tableNames) {
3849
+ if (action === "add") {
3850
+ if (await app.getTable(tableName)) {
3851
+ console.log(
3852
+ `Table "${tableName}" already exists in application "${appId}"`
3853
+ );
3854
+ } else {
3855
+ await app.createTable(tableName);
3856
+ console.log(`Added table "${tableName}" to application "${appId}"`);
3857
+ }
3858
+ } else {
3859
+ const table = await app.getTable(tableName);
3860
+ if (!table) {
3861
+ console.log(
3862
+ `Table "${tableName}" not found in application "${appId}"`
3863
+ );
3864
+ } else {
3865
+ await table.delete();
3866
+ console.log(
3867
+ `Removed table "${tableName}" from application "${appId}"`
3868
+ );
3869
+ }
3870
+ }
3871
+ }
3872
+ } else {
3873
+ const appId = action === "list" ? args[2] : action;
3874
+ if (!appId) {
3875
+ throw new TetherServerError(
3876
+ 6 /* ConfigurationError */,
3877
+ "Missing application ID"
3878
+ );
3879
+ }
3880
+ const app = await storage.getApp(appId);
3881
+ if (!app) {
3882
+ throw new TetherServerError(
3883
+ 1 /* NotFound */,
3884
+ `Application "${appId}" not found`
3885
+ );
3886
+ }
3887
+ const tables = await app.getTables();
3888
+ if (!tables.length) {
3889
+ console.log(`No tables found for application "${appId}".`);
3890
+ } else {
3891
+ console.log(`Tables for application "${appId}" (${tables.length}):`);
3892
+ for (const t of tables) console.log(` \u2022 ${t.name}`);
3893
+ }
3894
+ }
3895
+ }
3896
+
3897
+ // src/cli/commands/users.ts
3898
+ async function handleUsersCommand(storage, [, action = "list", arg1, arg2]) {
3899
+ if (action === "list") {
3900
+ const users = await storage.getUsers();
3901
+ if (!users.length) return console.log("No registered users found.");
3902
+ console.log(`Registered users (${users.length}):`);
3903
+ for (const u of users) {
3904
+ console.log(
3905
+ ` \u2022 [${u.id}] ${u.username} (created: ${new Date(u.createdAt).toISOString()})`
3906
+ );
3907
+ }
3908
+ } else if (action === "add") {
3909
+ if (!arg1) {
3910
+ throw new TetherServerError(
3911
+ 6 /* ConfigurationError */,
3912
+ "Missing username"
3913
+ );
3914
+ }
3915
+ if (!arg2) {
3916
+ throw new TetherServerError(
3917
+ 6 /* ConfigurationError */,
3918
+ "Missing password"
3919
+ );
3920
+ }
3921
+ const user = await storage.createUser(arg1, arg2);
3922
+ console.log(`Created user: [${user.id}] ${user.username}`);
3923
+ } else if (action === "rm") {
3924
+ if (!arg1) {
3925
+ throw new TetherServerError(
3926
+ 6 /* ConfigurationError */,
3927
+ "Missing user ID"
3928
+ );
3929
+ }
3930
+ const user = await storage.getUser(arg1);
3931
+ if (!user) return console.log(`User not found: ${arg1}`);
3932
+ await user.delete();
3933
+ console.log(`Deleted user: ${arg1}`);
3934
+ } else {
3935
+ throw new TetherServerError(
3936
+ 6 /* ConfigurationError */,
3937
+ `Unknown users action: "${action}"`
3938
+ );
3939
+ }
3940
+ }
3941
+
3942
+ // src/cli/cli.ts
3943
+ async function runCli(args = process.argv.slice(2)) {
3944
+ if (isHelpRequested(args)) {
3945
+ printHelp();
3946
+ return;
3947
+ }
3948
+ let storage;
3949
+ try {
3950
+ const { command, positionalArgs, port, host, backend, dir } = parseCliArgs(args);
3951
+ storage = createBackend(backend, dir);
3952
+ switch (command) {
3953
+ case "serve":
3954
+ await handleServeCommand(storage, backend, dir, port, host);
3955
+ break;
3956
+ case "status":
3957
+ await handleStatusCommand(storage, positionalArgs);
3958
+ break;
3959
+ case "maintenance":
3960
+ await handleMaintenanceCommand(storage, positionalArgs);
3961
+ break;
3962
+ case "apps":
3963
+ await handleAppsCommand(storage, positionalArgs);
3964
+ break;
3965
+ case "tables":
3966
+ await handleTablesCommand(storage, positionalArgs);
3967
+ break;
3968
+ case "users":
3969
+ await handleUsersCommand(storage, positionalArgs);
3970
+ break;
3971
+ default:
3972
+ throw new TetherServerError(
3973
+ 6 /* ConfigurationError */,
3974
+ `Unknown command: "${command}"`
3975
+ );
3976
+ }
3977
+ await storage.close?.();
3978
+ } catch (err) {
3979
+ console.error("Command failed:", err.message);
3980
+ await storage?.close?.();
3981
+ process.exit(1);
3982
+ }
3983
+ }
3984
+ if (process.argv[1] && (0, import_node_url.fileURLToPath)(importMetaUrl) === path8.resolve(process.argv[1])) {
3985
+ runCli().catch((err) => {
3986
+ console.error("Failed to start TetherDB CLI:", err);
3987
+ process.exit(1);
3988
+ });
3989
+ }
3990
+ function isHelpRequested(args) {
3991
+ return args.some(
3992
+ (arg) => arg === "help" || arg === "--help" || arg === "-h" || arg === "-?" || arg === "/?" || arg === "-help"
3993
+ );
3994
+ }
3995
+ // Annotate the CommonJS export names for ESM import in node:
3996
+ 0 && (module.exports = {
3997
+ runCli
3998
+ });
3999
+ //# sourceMappingURL=index.cjs.map