@delali/sirannon-db 0.1.1
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/LICENSE +201 -0
- package/NOTICE +14 -0
- package/README.md +418 -0
- package/dist/chunk-VI4UP4RR.mjs +417 -0
- package/dist/client/index.d.ts +223 -0
- package/dist/client/index.mjs +479 -0
- package/dist/core/index.d.ts +295 -0
- package/dist/core/index.mjs +1346 -0
- package/dist/protocol-BX1H-_Mz.d.ts +104 -0
- package/dist/server/index.d.ts +103 -0
- package/dist/server/index.mjs +808 -0
- package/dist/sirannon-BJ8Yd1Uf.d.ts +148 -0
- package/dist/types-DArCObcu.d.ts +186 -0
- package/package.json +87 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { ChangeTracker, SubscriptionManager, SirannonError } from '../chunk-VI4UP4RR.mjs';
|
|
2
|
+
import uWS from 'uWebSockets.js';
|
|
3
|
+
import SqliteDatabase from 'better-sqlite3';
|
|
4
|
+
|
|
5
|
+
// src/server/protocol.ts
|
|
6
|
+
function toExecuteResponse(result) {
|
|
7
|
+
return {
|
|
8
|
+
changes: result.changes,
|
|
9
|
+
lastInsertRowId: typeof result.lastInsertRowId === "bigint" ? result.lastInsertRowId.toString() : result.lastInsertRowId
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/server/health.ts
|
|
14
|
+
function handleLiveness() {
|
|
15
|
+
const payload = JSON.stringify({ status: "ok" });
|
|
16
|
+
return (res) => {
|
|
17
|
+
res.cork(() => {
|
|
18
|
+
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function handleReadiness(sirannon) {
|
|
23
|
+
return (res) => {
|
|
24
|
+
const dbs = sirannon.databases();
|
|
25
|
+
const databases = [];
|
|
26
|
+
let degraded = false;
|
|
27
|
+
for (const [id, db] of dbs) {
|
|
28
|
+
const entry = {
|
|
29
|
+
id,
|
|
30
|
+
readOnly: db.readOnly,
|
|
31
|
+
closed: db.closed
|
|
32
|
+
};
|
|
33
|
+
databases.push(entry);
|
|
34
|
+
if (db.closed) degraded = true;
|
|
35
|
+
}
|
|
36
|
+
const body = {
|
|
37
|
+
status: degraded ? "degraded" : "ok",
|
|
38
|
+
databases
|
|
39
|
+
};
|
|
40
|
+
const payload = JSON.stringify(body);
|
|
41
|
+
res.cork(() => {
|
|
42
|
+
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/server/http-handler.ts
|
|
48
|
+
function initAbortHandler(res) {
|
|
49
|
+
const listeners = [];
|
|
50
|
+
let aborted = false;
|
|
51
|
+
res.onAborted(() => {
|
|
52
|
+
aborted = true;
|
|
53
|
+
for (const fn of listeners) fn();
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
get aborted() {
|
|
57
|
+
return aborted;
|
|
58
|
+
},
|
|
59
|
+
onAbort(fn) {
|
|
60
|
+
if (aborted) {
|
|
61
|
+
fn();
|
|
62
|
+
} else {
|
|
63
|
+
listeners.push(fn);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function readBody(res, maxBytes, abort) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
if (abort.aborted) {
|
|
71
|
+
reject(new Error("Request aborted"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let done = false;
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let totalLength = 0;
|
|
77
|
+
abort.onAbort(() => {
|
|
78
|
+
if (done) return;
|
|
79
|
+
done = true;
|
|
80
|
+
reject(new Error("Request aborted"));
|
|
81
|
+
});
|
|
82
|
+
res.onData((chunk, isLast) => {
|
|
83
|
+
if (done || abort.aborted) return;
|
|
84
|
+
totalLength += chunk.byteLength;
|
|
85
|
+
if (totalLength > maxBytes) {
|
|
86
|
+
done = true;
|
|
87
|
+
if (!abort.aborted) {
|
|
88
|
+
sendError(res, 413, "PAYLOAD_TOO_LARGE", "Request body exceeds size limit");
|
|
89
|
+
}
|
|
90
|
+
reject(new Error("Payload too large"));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(Buffer.from(chunk));
|
|
94
|
+
if (isLast) {
|
|
95
|
+
done = true;
|
|
96
|
+
resolve(Buffer.concat(chunks));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function parseBody(res, raw) {
|
|
102
|
+
if (raw.length === 0) {
|
|
103
|
+
sendError(res, 400, "EMPTY_BODY", "Request body is empty");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw.toString("utf-8"));
|
|
108
|
+
} catch {
|
|
109
|
+
sendError(res, 400, "INVALID_JSON", "Request body is not valid JSON");
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function sendJson(res, data) {
|
|
114
|
+
const payload = JSON.stringify(data);
|
|
115
|
+
res.cork(() => {
|
|
116
|
+
res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function sendError(res, status, code, message) {
|
|
120
|
+
const body = { error: { code, message } };
|
|
121
|
+
const payload = JSON.stringify(body);
|
|
122
|
+
res.cork(() => {
|
|
123
|
+
res.writeStatus(`${status}`).writeHeader("Content-Type", "application/json").end(payload);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function httpStatusForError(err) {
|
|
127
|
+
switch (err.code) {
|
|
128
|
+
case "DATABASE_NOT_FOUND":
|
|
129
|
+
return 404;
|
|
130
|
+
case "READ_ONLY":
|
|
131
|
+
return 403;
|
|
132
|
+
case "QUERY_ERROR":
|
|
133
|
+
case "TRANSACTION_ERROR":
|
|
134
|
+
return 400;
|
|
135
|
+
case "HOOK_DENIED":
|
|
136
|
+
return 403;
|
|
137
|
+
case "DATABASE_CLOSED":
|
|
138
|
+
case "SHUTDOWN":
|
|
139
|
+
return 503;
|
|
140
|
+
default:
|
|
141
|
+
return 500;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function resolveDatabase(res, sirannon, id) {
|
|
145
|
+
const db = sirannon.get(id);
|
|
146
|
+
if (!db) {
|
|
147
|
+
sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${id}' not found`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return db;
|
|
151
|
+
}
|
|
152
|
+
function handleQuery(sirannon) {
|
|
153
|
+
return (res, dbId, rawBody) => {
|
|
154
|
+
const body = parseBody(res, rawBody);
|
|
155
|
+
if (!body) return;
|
|
156
|
+
if (!body.sql || typeof body.sql !== "string") {
|
|
157
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const db = resolveDatabase(res, sirannon, dbId);
|
|
161
|
+
if (!db) return;
|
|
162
|
+
try {
|
|
163
|
+
const rows = db.query(body.sql, body.params);
|
|
164
|
+
sendJson(res, { rows });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err instanceof SirannonError) {
|
|
167
|
+
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
168
|
+
} else {
|
|
169
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function handleExecute(sirannon) {
|
|
175
|
+
return (res, dbId, rawBody) => {
|
|
176
|
+
const body = parseBody(res, rawBody);
|
|
177
|
+
if (!body) return;
|
|
178
|
+
if (!body.sql || typeof body.sql !== "string") {
|
|
179
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const db = resolveDatabase(res, sirannon, dbId);
|
|
183
|
+
if (!db) return;
|
|
184
|
+
try {
|
|
185
|
+
const result = db.execute(body.sql, body.params);
|
|
186
|
+
sendJson(res, toExecuteResponse(result));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err instanceof SirannonError) {
|
|
189
|
+
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
190
|
+
} else {
|
|
191
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function handleTransaction(sirannon) {
|
|
197
|
+
return (res, dbId, rawBody) => {
|
|
198
|
+
const body = parseBody(res, rawBody);
|
|
199
|
+
if (!body) return;
|
|
200
|
+
if (!Array.isArray(body.statements)) {
|
|
201
|
+
sendError(res, 400, "INVALID_REQUEST", 'Field "statements" is required and must be an array');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (body.statements.length === 0) {
|
|
205
|
+
sendError(res, 400, "INVALID_REQUEST", "Transaction requires at least one statement");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (let i = 0; i < body.statements.length; i++) {
|
|
209
|
+
const stmt = body.statements[i];
|
|
210
|
+
if (!stmt.sql || typeof stmt.sql !== "string") {
|
|
211
|
+
sendError(res, 400, "INVALID_REQUEST", `Statement at index ${i} is missing a valid "sql" field`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const db = resolveDatabase(res, sirannon, dbId);
|
|
216
|
+
if (!db) return;
|
|
217
|
+
try {
|
|
218
|
+
const results = db.transaction((tx) => {
|
|
219
|
+
return body.statements.map((stmt) => tx.execute(stmt.sql, stmt.params));
|
|
220
|
+
});
|
|
221
|
+
sendJson(res, {
|
|
222
|
+
results: results.map(toExecuteResponse)
|
|
223
|
+
});
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err instanceof SirannonError) {
|
|
226
|
+
sendError(res, httpStatusForError(err), err.code, err.message);
|
|
227
|
+
} else {
|
|
228
|
+
sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
var DEFAULT_POLL_INTERVAL_MS = 50;
|
|
234
|
+
var DEFAULT_MAX_PAYLOAD_LENGTH = 1048576;
|
|
235
|
+
var WSHandler = class {
|
|
236
|
+
sirannon;
|
|
237
|
+
maxPayloadLength;
|
|
238
|
+
connections = /* @__PURE__ */ new Map();
|
|
239
|
+
cdcContexts = /* @__PURE__ */ new Map();
|
|
240
|
+
closed = false;
|
|
241
|
+
constructor(sirannon, options) {
|
|
242
|
+
this.sirannon = sirannon;
|
|
243
|
+
this.maxPayloadLength = options?.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
|
|
244
|
+
}
|
|
245
|
+
// --- Public API ---
|
|
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) {
|
|
253
|
+
if (this.closed) {
|
|
254
|
+
this.sendError(conn, "", "HANDLER_CLOSED", "WebSocket handler is shut down");
|
|
255
|
+
conn.close(1013, "Handler shutting down");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const database = this.sirannon.get(databaseId);
|
|
259
|
+
if (!database) {
|
|
260
|
+
this.sendError(conn, "", "DATABASE_NOT_FOUND", `Database '${databaseId}' not found`);
|
|
261
|
+
conn.close(1008, "Database not found");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (database.closed) {
|
|
265
|
+
this.sendError(conn, "", "DATABASE_CLOSED", `Database '${databaseId}' is closed`);
|
|
266
|
+
conn.close(1008, "Database closed");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.connections.set(conn, {
|
|
270
|
+
databaseId,
|
|
271
|
+
database,
|
|
272
|
+
subscriptions: /* @__PURE__ */ new Map()
|
|
273
|
+
});
|
|
274
|
+
}
|
|
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
|
+
handleMessage(conn, data) {
|
|
282
|
+
const state = this.connections.get(conn);
|
|
283
|
+
if (!state) return;
|
|
284
|
+
if (data.length > this.maxPayloadLength) {
|
|
285
|
+
this.sendError(conn, "", "PAYLOAD_TOO_LARGE", "Message exceeds maximum payload length");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
let msg;
|
|
289
|
+
try {
|
|
290
|
+
msg = JSON.parse(data);
|
|
291
|
+
} catch {
|
|
292
|
+
this.sendError(conn, "", "INVALID_JSON", "Failed to parse message as JSON");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
|
|
296
|
+
this.sendError(conn, "", "INVALID_MESSAGE", "Message must be a JSON object");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (typeof msg.type !== "string") {
|
|
300
|
+
this.sendError(conn, "", "INVALID_MESSAGE", 'Message must have a string "type" field');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (typeof msg.id !== "string") {
|
|
304
|
+
this.sendError(conn, "", "INVALID_MESSAGE", 'Message must have a string "id" field');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const id = msg.id;
|
|
308
|
+
switch (msg.type) {
|
|
309
|
+
case "query":
|
|
310
|
+
this.handleQuery(conn, state, msg, id);
|
|
311
|
+
break;
|
|
312
|
+
case "execute":
|
|
313
|
+
this.handleExecute(conn, state, msg, id);
|
|
314
|
+
break;
|
|
315
|
+
case "subscribe":
|
|
316
|
+
this.handleSubscribe(conn, state, msg, id);
|
|
317
|
+
break;
|
|
318
|
+
case "unsubscribe":
|
|
319
|
+
this.handleUnsubscribe(conn, state, id);
|
|
320
|
+
break;
|
|
321
|
+
default:
|
|
322
|
+
this.sendError(conn, id, "UNKNOWN_TYPE", `Unknown message type: '${msg.type}'`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
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
|
+
handleClose(conn) {
|
|
332
|
+
const state = this.connections.get(conn);
|
|
333
|
+
if (!state) return;
|
|
334
|
+
for (const sub of state.subscriptions.values()) {
|
|
335
|
+
sub.unsubscribe();
|
|
336
|
+
}
|
|
337
|
+
state.subscriptions.clear();
|
|
338
|
+
this.maybeCleanupCDC(state.databaseId);
|
|
339
|
+
this.connections.delete(conn);
|
|
340
|
+
}
|
|
341
|
+
/** Number of active WebSocket connections. */
|
|
342
|
+
get connectionCount() {
|
|
343
|
+
return this.connections.size;
|
|
344
|
+
}
|
|
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() {
|
|
352
|
+
if (this.closed) return;
|
|
353
|
+
this.closed = true;
|
|
354
|
+
for (const [conn, state] of this.connections) {
|
|
355
|
+
for (const sub of state.subscriptions.values()) {
|
|
356
|
+
sub.unsubscribe();
|
|
357
|
+
}
|
|
358
|
+
state.subscriptions.clear();
|
|
359
|
+
conn.close(1001, "Handler shutting down");
|
|
360
|
+
}
|
|
361
|
+
this.connections.clear();
|
|
362
|
+
for (const ctx of this.cdcContexts.values()) {
|
|
363
|
+
ctx.stopPolling();
|
|
364
|
+
try {
|
|
365
|
+
ctx.cdcDb.close();
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
this.cdcContexts.clear();
|
|
370
|
+
}
|
|
371
|
+
// --- Private message handlers ---
|
|
372
|
+
handleQuery(conn, state, msg, id) {
|
|
373
|
+
if (typeof msg.sql !== "string") {
|
|
374
|
+
this.sendError(conn, id, "INVALID_MESSAGE", 'Query message requires a "sql" string field');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (!this.isValidParams(msg.params)) {
|
|
378
|
+
this.sendError(conn, id, "INVALID_MESSAGE", '"params" must be an object or array');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const params = msg.params ?? void 0;
|
|
383
|
+
const rows = state.database.query(msg.sql, params);
|
|
384
|
+
this.send(conn, { type: "result", id, data: { rows } });
|
|
385
|
+
} catch (err) {
|
|
386
|
+
this.sendSirannonError(conn, id, err);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
handleExecute(conn, state, msg, id) {
|
|
390
|
+
if (typeof msg.sql !== "string") {
|
|
391
|
+
this.sendError(conn, id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!this.isValidParams(msg.params)) {
|
|
395
|
+
this.sendError(conn, id, "INVALID_MESSAGE", '"params" must be an object or array');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const params = msg.params ?? void 0;
|
|
400
|
+
const result = state.database.execute(msg.sql, params);
|
|
401
|
+
this.send(conn, {
|
|
402
|
+
type: "result",
|
|
403
|
+
id,
|
|
404
|
+
data: toExecuteResponse(result)
|
|
405
|
+
});
|
|
406
|
+
} catch (err) {
|
|
407
|
+
this.sendSirannonError(conn, id, err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
handleSubscribe(conn, state, msg, id) {
|
|
411
|
+
if (typeof msg.table !== "string") {
|
|
412
|
+
this.sendError(conn, id, "INVALID_MESSAGE", 'Subscribe message requires a "table" string field');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (state.subscriptions.has(id)) {
|
|
416
|
+
this.sendError(conn, id, "DUPLICATE_SUBSCRIPTION", `Subscription '${id}' already exists on this connection`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (state.database.readOnly) {
|
|
420
|
+
this.sendError(conn, id, "READ_ONLY", "Subscriptions are not available on read-only databases");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (state.database.path === ":memory:") {
|
|
424
|
+
this.sendError(conn, id, "CDC_UNSUPPORTED", "CDC subscriptions require file-based databases");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (msg.filter !== void 0 && msg.filter !== null && (typeof msg.filter !== "object" || Array.isArray(msg.filter))) {
|
|
428
|
+
this.sendError(conn, id, "INVALID_MESSAGE", '"filter" must be a plain object');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const filter = msg.filter ?? void 0;
|
|
432
|
+
try {
|
|
433
|
+
const ctx = this.ensureCDC(state.databaseId, state.database);
|
|
434
|
+
ctx.tracker.watch(ctx.cdcDb, msg.table);
|
|
435
|
+
const sub = ctx.manager.subscribe(msg.table, filter, (event) => {
|
|
436
|
+
this.sendChange(conn, id, event);
|
|
437
|
+
});
|
|
438
|
+
state.subscriptions.set(id, sub);
|
|
439
|
+
this.send(conn, { type: "subscribed", id });
|
|
440
|
+
} catch (err) {
|
|
441
|
+
this.sendSirannonError(conn, id, err);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
handleUnsubscribe(conn, state, id) {
|
|
445
|
+
const sub = state.subscriptions.get(id);
|
|
446
|
+
if (!sub) {
|
|
447
|
+
this.sendError(conn, id, "SUBSCRIPTION_NOT_FOUND", `Subscription '${id}' not found`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
sub.unsubscribe();
|
|
451
|
+
state.subscriptions.delete(id);
|
|
452
|
+
this.send(conn, { type: "unsubscribed", id });
|
|
453
|
+
this.maybeCleanupCDC(state.databaseId);
|
|
454
|
+
}
|
|
455
|
+
// --- CDC management ---
|
|
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) {
|
|
464
|
+
const existing = this.cdcContexts.get(databaseId);
|
|
465
|
+
if (existing) return existing;
|
|
466
|
+
const cdcDb = new SqliteDatabase(database.path);
|
|
467
|
+
cdcDb.pragma("journal_mode = WAL");
|
|
468
|
+
cdcDb.pragma("busy_timeout = 5000");
|
|
469
|
+
const tracker = new ChangeTracker();
|
|
470
|
+
const manager = new SubscriptionManager();
|
|
471
|
+
let consecutiveErrors = 0;
|
|
472
|
+
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
473
|
+
const stopPolling = () => {
|
|
474
|
+
clearInterval(interval);
|
|
475
|
+
};
|
|
476
|
+
const tick = () => {
|
|
477
|
+
if (manager.size === 0) return;
|
|
478
|
+
try {
|
|
479
|
+
const events = tracker.poll(cdcDb);
|
|
480
|
+
if (events.length > 0) {
|
|
481
|
+
manager.dispatch(events);
|
|
482
|
+
}
|
|
483
|
+
consecutiveErrors = 0;
|
|
484
|
+
} catch {
|
|
485
|
+
consecutiveErrors++;
|
|
486
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
487
|
+
stopPolling();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const interval = setInterval(tick, DEFAULT_POLL_INTERVAL_MS);
|
|
492
|
+
if (typeof interval.unref === "function") interval.unref();
|
|
493
|
+
const ctx = { cdcDb, tracker, manager, stopPolling };
|
|
494
|
+
this.cdcContexts.set(databaseId, ctx);
|
|
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
|
+
*/
|
|
502
|
+
maybeCleanupCDC(databaseId) {
|
|
503
|
+
const ctx = this.cdcContexts.get(databaseId);
|
|
504
|
+
if (!ctx || ctx.manager.size > 0) return;
|
|
505
|
+
ctx.stopPolling();
|
|
506
|
+
try {
|
|
507
|
+
ctx.cdcDb.close();
|
|
508
|
+
} catch {
|
|
509
|
+
}
|
|
510
|
+
this.cdcContexts.delete(databaseId);
|
|
511
|
+
}
|
|
512
|
+
// --- Validation ---
|
|
513
|
+
isValidParams(params) {
|
|
514
|
+
if (params === void 0 || params === null) return true;
|
|
515
|
+
return typeof params === "object";
|
|
516
|
+
}
|
|
517
|
+
// --- Send helpers ---
|
|
518
|
+
send(conn, msg) {
|
|
519
|
+
try {
|
|
520
|
+
conn.send(JSON.stringify(msg));
|
|
521
|
+
} catch {
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
sendError(conn, id, code, message) {
|
|
525
|
+
this.send(conn, { type: "error", id, error: { code, message } });
|
|
526
|
+
}
|
|
527
|
+
sendSirannonError(conn, id, err) {
|
|
528
|
+
const code = err instanceof SirannonError ? err.code : "INTERNAL_ERROR";
|
|
529
|
+
const message = err instanceof SirannonError ? err.message : "An unexpected error occurred";
|
|
530
|
+
this.sendError(conn, id, code, message);
|
|
531
|
+
}
|
|
532
|
+
sendChange(conn, subscriptionId, event) {
|
|
533
|
+
this.send(conn, {
|
|
534
|
+
type: "change",
|
|
535
|
+
id: subscriptionId,
|
|
536
|
+
event: {
|
|
537
|
+
type: event.type,
|
|
538
|
+
table: event.table,
|
|
539
|
+
row: event.row,
|
|
540
|
+
oldRow: event.oldRow,
|
|
541
|
+
seq: event.seq.toString(),
|
|
542
|
+
timestamp: event.timestamp
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
function createWSHandler(sirannon, options) {
|
|
548
|
+
return new WSHandler(sirannon, options);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/server/server.ts
|
|
552
|
+
function resolveCors(cors) {
|
|
553
|
+
if (!cors) return null;
|
|
554
|
+
if (cors === true) {
|
|
555
|
+
return {
|
|
556
|
+
origin: "*",
|
|
557
|
+
methods: "GET, POST, OPTIONS",
|
|
558
|
+
headers: "Content-Type, Authorization"
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
origin: cors.origin ?? "*",
|
|
563
|
+
methods: cors.methods?.join(", ") ?? "GET, POST, OPTIONS",
|
|
564
|
+
headers: cors.headers?.join(", ") ?? "Content-Type, Authorization"
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function matchOrigin(cors, requestOrigin) {
|
|
568
|
+
if (cors.origin === "*") return "*";
|
|
569
|
+
if (typeof cors.origin === "string") return cors.origin;
|
|
570
|
+
if (cors.origin.includes(requestOrigin)) return requestOrigin;
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
function writeCorsOrigin(res, cors, requestOrigin) {
|
|
574
|
+
const allowed = matchOrigin(cors, requestOrigin);
|
|
575
|
+
if (!allowed) return;
|
|
576
|
+
res.writeHeader("Access-Control-Allow-Origin", allowed);
|
|
577
|
+
if (allowed !== "*") {
|
|
578
|
+
res.writeHeader("Vary", "Origin");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function decodeRemoteAddress(res) {
|
|
582
|
+
return Buffer.from(res.getRemoteAddressAsText()).toString();
|
|
583
|
+
}
|
|
584
|
+
function isRequestDenial(value) {
|
|
585
|
+
return typeof value === "object" && value !== null && "status" in value;
|
|
586
|
+
}
|
|
587
|
+
async function runOnRequest(res, ctx, hook) {
|
|
588
|
+
try {
|
|
589
|
+
const result = await hook(ctx);
|
|
590
|
+
if (isRequestDenial(result)) {
|
|
591
|
+
sendError(res, result.status, result.code, result.message);
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
return true;
|
|
595
|
+
} catch {
|
|
596
|
+
sendError(res, 500, "HOOK_ERROR", "onRequest hook threw an error");
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
var SirannonServer = class {
|
|
601
|
+
app;
|
|
602
|
+
listenSocket = null;
|
|
603
|
+
host;
|
|
604
|
+
port;
|
|
605
|
+
cors;
|
|
606
|
+
onRequestHook;
|
|
607
|
+
sirannon;
|
|
608
|
+
wsHandler;
|
|
609
|
+
constructor(sirannon, options) {
|
|
610
|
+
this.sirannon = sirannon;
|
|
611
|
+
this.host = options?.host ?? "127.0.0.1";
|
|
612
|
+
this.port = options?.port ?? 9876;
|
|
613
|
+
this.cors = resolveCors(options?.cors);
|
|
614
|
+
this.onRequestHook = options?.onRequest;
|
|
615
|
+
this.wsHandler = new WSHandler(sirannon);
|
|
616
|
+
this.app = uWS.App();
|
|
617
|
+
this.registerRoutes();
|
|
618
|
+
}
|
|
619
|
+
listen() {
|
|
620
|
+
return new Promise((resolve, reject) => {
|
|
621
|
+
this.app.listen(this.host, this.port, (socket) => {
|
|
622
|
+
if (socket) {
|
|
623
|
+
this.listenSocket = socket;
|
|
624
|
+
resolve();
|
|
625
|
+
} else {
|
|
626
|
+
reject(new Error(`Failed to listen on ${this.host}:${this.port}`));
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
close() {
|
|
632
|
+
return new Promise((resolve) => {
|
|
633
|
+
this.wsHandler.close();
|
|
634
|
+
if (this.listenSocket) {
|
|
635
|
+
uWS.us_listen_socket_close(this.listenSocket);
|
|
636
|
+
this.listenSocket = null;
|
|
637
|
+
}
|
|
638
|
+
resolve();
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
get listeningPort() {
|
|
642
|
+
if (!this.listenSocket) return -1;
|
|
643
|
+
return uWS.us_socket_local_port(this.listenSocket);
|
|
644
|
+
}
|
|
645
|
+
registerRoutes() {
|
|
646
|
+
if (this.cors) {
|
|
647
|
+
const cors = this.cors;
|
|
648
|
+
this.app.options("/*", (res, req) => {
|
|
649
|
+
const requestOrigin = req.getHeader("origin");
|
|
650
|
+
res.cork(() => {
|
|
651
|
+
res.writeStatus("204 No Content");
|
|
652
|
+
writeCorsOrigin(res, cors, requestOrigin);
|
|
653
|
+
res.writeHeader("Access-Control-Allow-Methods", cors.methods).writeHeader("Access-Control-Allow-Headers", cors.headers).writeHeader("Access-Control-Max-Age", "86400").endWithoutBody();
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
this.app.get("/health", this.withCors(handleLiveness()));
|
|
658
|
+
this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon)));
|
|
659
|
+
this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon)));
|
|
660
|
+
this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon)));
|
|
661
|
+
this.app.post("/db/:id/transaction", this.wrapDbRoute(handleTransaction(this.sirannon)));
|
|
662
|
+
this.registerWebSocketRoute();
|
|
663
|
+
this.app.any("/*", (res) => {
|
|
664
|
+
sendError(res, 404, "NOT_FOUND", "Route not found");
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
registerWebSocketRoute() {
|
|
668
|
+
const wsHandler = this.wsHandler;
|
|
669
|
+
const onRequestHook = this.onRequestHook;
|
|
670
|
+
this.app.ws("/db/:id", {
|
|
671
|
+
maxPayloadLength: 1048576,
|
|
672
|
+
idleTimeout: 120,
|
|
673
|
+
sendPingsAutomatically: true,
|
|
674
|
+
upgrade: (res, req, context) => {
|
|
675
|
+
const dbId = req.getParameter(0) ?? "";
|
|
676
|
+
const url = req.getUrl();
|
|
677
|
+
const method = req.getMethod();
|
|
678
|
+
const secWebSocketKey = req.getHeader("sec-websocket-key");
|
|
679
|
+
const secWebSocketProtocol = req.getHeader("sec-websocket-protocol");
|
|
680
|
+
const secWebSocketExtensions = req.getHeader("sec-websocket-extensions");
|
|
681
|
+
const headers = {};
|
|
682
|
+
req.forEach((key, value) => {
|
|
683
|
+
headers[key] = value;
|
|
684
|
+
});
|
|
685
|
+
const remoteAddress = decodeRemoteAddress(res);
|
|
686
|
+
let aborted = false;
|
|
687
|
+
res.onAborted(() => {
|
|
688
|
+
aborted = true;
|
|
689
|
+
});
|
|
690
|
+
if (!onRequestHook) {
|
|
691
|
+
if (!aborted) {
|
|
692
|
+
res.upgrade(
|
|
693
|
+
{ databaseId: dbId },
|
|
694
|
+
secWebSocketKey,
|
|
695
|
+
secWebSocketProtocol,
|
|
696
|
+
secWebSocketExtensions,
|
|
697
|
+
context
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const ctx = {
|
|
703
|
+
headers,
|
|
704
|
+
method,
|
|
705
|
+
path: url,
|
|
706
|
+
databaseId: dbId,
|
|
707
|
+
remoteAddress
|
|
708
|
+
};
|
|
709
|
+
runOnRequest(res, ctx, onRequestHook).then((allowed) => {
|
|
710
|
+
if (aborted || !allowed) return;
|
|
711
|
+
res.upgrade(
|
|
712
|
+
{ databaseId: dbId },
|
|
713
|
+
secWebSocketKey,
|
|
714
|
+
secWebSocketProtocol,
|
|
715
|
+
secWebSocketExtensions,
|
|
716
|
+
context
|
|
717
|
+
);
|
|
718
|
+
}).catch(() => {
|
|
719
|
+
});
|
|
720
|
+
},
|
|
721
|
+
open: (ws) => {
|
|
722
|
+
const userData = ws.getUserData();
|
|
723
|
+
const conn = {
|
|
724
|
+
send(data) {
|
|
725
|
+
try {
|
|
726
|
+
ws.send(data, false);
|
|
727
|
+
} catch {
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
close(code, reason) {
|
|
731
|
+
try {
|
|
732
|
+
ws.end(code, reason);
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
userData.conn = conn;
|
|
738
|
+
wsHandler.handleOpen(conn, userData.databaseId);
|
|
739
|
+
},
|
|
740
|
+
message: (ws, message) => {
|
|
741
|
+
const userData = ws.getUserData();
|
|
742
|
+
if (!userData.conn) return;
|
|
743
|
+
const text = Buffer.from(message).toString("utf-8");
|
|
744
|
+
wsHandler.handleMessage(userData.conn, text);
|
|
745
|
+
},
|
|
746
|
+
close: (ws) => {
|
|
747
|
+
const userData = ws.getUserData();
|
|
748
|
+
if (!userData.conn) return;
|
|
749
|
+
wsHandler.handleClose(userData.conn);
|
|
750
|
+
userData.conn = void 0;
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
withCors(handler) {
|
|
755
|
+
if (!this.cors) return handler;
|
|
756
|
+
const cors = this.cors;
|
|
757
|
+
return (res, req) => {
|
|
758
|
+
writeCorsOrigin(res, cors, req.getHeader("origin"));
|
|
759
|
+
handler(res, req);
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
wrapDbRoute(handler) {
|
|
763
|
+
const onRequestHook = this.onRequestHook;
|
|
764
|
+
const corsHeaders = this.cors;
|
|
765
|
+
const MAX_BODY = 1048576;
|
|
766
|
+
return (res, req) => {
|
|
767
|
+
const dbId = req.getParameter(0) ?? "";
|
|
768
|
+
const method = req.getMethod();
|
|
769
|
+
const path = req.getUrl();
|
|
770
|
+
if (corsHeaders) {
|
|
771
|
+
writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
|
|
772
|
+
}
|
|
773
|
+
const abort = initAbortHandler(res);
|
|
774
|
+
const bodyPromise = readBody(res, MAX_BODY, abort);
|
|
775
|
+
if (!onRequestHook) {
|
|
776
|
+
bodyPromise.then((rawBody) => {
|
|
777
|
+
if (abort.aborted) return;
|
|
778
|
+
handler(res, dbId, rawBody);
|
|
779
|
+
}).catch(() => {
|
|
780
|
+
});
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const headers = {};
|
|
784
|
+
req.forEach((key, value) => {
|
|
785
|
+
headers[key] = value;
|
|
786
|
+
});
|
|
787
|
+
const remoteAddress = decodeRemoteAddress(res);
|
|
788
|
+
const ctx = {
|
|
789
|
+
headers,
|
|
790
|
+
method,
|
|
791
|
+
path,
|
|
792
|
+
databaseId: dbId,
|
|
793
|
+
remoteAddress
|
|
794
|
+
};
|
|
795
|
+
const hookPromise = runOnRequest(res, ctx, onRequestHook);
|
|
796
|
+
Promise.all([bodyPromise, hookPromise]).then(([rawBody, allowed]) => {
|
|
797
|
+
if (abort.aborted || !allowed) return;
|
|
798
|
+
handler(res, dbId, rawBody);
|
|
799
|
+
}).catch(() => {
|
|
800
|
+
});
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
function createServer(sirannon, options) {
|
|
805
|
+
return new SirannonServer(sirannon, options);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export { SirannonServer, WSHandler, createServer, createWSHandler, toExecuteResponse };
|