@delali/sirannon-db 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +655 -80
- package/dist/backup-scheduler/index.d.ts +3 -0
- package/dist/backup-scheduler/index.mjs +2 -0
- package/dist/change-tracker-CFTQ9TSn.d.ts +89 -0
- package/dist/chunk-3MCMONVP.mjs +115 -0
- package/dist/chunk-74UN4DIE.mjs +14 -0
- package/dist/chunk-ER7ODTDA.mjs +23 -0
- package/dist/chunk-FB2U2Q3Y.mjs +21 -0
- package/dist/chunk-GS7T5YMI.mjs +51 -0
- package/dist/chunk-O7BHI3CF.mjs +90 -0
- package/dist/chunk-PXKAKK2V.mjs +124 -0
- package/dist/chunk-UTO3ZAFS.mjs +514 -0
- package/dist/chunk-UVMVN3OT.mjs +111 -0
- package/dist/client/index.d.ts +137 -44
- package/dist/client/index.mjs +726 -26
- package/dist/core/index.d.ts +32 -241
- package/dist/core/index.mjs +294 -568
- package/dist/database-BVY1GqE7.d.ts +95 -0
- package/dist/driver/better-sqlite3.d.ts +8 -0
- package/dist/driver/better-sqlite3.mjs +63 -0
- package/dist/driver/bun.mjs +61 -0
- package/dist/driver/expo.mjs +55 -0
- package/dist/driver/node.d.ts +8 -0
- package/dist/driver/node.mjs +60 -0
- package/dist/driver/wa-sqlite.d.ts +34 -0
- package/dist/driver/wa-sqlite.mjs +141 -0
- package/dist/errors-C00ed08Q.d.ts +101 -0
- package/dist/file-migrations/index.d.ts +16 -0
- package/dist/file-migrations/index.mjs +128 -0
- package/dist/index-CLdNrcPz.d.ts +16 -0
- package/dist/replication/coordinator/etcd.d.ts +44 -0
- package/dist/replication/coordinator/etcd.mjs +650 -0
- package/dist/replication/index.d.ts +491 -0
- package/dist/replication/index.mjs +3784 -0
- package/dist/server/index.d.ts +121 -54
- package/dist/server/index.mjs +347 -114
- package/dist/sirannon-Cd-lK6T0.d.ts +31 -0
- package/dist/transport/grpc.d.ts +316 -0
- package/dist/transport/grpc.mjs +3341 -0
- package/dist/transport/memory.d.ts +221 -0
- package/dist/transport/memory.mjs +337 -0
- package/dist/types-B2byqt0B.d.ts +273 -0
- package/dist/types-BEu1I_9_.d.ts +139 -0
- package/dist/types-BFSsG77t.d.ts +29 -0
- package/dist/types-BeozgNPr.d.ts +26 -0
- package/dist/{types-DArCObcu.d.ts → types-D-74JiXb.d.ts} +80 -1
- package/dist/vfs-INWQ5DTE.mjs +2 -0
- package/package.json +106 -11
- package/dist/chunk-VI4UP4RR.mjs +0 -417
- package/dist/protocol-BX1H-_Mz.d.ts +0 -104
- package/dist/sirannon-BJ8Yd1Uf.d.ts +0 -148
package/dist/server/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ChangeTracker, SubscriptionManager
|
|
1
|
+
import { ChangeTracker, SubscriptionManager } from '../chunk-UTO3ZAFS.mjs';
|
|
2
|
+
import '../chunk-GS7T5YMI.mjs';
|
|
3
|
+
import { SirannonError } from '../chunk-O7BHI3CF.mjs';
|
|
2
4
|
import uWS from 'uWebSockets.js';
|
|
3
|
-
import SqliteDatabase from 'better-sqlite3';
|
|
4
5
|
|
|
5
6
|
// src/server/protocol.ts
|
|
6
7
|
function toExecuteResponse(result) {
|
|
@@ -19,7 +20,7 @@ function handleLiveness() {
|
|
|
19
20
|
});
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
function handleReadiness(sirannon) {
|
|
23
|
+
function handleReadiness(sirannon, getReplicationStatus) {
|
|
23
24
|
return (res) => {
|
|
24
25
|
const dbs = sirannon.databases();
|
|
25
26
|
const databases = [];
|
|
@@ -37,12 +38,43 @@ function handleReadiness(sirannon) {
|
|
|
37
38
|
status: degraded ? "degraded" : "ok",
|
|
38
39
|
databases
|
|
39
40
|
};
|
|
41
|
+
if (getReplicationStatus) {
|
|
42
|
+
const replStatus = getReplicationStatus();
|
|
43
|
+
if (replStatus) {
|
|
44
|
+
body.replication = {
|
|
45
|
+
role: replStatus.role,
|
|
46
|
+
writeForwarding: replStatus.writeForwarding,
|
|
47
|
+
peers: replStatus.peers,
|
|
48
|
+
localSeq: replStatus.localSeq.toString(),
|
|
49
|
+
replicationGroupId: replStatus.replicationGroupId,
|
|
50
|
+
primaryTerm: replStatus.primaryTerm?.toString(),
|
|
51
|
+
currentPrimary: replStatus.currentPrimary,
|
|
52
|
+
coordinator: replStatus.coordinator,
|
|
53
|
+
controller: replStatus.controller,
|
|
54
|
+
inSyncReplicas: replStatus.inSyncReplicas,
|
|
55
|
+
laggingReplicas: replStatus.laggingReplicas,
|
|
56
|
+
syncState: replStatus.syncState,
|
|
57
|
+
readAvailability: replStatus.readAvailability,
|
|
58
|
+
writeAvailability: replStatus.writeAvailability
|
|
59
|
+
};
|
|
60
|
+
body.status = readinessStatusForReplication(replStatus, body.status);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
40
63
|
const payload = JSON.stringify(body);
|
|
41
64
|
res.cork(() => {
|
|
42
65
|
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
43
66
|
});
|
|
44
67
|
};
|
|
45
68
|
}
|
|
69
|
+
function readinessStatusForReplication(replication, current) {
|
|
70
|
+
if (replication.syncState === "syncing" || replication.syncState === "catching-up") return "syncing";
|
|
71
|
+
if (replication.controller?.state === "active" && replication.writeAvailability === "unavailable")
|
|
72
|
+
return "failing_over";
|
|
73
|
+
if (replication.readAvailability === "unavailable" && replication.writeAvailability === "unavailable")
|
|
74
|
+
return "unavailable";
|
|
75
|
+
if ((replication.laggingReplicas?.length ?? 0) > 0) return "degraded";
|
|
76
|
+
return current;
|
|
77
|
+
}
|
|
46
78
|
|
|
47
79
|
// src/server/http-handler.ts
|
|
48
80
|
function initAbortHandler(res) {
|
|
@@ -116,8 +148,8 @@ function sendJson(res, data) {
|
|
|
116
148
|
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
117
149
|
});
|
|
118
150
|
}
|
|
119
|
-
function sendError(res, status, code, message) {
|
|
120
|
-
const body = { error: { code, message } };
|
|
151
|
+
function sendError(res, status, code, message, details) {
|
|
152
|
+
const body = { error: details ? { code, message, details } : { code, message } };
|
|
121
153
|
const payload = JSON.stringify(body);
|
|
122
154
|
res.cork(() => {
|
|
123
155
|
res.writeStatus(`${status}`).writeHeader("Content-Type", "application/json").end(payload);
|
|
@@ -132,69 +164,105 @@ function httpStatusForError(err) {
|
|
|
132
164
|
case "QUERY_ERROR":
|
|
133
165
|
case "TRANSACTION_ERROR":
|
|
134
166
|
return 400;
|
|
167
|
+
case "STALE_PRIMARY":
|
|
168
|
+
case "PROTOCOL_VERSION_MISMATCH":
|
|
169
|
+
return 409;
|
|
135
170
|
case "HOOK_DENIED":
|
|
136
171
|
return 403;
|
|
137
172
|
case "DATABASE_CLOSED":
|
|
138
173
|
case "SHUTDOWN":
|
|
174
|
+
case "READ_CONCERN_ERROR":
|
|
175
|
+
case "COORDINATOR_UNAVAILABLE":
|
|
176
|
+
case "AUTHORITY_LOST":
|
|
177
|
+
case "NO_SAFE_PRIMARY":
|
|
178
|
+
case "NODE_NOT_IN_SYNC":
|
|
179
|
+
case "NODE_DRAINING":
|
|
180
|
+
case "UNSAFE_RECOVERY_REQUIRED":
|
|
139
181
|
return 503;
|
|
140
182
|
default:
|
|
141
183
|
return 500;
|
|
142
184
|
}
|
|
143
185
|
}
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
186
|
+
async function resolveExecutionTarget(res, sirannon, id, resolver) {
|
|
187
|
+
let target;
|
|
188
|
+
try {
|
|
189
|
+
target = resolver ? await resolver(id) : await sirannon.resolve(id);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof SirannonError) {
|
|
192
|
+
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
193
|
+
} else {
|
|
194
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (!target) {
|
|
147
199
|
sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${id}' not found`);
|
|
148
200
|
return null;
|
|
149
201
|
}
|
|
150
|
-
return
|
|
202
|
+
return target;
|
|
151
203
|
}
|
|
152
|
-
function handleQuery(sirannon) {
|
|
153
|
-
return (res, dbId, rawBody) => {
|
|
204
|
+
function handleQuery(sirannon, resolveTarget) {
|
|
205
|
+
return async (res, dbId, rawBody, abort) => {
|
|
154
206
|
const body = parseBody(res, rawBody);
|
|
155
207
|
if (!body) return;
|
|
156
208
|
if (!body.sql || typeof body.sql !== "string") {
|
|
157
209
|
sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
|
|
158
210
|
return;
|
|
159
211
|
}
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
212
|
+
const readConcern = parseReadConcern(res, body.readConcern);
|
|
213
|
+
if (!readConcern.ok) return;
|
|
214
|
+
const target = await resolveExecutionTarget(res, sirannon, dbId, resolveTarget);
|
|
215
|
+
if (!target) return;
|
|
162
216
|
try {
|
|
163
|
-
const rows =
|
|
217
|
+
const rows = await target.query(
|
|
218
|
+
body.sql,
|
|
219
|
+
body.params,
|
|
220
|
+
readConcern.value ? { readConcern: readConcern.value } : void 0
|
|
221
|
+
);
|
|
222
|
+
if (abort.aborted) return;
|
|
164
223
|
sendJson(res, { rows });
|
|
165
224
|
} catch (err) {
|
|
225
|
+
if (abort.aborted) return;
|
|
166
226
|
if (err instanceof SirannonError) {
|
|
167
|
-
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
227
|
+
sendError(res, httpStatusForError(err), err.code, err.message, errorDetails(err));
|
|
168
228
|
} else {
|
|
169
229
|
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
170
230
|
}
|
|
171
231
|
}
|
|
172
232
|
};
|
|
173
233
|
}
|
|
174
|
-
function handleExecute(sirannon) {
|
|
175
|
-
return (res, dbId, rawBody) => {
|
|
234
|
+
function handleExecute(sirannon, resolveTarget) {
|
|
235
|
+
return async (res, dbId, rawBody, abort) => {
|
|
176
236
|
const body = parseBody(res, rawBody);
|
|
177
237
|
if (!body) return;
|
|
178
238
|
if (!body.sql || typeof body.sql !== "string") {
|
|
179
239
|
sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
|
|
180
240
|
return;
|
|
181
241
|
}
|
|
182
|
-
const
|
|
183
|
-
if (!
|
|
242
|
+
const writeConcern = parseWriteConcern(res, body.writeConcern);
|
|
243
|
+
if (!writeConcern.ok) return;
|
|
244
|
+
const target = await resolveExecutionTarget(res, sirannon, dbId, resolveTarget);
|
|
245
|
+
if (!target) return;
|
|
184
246
|
try {
|
|
185
|
-
const result =
|
|
247
|
+
const result = await target.execute(
|
|
248
|
+
body.sql,
|
|
249
|
+
body.params,
|
|
250
|
+
writeConcern.value ? { writeConcern: writeConcern.value } : void 0
|
|
251
|
+
);
|
|
252
|
+
if (abort.aborted) return;
|
|
186
253
|
sendJson(res, toExecuteResponse(result));
|
|
187
254
|
} catch (err) {
|
|
255
|
+
if (abort.aborted) return;
|
|
188
256
|
if (err instanceof SirannonError) {
|
|
189
|
-
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
257
|
+
sendError(res, httpStatusForError(err), err.code, err.message, errorDetails(err));
|
|
190
258
|
} else {
|
|
191
259
|
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
192
260
|
}
|
|
193
261
|
}
|
|
194
262
|
};
|
|
195
263
|
}
|
|
196
|
-
function handleTransaction(sirannon) {
|
|
197
|
-
return (res, dbId, rawBody) => {
|
|
264
|
+
function handleTransaction(sirannon, resolveTarget) {
|
|
265
|
+
return async (res, dbId, rawBody, abort) => {
|
|
198
266
|
const body = parseBody(res, rawBody);
|
|
199
267
|
if (!body) return;
|
|
200
268
|
if (!Array.isArray(body.statements)) {
|
|
@@ -205,6 +273,8 @@ function handleTransaction(sirannon) {
|
|
|
205
273
|
sendError(res, 400, "INVALID_REQUEST", "Transaction requires at least one statement");
|
|
206
274
|
return;
|
|
207
275
|
}
|
|
276
|
+
const writeConcern = parseWriteConcern(res, body.writeConcern);
|
|
277
|
+
if (!writeConcern.ok) return;
|
|
208
278
|
for (let i = 0; i < body.statements.length; i++) {
|
|
209
279
|
const stmt = body.statements[i];
|
|
210
280
|
if (!stmt.sql || typeof stmt.sql !== "string") {
|
|
@@ -212,50 +282,138 @@ function handleTransaction(sirannon) {
|
|
|
212
282
|
return;
|
|
213
283
|
}
|
|
214
284
|
}
|
|
215
|
-
const
|
|
216
|
-
if (!
|
|
285
|
+
const target = await resolveExecutionTarget(res, sirannon, dbId, resolveTarget);
|
|
286
|
+
if (!target) return;
|
|
217
287
|
try {
|
|
218
|
-
const results =
|
|
219
|
-
|
|
220
|
-
|
|
288
|
+
const results = await target.transaction(
|
|
289
|
+
async (tx) => {
|
|
290
|
+
const txResults = [];
|
|
291
|
+
for (const stmt of body.statements) {
|
|
292
|
+
if (abort.aborted) throw new Error("Request aborted");
|
|
293
|
+
txResults.push(await tx.execute(stmt.sql, stmt.params));
|
|
294
|
+
}
|
|
295
|
+
return txResults;
|
|
296
|
+
},
|
|
297
|
+
writeConcern.value ? { writeConcern: writeConcern.value } : void 0
|
|
298
|
+
);
|
|
299
|
+
if (abort.aborted) return;
|
|
221
300
|
sendJson(res, {
|
|
222
301
|
results: results.map(toExecuteResponse)
|
|
223
302
|
});
|
|
224
303
|
} catch (err) {
|
|
304
|
+
if (abort.aborted) return;
|
|
225
305
|
if (err instanceof SirannonError) {
|
|
226
|
-
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
306
|
+
sendError(res, httpStatusForError(err), err.code, err.message, errorDetails(err));
|
|
227
307
|
} else {
|
|
228
308
|
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
229
309
|
}
|
|
230
310
|
}
|
|
231
311
|
};
|
|
232
312
|
}
|
|
313
|
+
function parseReadConcern(res, value) {
|
|
314
|
+
if (value === void 0) return { ok: true, value: void 0 };
|
|
315
|
+
if (!isPlainRecord(value)) {
|
|
316
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "readConcern" must be an object when provided');
|
|
317
|
+
return { ok: false };
|
|
318
|
+
}
|
|
319
|
+
const keys = Object.keys(value);
|
|
320
|
+
if (keys.length !== 1 || !keys.includes("level")) {
|
|
321
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "readConcern" must contain only "level"');
|
|
322
|
+
return { ok: false };
|
|
323
|
+
}
|
|
324
|
+
if (!isReadConcernLevel(value.level)) {
|
|
325
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "readConcern.level" is invalid');
|
|
326
|
+
return { ok: false };
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, value: { level: value.level } };
|
|
329
|
+
}
|
|
330
|
+
function parseWriteConcern(res, value) {
|
|
331
|
+
if (value === void 0) return { ok: true, value: void 0 };
|
|
332
|
+
if (!isPlainRecord(value)) {
|
|
333
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern" must be an object when provided');
|
|
334
|
+
return { ok: false };
|
|
335
|
+
}
|
|
336
|
+
const allowedKeys = /* @__PURE__ */ new Set(["level", "timeoutMs"]);
|
|
337
|
+
if (!Object.keys(value).every((key) => allowedKeys.has(key))) {
|
|
338
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern" contains unsupported keys');
|
|
339
|
+
return { ok: false };
|
|
340
|
+
}
|
|
341
|
+
if (!isWriteConcernLevel(value.level)) {
|
|
342
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern.level" is invalid');
|
|
343
|
+
return { ok: false };
|
|
344
|
+
}
|
|
345
|
+
const timeoutMs = value.timeoutMs;
|
|
346
|
+
if (timeoutMs !== void 0 && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)) {
|
|
347
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern.timeoutMs" must be a positive safe integer');
|
|
348
|
+
return { ok: false };
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
value: timeoutMs === void 0 ? { level: value.level } : { level: value.level, timeoutMs }
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function isPlainRecord(value) {
|
|
356
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
357
|
+
}
|
|
358
|
+
function isReadConcernLevel(value) {
|
|
359
|
+
return value === "local" || value === "majority" || value === "linearizable";
|
|
360
|
+
}
|
|
361
|
+
function isWriteConcernLevel(value) {
|
|
362
|
+
return value === "local" || value === "majority" || value === "all";
|
|
363
|
+
}
|
|
364
|
+
function handleClusterStatus(getClusterStatus) {
|
|
365
|
+
return (res, dbId) => {
|
|
366
|
+
if (!getClusterStatus) {
|
|
367
|
+
sendError(res, 404, "NOT_FOUND", "Cluster status is not configured");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const status = getClusterStatus(dbId);
|
|
371
|
+
if (!status) {
|
|
372
|
+
sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
sendJson(res, toClusterStatusResponse(status));
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function toClusterStatusResponse(status) {
|
|
379
|
+
return {
|
|
380
|
+
...status,
|
|
381
|
+
currentPrimary: status.currentPrimary ? { ...status.currentPrimary } : status.currentPrimary,
|
|
382
|
+
readEndpoints: status.readEndpoints?.map((endpoint) => ({
|
|
383
|
+
...endpoint,
|
|
384
|
+
readConcerns: [...endpoint.readConcerns]
|
|
385
|
+
})),
|
|
386
|
+
primaryTerm: status.primaryTerm?.toString()
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function errorDetails(err) {
|
|
390
|
+
const details = err.details;
|
|
391
|
+
return details && Object.keys(details).length > 0 ? details : void 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/server/ws-handler.ts
|
|
233
395
|
var DEFAULT_POLL_INTERVAL_MS = 50;
|
|
234
396
|
var DEFAULT_MAX_PAYLOAD_LENGTH = 1048576;
|
|
235
397
|
var WSHandler = class {
|
|
236
398
|
sirannon;
|
|
237
399
|
maxPayloadLength;
|
|
400
|
+
resolveExecutionTarget;
|
|
238
401
|
connections = /* @__PURE__ */ new Map();
|
|
239
402
|
cdcContexts = /* @__PURE__ */ new Map();
|
|
403
|
+
cdcPending = /* @__PURE__ */ new Map();
|
|
240
404
|
closed = false;
|
|
241
405
|
constructor(sirannon, options) {
|
|
242
406
|
this.sirannon = sirannon;
|
|
243
407
|
this.maxPayloadLength = options?.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
|
|
408
|
+
this.resolveExecutionTarget = options?.resolveExecutionTarget;
|
|
244
409
|
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Register a new WebSocket connection for the given database.
|
|
248
|
-
*
|
|
249
|
-
* Sends an error and closes the connection if the database does not exist,
|
|
250
|
-
* is closed, or the handler itself has been shut down.
|
|
251
|
-
*/
|
|
252
|
-
handleOpen(conn, databaseId) {
|
|
410
|
+
async handleOpen(conn, databaseId) {
|
|
253
411
|
if (this.closed) {
|
|
254
412
|
this.sendError(conn, "", "HANDLER_CLOSED", "WebSocket handler is shut down");
|
|
255
413
|
conn.close(1013, "Handler shutting down");
|
|
256
414
|
return;
|
|
257
415
|
}
|
|
258
|
-
const database = this.sirannon.
|
|
416
|
+
const database = await this.sirannon.resolve(databaseId);
|
|
259
417
|
if (!database) {
|
|
260
418
|
this.sendError(conn, "", "DATABASE_NOT_FOUND", `Database '${databaseId}' not found`);
|
|
261
419
|
conn.close(1008, "Database not found");
|
|
@@ -266,18 +424,26 @@ var WSHandler = class {
|
|
|
266
424
|
conn.close(1008, "Database closed");
|
|
267
425
|
return;
|
|
268
426
|
}
|
|
427
|
+
let executionTarget;
|
|
428
|
+
try {
|
|
429
|
+
executionTarget = await this.resolveTarget(databaseId);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
this.sendSirannonError(conn, "", err);
|
|
432
|
+
conn.close(1011, "Execution target resolution failed");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!executionTarget) {
|
|
436
|
+
this.sendError(conn, "", "DATABASE_NOT_FOUND", `Database '${databaseId}' not found`);
|
|
437
|
+
conn.close(1008, "Database not found");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
269
440
|
this.connections.set(conn, {
|
|
270
441
|
databaseId,
|
|
271
442
|
database,
|
|
443
|
+
executionTarget,
|
|
272
444
|
subscriptions: /* @__PURE__ */ new Map()
|
|
273
445
|
});
|
|
274
446
|
}
|
|
275
|
-
/**
|
|
276
|
-
* Process an incoming WebSocket message.
|
|
277
|
-
*
|
|
278
|
-
* Validates the JSON payload, extracts the message type and correlation ID,
|
|
279
|
-
* and routes to the appropriate handler.
|
|
280
|
-
*/
|
|
281
447
|
handleMessage(conn, data) {
|
|
282
448
|
const state = this.connections.get(conn);
|
|
283
449
|
if (!state) return;
|
|
@@ -322,12 +488,6 @@ var WSHandler = class {
|
|
|
322
488
|
this.sendError(conn, id, "UNKNOWN_TYPE", `Unknown message type: '${msg.type}'`);
|
|
323
489
|
}
|
|
324
490
|
}
|
|
325
|
-
/**
|
|
326
|
-
* Clean up after a WebSocket connection closes.
|
|
327
|
-
*
|
|
328
|
-
* Unsubscribes all active CDC subscriptions for this connection and
|
|
329
|
-
* releases the per-database CDC context if no subscribers remain.
|
|
330
|
-
*/
|
|
331
491
|
handleClose(conn) {
|
|
332
492
|
const state = this.connections.get(conn);
|
|
333
493
|
if (!state) return;
|
|
@@ -338,17 +498,10 @@ var WSHandler = class {
|
|
|
338
498
|
this.maybeCleanupCDC(state.databaseId);
|
|
339
499
|
this.connections.delete(conn);
|
|
340
500
|
}
|
|
341
|
-
/** Number of active WebSocket connections. */
|
|
342
501
|
get connectionCount() {
|
|
343
502
|
return this.connections.size;
|
|
344
503
|
}
|
|
345
|
-
|
|
346
|
-
* Shut down the handler.
|
|
347
|
-
*
|
|
348
|
-
* Unsubscribes all CDC subscriptions, closes all WebSocket connections
|
|
349
|
-
* with code 1001 (Going Away), and releases all CDC resources.
|
|
350
|
-
*/
|
|
351
|
-
close() {
|
|
504
|
+
async close() {
|
|
352
505
|
if (this.closed) return;
|
|
353
506
|
this.closed = true;
|
|
354
507
|
for (const [conn, state] of this.connections) {
|
|
@@ -362,14 +515,13 @@ var WSHandler = class {
|
|
|
362
515
|
for (const ctx of this.cdcContexts.values()) {
|
|
363
516
|
ctx.stopPolling();
|
|
364
517
|
try {
|
|
365
|
-
ctx.
|
|
518
|
+
await ctx.cdcConn.close();
|
|
366
519
|
} catch {
|
|
367
520
|
}
|
|
368
521
|
}
|
|
369
522
|
this.cdcContexts.clear();
|
|
370
523
|
}
|
|
371
|
-
|
|
372
|
-
handleQuery(conn, state, msg, id) {
|
|
524
|
+
async handleQuery(conn, state, msg, id) {
|
|
373
525
|
if (typeof msg.sql !== "string") {
|
|
374
526
|
this.sendError(conn, id, "INVALID_MESSAGE", 'Query message requires a "sql" string field');
|
|
375
527
|
return;
|
|
@@ -380,13 +532,13 @@ var WSHandler = class {
|
|
|
380
532
|
}
|
|
381
533
|
try {
|
|
382
534
|
const params = msg.params ?? void 0;
|
|
383
|
-
const rows = state.
|
|
535
|
+
const rows = await state.executionTarget.query(msg.sql, params);
|
|
384
536
|
this.send(conn, { type: "result", id, data: { rows } });
|
|
385
537
|
} catch (err) {
|
|
386
538
|
this.sendSirannonError(conn, id, err);
|
|
387
539
|
}
|
|
388
540
|
}
|
|
389
|
-
handleExecute(conn, state, msg, id) {
|
|
541
|
+
async handleExecute(conn, state, msg, id) {
|
|
390
542
|
if (typeof msg.sql !== "string") {
|
|
391
543
|
this.sendError(conn, id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
|
|
392
544
|
return;
|
|
@@ -397,7 +549,7 @@ var WSHandler = class {
|
|
|
397
549
|
}
|
|
398
550
|
try {
|
|
399
551
|
const params = msg.params ?? void 0;
|
|
400
|
-
const result = state.
|
|
552
|
+
const result = await state.executionTarget.execute(msg.sql, params);
|
|
401
553
|
this.send(conn, {
|
|
402
554
|
type: "result",
|
|
403
555
|
id,
|
|
@@ -407,7 +559,7 @@ var WSHandler = class {
|
|
|
407
559
|
this.sendSirannonError(conn, id, err);
|
|
408
560
|
}
|
|
409
561
|
}
|
|
410
|
-
handleSubscribe(conn, state, msg, id) {
|
|
562
|
+
async handleSubscribe(conn, state, msg, id) {
|
|
411
563
|
if (typeof msg.table !== "string") {
|
|
412
564
|
this.sendError(conn, id, "INVALID_MESSAGE", 'Subscribe message requires a "table" string field');
|
|
413
565
|
return;
|
|
@@ -429,15 +581,19 @@ var WSHandler = class {
|
|
|
429
581
|
return;
|
|
430
582
|
}
|
|
431
583
|
const filter = msg.filter ?? void 0;
|
|
584
|
+
let ctx = null;
|
|
432
585
|
try {
|
|
433
|
-
|
|
434
|
-
ctx.tracker.watch(ctx.
|
|
586
|
+
ctx = await this.ensureCDC(state.databaseId, state.database);
|
|
587
|
+
await ctx.tracker.watch(ctx.cdcConn, msg.table);
|
|
435
588
|
const sub = ctx.manager.subscribe(msg.table, filter, (event) => {
|
|
436
589
|
this.sendChange(conn, id, event);
|
|
437
590
|
});
|
|
438
591
|
state.subscriptions.set(id, sub);
|
|
439
592
|
this.send(conn, { type: "subscribed", id });
|
|
440
593
|
} catch (err) {
|
|
594
|
+
if (ctx?.manager.size === 0) {
|
|
595
|
+
this.maybeCleanupCDC(state.databaseId);
|
|
596
|
+
}
|
|
441
597
|
this.sendSirannonError(conn, id, err);
|
|
442
598
|
}
|
|
443
599
|
}
|
|
@@ -452,31 +608,50 @@ var WSHandler = class {
|
|
|
452
608
|
this.send(conn, { type: "unsubscribed", id });
|
|
453
609
|
this.maybeCleanupCDC(state.databaseId);
|
|
454
610
|
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Get or create a CDC context for the given database.
|
|
458
|
-
*
|
|
459
|
-
* Opens a separate better-sqlite3 connection for trigger installation and
|
|
460
|
-
* change polling. WAL mode and a 5-second busy timeout are set so that
|
|
461
|
-
* the CDC connection coexists with the Database class's connection pool.
|
|
462
|
-
*/
|
|
463
|
-
ensureCDC(databaseId, database) {
|
|
611
|
+
async ensureCDC(databaseId, database) {
|
|
464
612
|
const existing = this.cdcContexts.get(databaseId);
|
|
465
613
|
if (existing) return existing;
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
614
|
+
const pending = this.cdcPending.get(databaseId);
|
|
615
|
+
if (pending) return pending;
|
|
616
|
+
const promise = this.createCDCContext(database);
|
|
617
|
+
this.cdcPending.set(databaseId, promise);
|
|
618
|
+
try {
|
|
619
|
+
const ctx = await promise;
|
|
620
|
+
if (this.closed) {
|
|
621
|
+
ctx.stopPolling();
|
|
622
|
+
await ctx.cdcConn.close().catch(() => {
|
|
623
|
+
});
|
|
624
|
+
throw new SirannonError("WebSocket handler is shut down", "HANDLER_CLOSED");
|
|
625
|
+
}
|
|
626
|
+
this.cdcContexts.set(databaseId, ctx);
|
|
627
|
+
return ctx;
|
|
628
|
+
} finally {
|
|
629
|
+
this.cdcPending.delete(databaseId);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async createCDCContext(database) {
|
|
633
|
+
const cdcConn = await this.sirannon.driver.open(database.path, { walMode: true });
|
|
469
634
|
const tracker = new ChangeTracker();
|
|
470
635
|
const manager = new SubscriptionManager();
|
|
636
|
+
try {
|
|
637
|
+
await tracker.advanceToLatest(cdcConn);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
await cdcConn.close().catch(() => {
|
|
640
|
+
});
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
let polling = false;
|
|
471
644
|
let consecutiveErrors = 0;
|
|
472
645
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
473
646
|
const stopPolling = () => {
|
|
474
647
|
clearInterval(interval);
|
|
475
648
|
};
|
|
476
|
-
const tick = () => {
|
|
649
|
+
const tick = async () => {
|
|
477
650
|
if (manager.size === 0) return;
|
|
651
|
+
if (polling) return;
|
|
652
|
+
polling = true;
|
|
478
653
|
try {
|
|
479
|
-
const events = tracker.poll(
|
|
654
|
+
const events = await tracker.poll(cdcConn);
|
|
480
655
|
if (events.length > 0) {
|
|
481
656
|
manager.dispatch(events);
|
|
482
657
|
}
|
|
@@ -486,35 +661,32 @@ var WSHandler = class {
|
|
|
486
661
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
487
662
|
stopPolling();
|
|
488
663
|
}
|
|
664
|
+
} finally {
|
|
665
|
+
polling = false;
|
|
489
666
|
}
|
|
490
667
|
};
|
|
491
668
|
const interval = setInterval(tick, DEFAULT_POLL_INTERVAL_MS);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return ctx;
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* Release CDC resources for a database if no subscribers remain.
|
|
499
|
-
*
|
|
500
|
-
* Stops the polling loop and closes the dedicated CDC connection.
|
|
501
|
-
*/
|
|
669
|
+
interval.unref?.();
|
|
670
|
+
return { cdcConn, tracker, manager, stopPolling };
|
|
671
|
+
}
|
|
502
672
|
maybeCleanupCDC(databaseId) {
|
|
503
673
|
const ctx = this.cdcContexts.get(databaseId);
|
|
504
674
|
if (!ctx || ctx.manager.size > 0) return;
|
|
505
675
|
ctx.stopPolling();
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
} catch {
|
|
509
|
-
}
|
|
676
|
+
ctx.cdcConn.close().catch(() => {
|
|
677
|
+
});
|
|
510
678
|
this.cdcContexts.delete(databaseId);
|
|
511
679
|
}
|
|
512
|
-
// --- Validation ---
|
|
513
680
|
isValidParams(params) {
|
|
514
681
|
if (params === void 0 || params === null) return true;
|
|
515
682
|
return typeof params === "object";
|
|
516
683
|
}
|
|
517
|
-
|
|
684
|
+
async resolveTarget(databaseId) {
|
|
685
|
+
if (!this.resolveExecutionTarget) {
|
|
686
|
+
return await this.sirannon.resolve(databaseId) ?? null;
|
|
687
|
+
}
|
|
688
|
+
return await this.resolveExecutionTarget(databaseId) ?? null;
|
|
689
|
+
}
|
|
518
690
|
send(conn, msg) {
|
|
519
691
|
try {
|
|
520
692
|
conn.send(JSON.stringify(msg));
|
|
@@ -578,6 +750,10 @@ function writeCorsOrigin(res, cors, requestOrigin) {
|
|
|
578
750
|
res.writeHeader("Vary", "Origin");
|
|
579
751
|
}
|
|
580
752
|
}
|
|
753
|
+
function selectWebSocketProtocol(header) {
|
|
754
|
+
const [firstProtocol] = header.split(",");
|
|
755
|
+
return firstProtocol?.trim() ?? "";
|
|
756
|
+
}
|
|
581
757
|
function decodeRemoteAddress(res) {
|
|
582
758
|
return Buffer.from(res.getRemoteAddressAsText()).toString();
|
|
583
759
|
}
|
|
@@ -604,6 +780,9 @@ var SirannonServer = class {
|
|
|
604
780
|
port;
|
|
605
781
|
cors;
|
|
606
782
|
onRequestHook;
|
|
783
|
+
resolveExecutionTarget;
|
|
784
|
+
getReplicationStatus;
|
|
785
|
+
getClusterStatus;
|
|
607
786
|
sirannon;
|
|
608
787
|
wsHandler;
|
|
609
788
|
constructor(sirannon, options) {
|
|
@@ -612,7 +791,10 @@ var SirannonServer = class {
|
|
|
612
791
|
this.port = options?.port ?? 9876;
|
|
613
792
|
this.cors = resolveCors(options?.cors);
|
|
614
793
|
this.onRequestHook = options?.onRequest;
|
|
615
|
-
this.
|
|
794
|
+
this.resolveExecutionTarget = options?.resolveExecutionTarget;
|
|
795
|
+
this.getReplicationStatus = options?.getReplicationStatus;
|
|
796
|
+
this.getClusterStatus = options?.getClusterStatus;
|
|
797
|
+
this.wsHandler = new WSHandler(sirannon, { resolveExecutionTarget: this.resolveExecutionTarget });
|
|
616
798
|
this.app = uWS.App();
|
|
617
799
|
this.registerRoutes();
|
|
618
800
|
}
|
|
@@ -628,15 +810,15 @@ var SirannonServer = class {
|
|
|
628
810
|
});
|
|
629
811
|
});
|
|
630
812
|
}
|
|
631
|
-
close() {
|
|
632
|
-
|
|
633
|
-
this.wsHandler.close();
|
|
813
|
+
async close() {
|
|
814
|
+
try {
|
|
815
|
+
await this.wsHandler.close();
|
|
816
|
+
} finally {
|
|
634
817
|
if (this.listenSocket) {
|
|
635
818
|
uWS.us_listen_socket_close(this.listenSocket);
|
|
636
819
|
this.listenSocket = null;
|
|
637
820
|
}
|
|
638
|
-
|
|
639
|
-
});
|
|
821
|
+
}
|
|
640
822
|
}
|
|
641
823
|
get listeningPort() {
|
|
642
824
|
if (!this.listenSocket) return -1;
|
|
@@ -655,10 +837,14 @@ var SirannonServer = class {
|
|
|
655
837
|
});
|
|
656
838
|
}
|
|
657
839
|
this.app.get("/health", this.withCors(handleLiveness()));
|
|
658
|
-
this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon)));
|
|
659
|
-
this.app.
|
|
660
|
-
this.app.post("/db/:id/
|
|
661
|
-
this.app.post("/db/:id/
|
|
840
|
+
this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon, this.getReplicationStatus)));
|
|
841
|
+
this.app.get("/db/:id/cluster", this.wrapDbGetRoute(handleClusterStatus(this.getClusterStatus)));
|
|
842
|
+
this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
|
|
843
|
+
this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
|
|
844
|
+
this.app.post(
|
|
845
|
+
"/db/:id/transaction",
|
|
846
|
+
this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget))
|
|
847
|
+
);
|
|
662
848
|
this.registerWebSocketRoute();
|
|
663
849
|
this.app.any("/*", (res) => {
|
|
664
850
|
sendError(res, 404, "NOT_FOUND", "Route not found");
|
|
@@ -677,6 +863,7 @@ var SirannonServer = class {
|
|
|
677
863
|
const method = req.getMethod();
|
|
678
864
|
const secWebSocketKey = req.getHeader("sec-websocket-key");
|
|
679
865
|
const secWebSocketProtocol = req.getHeader("sec-websocket-protocol");
|
|
866
|
+
const selectedWebSocketProtocol = selectWebSocketProtocol(secWebSocketProtocol);
|
|
680
867
|
const secWebSocketExtensions = req.getHeader("sec-websocket-extensions");
|
|
681
868
|
const headers = {};
|
|
682
869
|
req.forEach((key, value) => {
|
|
@@ -692,7 +879,7 @@ var SirannonServer = class {
|
|
|
692
879
|
res.upgrade(
|
|
693
880
|
{ databaseId: dbId },
|
|
694
881
|
secWebSocketKey,
|
|
695
|
-
|
|
882
|
+
selectedWebSocketProtocol,
|
|
696
883
|
secWebSocketExtensions,
|
|
697
884
|
context
|
|
698
885
|
);
|
|
@@ -711,7 +898,7 @@ var SirannonServer = class {
|
|
|
711
898
|
res.upgrade(
|
|
712
899
|
{ databaseId: dbId },
|
|
713
900
|
secWebSocketKey,
|
|
714
|
-
|
|
901
|
+
selectedWebSocketProtocol,
|
|
715
902
|
secWebSocketExtensions,
|
|
716
903
|
context
|
|
717
904
|
);
|
|
@@ -735,7 +922,8 @@ var SirannonServer = class {
|
|
|
735
922
|
}
|
|
736
923
|
};
|
|
737
924
|
userData.conn = conn;
|
|
738
|
-
wsHandler.handleOpen(conn, userData.databaseId)
|
|
925
|
+
wsHandler.handleOpen(conn, userData.databaseId).catch(() => {
|
|
926
|
+
});
|
|
739
927
|
},
|
|
740
928
|
message: (ws, message) => {
|
|
741
929
|
const userData = ws.getUserData();
|
|
@@ -773,9 +961,15 @@ var SirannonServer = class {
|
|
|
773
961
|
const abort = initAbortHandler(res);
|
|
774
962
|
const bodyPromise = readBody(res, MAX_BODY, abort);
|
|
775
963
|
if (!onRequestHook) {
|
|
776
|
-
bodyPromise.then((rawBody) => {
|
|
964
|
+
bodyPromise.then(async (rawBody) => {
|
|
777
965
|
if (abort.aborted) return;
|
|
778
|
-
|
|
966
|
+
try {
|
|
967
|
+
await handler(res, dbId, rawBody, abort);
|
|
968
|
+
} catch {
|
|
969
|
+
if (!abort.aborted) {
|
|
970
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
971
|
+
}
|
|
972
|
+
}
|
|
779
973
|
}).catch(() => {
|
|
780
974
|
});
|
|
781
975
|
return;
|
|
@@ -793,9 +987,48 @@ var SirannonServer = class {
|
|
|
793
987
|
remoteAddress
|
|
794
988
|
};
|
|
795
989
|
const hookPromise = runOnRequest(res, ctx, onRequestHook);
|
|
796
|
-
Promise.all([bodyPromise, hookPromise]).then(([rawBody, allowed]) => {
|
|
990
|
+
Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, allowed]) => {
|
|
797
991
|
if (abort.aborted || !allowed) return;
|
|
798
|
-
|
|
992
|
+
try {
|
|
993
|
+
await handler(res, dbId, rawBody, abort);
|
|
994
|
+
} catch {
|
|
995
|
+
if (!abort.aborted) {
|
|
996
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}).catch(() => {
|
|
1000
|
+
});
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
wrapDbGetRoute(handler) {
|
|
1004
|
+
const onRequestHook = this.onRequestHook;
|
|
1005
|
+
const corsHeaders = this.cors;
|
|
1006
|
+
return (res, req) => {
|
|
1007
|
+
const dbId = req.getParameter(0) ?? "";
|
|
1008
|
+
const method = req.getMethod();
|
|
1009
|
+
const path = req.getUrl();
|
|
1010
|
+
if (corsHeaders) {
|
|
1011
|
+
writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
|
|
1012
|
+
}
|
|
1013
|
+
if (!onRequestHook) {
|
|
1014
|
+
handler(res, dbId);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const headers = {};
|
|
1018
|
+
req.forEach((key, value) => {
|
|
1019
|
+
headers[key] = value;
|
|
1020
|
+
});
|
|
1021
|
+
const ctx = {
|
|
1022
|
+
headers,
|
|
1023
|
+
method,
|
|
1024
|
+
path,
|
|
1025
|
+
databaseId: dbId,
|
|
1026
|
+
remoteAddress: decodeRemoteAddress(res)
|
|
1027
|
+
};
|
|
1028
|
+
runOnRequest(res, ctx, onRequestHook).then((allowed) => {
|
|
1029
|
+
if (allowed) {
|
|
1030
|
+
handler(res, dbId);
|
|
1031
|
+
}
|
|
799
1032
|
}).catch(() => {
|
|
800
1033
|
});
|
|
801
1034
|
};
|