@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,479 @@
|
|
|
1
|
+
// src/client/subscription.ts
|
|
2
|
+
var RemoteSubscriptionBuilderImpl = class {
|
|
3
|
+
constructor(table, transport) {
|
|
4
|
+
this.table = table;
|
|
5
|
+
this.transport = transport;
|
|
6
|
+
}
|
|
7
|
+
conditions = {};
|
|
8
|
+
filter(conditions) {
|
|
9
|
+
this.conditions = { ...this.conditions, ...conditions };
|
|
10
|
+
return this;
|
|
11
|
+
}
|
|
12
|
+
subscribe(callback) {
|
|
13
|
+
const filter = Object.keys(this.conditions).length > 0 ? this.conditions : void 0;
|
|
14
|
+
return this.transport.subscribe(this.table, filter, callback);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/client/database-proxy.ts
|
|
19
|
+
var RemoteDatabase = class {
|
|
20
|
+
constructor(id, transport, onDispose) {
|
|
21
|
+
this.id = id;
|
|
22
|
+
this.transport = transport;
|
|
23
|
+
this.onDispose = onDispose;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Execute a SELECT and return all matching rows.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const users = await db.query<{ id: number; name: string }>(
|
|
30
|
+
* 'SELECT * FROM users WHERE age > ?',
|
|
31
|
+
* [21],
|
|
32
|
+
* )
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
async query(sql, params) {
|
|
36
|
+
const response = await this.transport.query(sql, params);
|
|
37
|
+
return response.rows;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Execute a mutation (INSERT, UPDATE, DELETE) and return
|
|
41
|
+
* the number of affected rows and last insert row ID.
|
|
42
|
+
*/
|
|
43
|
+
async execute(sql, params) {
|
|
44
|
+
return this.transport.execute(sql, params);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Execute multiple statements as a single atomic transaction.
|
|
48
|
+
* Returns an array of results, one per statement.
|
|
49
|
+
*
|
|
50
|
+
* Requires HTTP transport. WebSocket transport does not
|
|
51
|
+
* support server-side transactions.
|
|
52
|
+
*/
|
|
53
|
+
async transaction(statements) {
|
|
54
|
+
const response = await this.transport.transaction(statements);
|
|
55
|
+
return response.results;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Start building a CDC subscription for the given table.
|
|
59
|
+
* Chain `.filter()` to narrow the events, then call `.subscribe()`
|
|
60
|
+
* with a callback to begin receiving real-time change events.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* const sub = await db
|
|
64
|
+
* .on('orders')
|
|
65
|
+
* .filter({ status: 'pending' })
|
|
66
|
+
* .subscribe(event => console.log(event))
|
|
67
|
+
*
|
|
68
|
+
* // Later:
|
|
69
|
+
* sub.unsubscribe()
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
on(table) {
|
|
73
|
+
return new RemoteSubscriptionBuilderImpl(table, this.transport);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Close the transport for this database. After calling `close()`,
|
|
77
|
+
* all pending requests are rejected and new calls will throw.
|
|
78
|
+
*/
|
|
79
|
+
close() {
|
|
80
|
+
this.transport.close();
|
|
81
|
+
this.onDispose?.();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/client/types.ts
|
|
86
|
+
var RemoteError = class extends Error {
|
|
87
|
+
code;
|
|
88
|
+
constructor(code, message) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = "RemoteError";
|
|
91
|
+
this.code = code;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/client/transport/http.ts
|
|
96
|
+
var HttpTransport = class {
|
|
97
|
+
baseUrl;
|
|
98
|
+
headers;
|
|
99
|
+
closed = false;
|
|
100
|
+
constructor(baseUrl, headers) {
|
|
101
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
102
|
+
this.headers = {
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
...headers
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async query(sql, params) {
|
|
108
|
+
return this.post("/query", { sql, params });
|
|
109
|
+
}
|
|
110
|
+
async execute(sql, params) {
|
|
111
|
+
return this.post("/execute", { sql, params });
|
|
112
|
+
}
|
|
113
|
+
async transaction(statements) {
|
|
114
|
+
return this.post("/transaction", { statements });
|
|
115
|
+
}
|
|
116
|
+
async subscribe(_table, _filter, _callback) {
|
|
117
|
+
throw new RemoteError(
|
|
118
|
+
"TRANSPORT_ERROR",
|
|
119
|
+
'Subscriptions require WebSocket transport. Create the client with { transport: "websocket" } to use real-time subscriptions.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
close() {
|
|
123
|
+
this.closed = true;
|
|
124
|
+
}
|
|
125
|
+
async post(path, body) {
|
|
126
|
+
if (this.closed) {
|
|
127
|
+
throw new RemoteError("TRANSPORT_ERROR", "Transport is closed");
|
|
128
|
+
}
|
|
129
|
+
const url = `${this.baseUrl}${path}`;
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await fetch(url, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: this.headers,
|
|
135
|
+
body: JSON.stringify(body)
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
throw new RemoteError(
|
|
139
|
+
"CONNECTION_ERROR",
|
|
140
|
+
`Failed to connect to ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
let data;
|
|
144
|
+
try {
|
|
145
|
+
data = await response.json();
|
|
146
|
+
} catch {
|
|
147
|
+
throw new RemoteError("INVALID_RESPONSE", `Server returned non-JSON response (HTTP ${response.status})`);
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const errorData = data;
|
|
151
|
+
throw new RemoteError(
|
|
152
|
+
errorData.error?.code ?? "UNKNOWN_ERROR",
|
|
153
|
+
errorData.error?.message ?? `HTTP ${response.status}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// src/client/transport/ws.ts
|
|
161
|
+
var DEFAULT_REQUEST_TIMEOUT = 3e4;
|
|
162
|
+
var WebSocketTransport = class {
|
|
163
|
+
ws = null;
|
|
164
|
+
url;
|
|
165
|
+
autoReconnect;
|
|
166
|
+
reconnectInterval;
|
|
167
|
+
requestTimeout;
|
|
168
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
169
|
+
activeSubscriptions = /* @__PURE__ */ new Map();
|
|
170
|
+
idCounter = 0;
|
|
171
|
+
closed = false;
|
|
172
|
+
connectPromise = null;
|
|
173
|
+
reconnectTimer = null;
|
|
174
|
+
constructor(url, options) {
|
|
175
|
+
this.url = url;
|
|
176
|
+
this.autoReconnect = options?.autoReconnect ?? true;
|
|
177
|
+
this.reconnectInterval = options?.reconnectInterval ?? 1e3;
|
|
178
|
+
this.requestTimeout = options?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
179
|
+
}
|
|
180
|
+
async query(sql, params) {
|
|
181
|
+
await this.ensureConnected();
|
|
182
|
+
const id = this.nextId();
|
|
183
|
+
return this.request({ type: "query", id, sql, params });
|
|
184
|
+
}
|
|
185
|
+
async execute(sql, params) {
|
|
186
|
+
await this.ensureConnected();
|
|
187
|
+
const id = this.nextId();
|
|
188
|
+
return this.request({ type: "execute", id, sql, params });
|
|
189
|
+
}
|
|
190
|
+
async transaction(_statements) {
|
|
191
|
+
throw new RemoteError(
|
|
192
|
+
"TRANSPORT_ERROR",
|
|
193
|
+
"Transactions are not supported over WebSocket. Use HTTP transport for batch transactions."
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
async subscribe(table, filter, callback) {
|
|
197
|
+
await this.ensureConnected();
|
|
198
|
+
const id = this.nextId();
|
|
199
|
+
this.activeSubscriptions.set(id, { table, filter, callback });
|
|
200
|
+
try {
|
|
201
|
+
const msg = {
|
|
202
|
+
type: "subscribe",
|
|
203
|
+
id,
|
|
204
|
+
table,
|
|
205
|
+
...filter ? { filter } : {}
|
|
206
|
+
};
|
|
207
|
+
await this.request(msg);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
this.activeSubscriptions.delete(id);
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
unsubscribe: () => {
|
|
214
|
+
this.activeSubscriptions.delete(id);
|
|
215
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
216
|
+
this.ws.send(JSON.stringify({ type: "unsubscribe", id }));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
close() {
|
|
222
|
+
this.closed = true;
|
|
223
|
+
this.cancelReconnect();
|
|
224
|
+
this.rejectAllPending(new RemoteError("TRANSPORT_ERROR", "Transport closed"));
|
|
225
|
+
this.activeSubscriptions.clear();
|
|
226
|
+
if (this.ws) {
|
|
227
|
+
this.ws.close(1e3, "Client closed");
|
|
228
|
+
this.ws = null;
|
|
229
|
+
}
|
|
230
|
+
this.connectPromise = null;
|
|
231
|
+
}
|
|
232
|
+
nextId() {
|
|
233
|
+
return `c_${++this.idCounter}_${Date.now()}`;
|
|
234
|
+
}
|
|
235
|
+
async ensureConnected() {
|
|
236
|
+
if (this.closed) {
|
|
237
|
+
throw new RemoteError("TRANSPORT_ERROR", "Transport is closed");
|
|
238
|
+
}
|
|
239
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!this.connectPromise) {
|
|
243
|
+
this.connectPromise = this.connect().finally(() => {
|
|
244
|
+
this.connectPromise = null;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return this.connectPromise;
|
|
248
|
+
}
|
|
249
|
+
connect() {
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
let settled = false;
|
|
252
|
+
const ws = new WebSocket(this.url);
|
|
253
|
+
const onOpen = () => {
|
|
254
|
+
settled = true;
|
|
255
|
+
this.ws = ws;
|
|
256
|
+
resolve();
|
|
257
|
+
};
|
|
258
|
+
const onError = () => {
|
|
259
|
+
if (!settled) {
|
|
260
|
+
settled = true;
|
|
261
|
+
reject(new RemoteError("CONNECTION_ERROR", `Failed to connect to ${this.url}`));
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const onClose = (event) => {
|
|
265
|
+
ws.removeEventListener("open", onOpen);
|
|
266
|
+
ws.removeEventListener("error", onError);
|
|
267
|
+
if (!settled) {
|
|
268
|
+
settled = true;
|
|
269
|
+
reject(
|
|
270
|
+
new RemoteError("CONNECTION_ERROR", `Connection closed during handshake: ${event.code} ${event.reason}`)
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.ws = null;
|
|
275
|
+
this.handleDisconnect();
|
|
276
|
+
};
|
|
277
|
+
const onMessage = (event) => {
|
|
278
|
+
this.handleMessage(String(event.data));
|
|
279
|
+
};
|
|
280
|
+
ws.addEventListener("open", onOpen);
|
|
281
|
+
ws.addEventListener("error", onError);
|
|
282
|
+
ws.addEventListener("close", onClose);
|
|
283
|
+
ws.addEventListener("message", onMessage);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
handleMessage(raw) {
|
|
287
|
+
let msg;
|
|
288
|
+
try {
|
|
289
|
+
msg = JSON.parse(raw);
|
|
290
|
+
} catch {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
switch (msg.type) {
|
|
294
|
+
case "result": {
|
|
295
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
296
|
+
if (pending) {
|
|
297
|
+
clearTimeout(pending.timer);
|
|
298
|
+
this.pendingRequests.delete(msg.id);
|
|
299
|
+
pending.resolve(msg.data);
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "subscribed": {
|
|
304
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
305
|
+
if (pending) {
|
|
306
|
+
clearTimeout(pending.timer);
|
|
307
|
+
this.pendingRequests.delete(msg.id);
|
|
308
|
+
pending.resolve(void 0);
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
case "error": {
|
|
313
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
314
|
+
if (pending) {
|
|
315
|
+
clearTimeout(pending.timer);
|
|
316
|
+
this.pendingRequests.delete(msg.id);
|
|
317
|
+
pending.reject(new RemoteError(msg.error.code, msg.error.message));
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "change": {
|
|
322
|
+
const sub = this.activeSubscriptions.get(msg.id);
|
|
323
|
+
if (sub) {
|
|
324
|
+
try {
|
|
325
|
+
const event = {
|
|
326
|
+
type: msg.event.type,
|
|
327
|
+
table: msg.event.table,
|
|
328
|
+
row: msg.event.row,
|
|
329
|
+
oldRow: msg.event.oldRow,
|
|
330
|
+
seq: BigInt(msg.event.seq),
|
|
331
|
+
timestamp: msg.event.timestamp
|
|
332
|
+
};
|
|
333
|
+
sub.callback(event);
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
handleDisconnect() {
|
|
342
|
+
this.rejectAllPending(new RemoteError("CONNECTION_ERROR", "WebSocket disconnected"));
|
|
343
|
+
if (this.autoReconnect && !this.closed && this.activeSubscriptions.size > 0) {
|
|
344
|
+
this.scheduleReconnect();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
scheduleReconnect() {
|
|
348
|
+
if (this.reconnectTimer !== null || this.closed) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
352
|
+
this.reconnectTimer = null;
|
|
353
|
+
if (this.closed) return;
|
|
354
|
+
try {
|
|
355
|
+
await this.ensureConnected();
|
|
356
|
+
await this.resubscribeAll();
|
|
357
|
+
} catch {
|
|
358
|
+
if (!this.closed && this.activeSubscriptions.size > 0) {
|
|
359
|
+
this.scheduleReconnect();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}, this.reconnectInterval);
|
|
363
|
+
}
|
|
364
|
+
async resubscribeAll() {
|
|
365
|
+
const entries = [...this.activeSubscriptions.entries()];
|
|
366
|
+
for (const [id, sub] of entries) {
|
|
367
|
+
if (this.closed) break;
|
|
368
|
+
try {
|
|
369
|
+
const msg = {
|
|
370
|
+
type: "subscribe",
|
|
371
|
+
id,
|
|
372
|
+
table: sub.table,
|
|
373
|
+
...sub.filter ? { filter: sub.filter } : {}
|
|
374
|
+
};
|
|
375
|
+
await this.request(msg);
|
|
376
|
+
} catch {
|
|
377
|
+
this.activeSubscriptions.delete(id);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
request(msg) {
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
const { id } = msg;
|
|
384
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
385
|
+
reject(new RemoteError("CONNECTION_ERROR", "WebSocket is not connected"));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const timer = setTimeout(() => {
|
|
389
|
+
this.pendingRequests.delete(id);
|
|
390
|
+
reject(new RemoteError("TIMEOUT", `Request timed out after ${this.requestTimeout}ms`));
|
|
391
|
+
}, this.requestTimeout);
|
|
392
|
+
this.pendingRequests.set(id, {
|
|
393
|
+
resolve,
|
|
394
|
+
reject,
|
|
395
|
+
timer
|
|
396
|
+
});
|
|
397
|
+
this.ws.send(JSON.stringify(msg));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
rejectAllPending(error) {
|
|
401
|
+
for (const [, pending] of this.pendingRequests) {
|
|
402
|
+
clearTimeout(pending.timer);
|
|
403
|
+
pending.reject(error);
|
|
404
|
+
}
|
|
405
|
+
this.pendingRequests.clear();
|
|
406
|
+
}
|
|
407
|
+
cancelReconnect() {
|
|
408
|
+
if (this.reconnectTimer !== null) {
|
|
409
|
+
clearTimeout(this.reconnectTimer);
|
|
410
|
+
this.reconnectTimer = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// src/client/client.ts
|
|
416
|
+
var SirannonClient = class {
|
|
417
|
+
baseUrl;
|
|
418
|
+
wsBaseUrl;
|
|
419
|
+
transport;
|
|
420
|
+
headers;
|
|
421
|
+
autoReconnect;
|
|
422
|
+
reconnectInterval;
|
|
423
|
+
databases = /* @__PURE__ */ new Map();
|
|
424
|
+
closed = false;
|
|
425
|
+
constructor(url, options) {
|
|
426
|
+
this.baseUrl = url.replace(/\/$/, "");
|
|
427
|
+
this.wsBaseUrl = this.baseUrl.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://");
|
|
428
|
+
this.transport = options?.transport ?? "websocket";
|
|
429
|
+
this.headers = options?.headers;
|
|
430
|
+
this.autoReconnect = options?.autoReconnect ?? true;
|
|
431
|
+
this.reconnectInterval = options?.reconnectInterval ?? 1e3;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Get a {@link RemoteDatabase} proxy for the given database ID.
|
|
435
|
+
* Returns a cached instance if one already exists for this ID.
|
|
436
|
+
*
|
|
437
|
+
* The underlying transport connection is established lazily on
|
|
438
|
+
* the first operation (query, execute, or subscribe).
|
|
439
|
+
*/
|
|
440
|
+
database(id) {
|
|
441
|
+
if (this.closed) {
|
|
442
|
+
throw new Error("Client is closed");
|
|
443
|
+
}
|
|
444
|
+
const existing = this.databases.get(id);
|
|
445
|
+
if (existing) {
|
|
446
|
+
return existing;
|
|
447
|
+
}
|
|
448
|
+
const transport = this.createTransport(id);
|
|
449
|
+
const db = new RemoteDatabase(id, transport, () => {
|
|
450
|
+
this.databases.delete(id);
|
|
451
|
+
});
|
|
452
|
+
this.databases.set(id, db);
|
|
453
|
+
return db;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Close all database connections and release resources.
|
|
457
|
+
* After calling `close()`, new calls to `database()` will throw.
|
|
458
|
+
*/
|
|
459
|
+
close() {
|
|
460
|
+
this.closed = true;
|
|
461
|
+
const openDatabases = [...this.databases.values()];
|
|
462
|
+
this.databases.clear();
|
|
463
|
+
for (const db of openDatabases) {
|
|
464
|
+
db.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
createTransport(databaseId) {
|
|
468
|
+
const encodedId = encodeURIComponent(databaseId);
|
|
469
|
+
if (this.transport === "http") {
|
|
470
|
+
return new HttpTransport(`${this.baseUrl}/db/${encodedId}`, this.headers);
|
|
471
|
+
}
|
|
472
|
+
return new WebSocketTransport(`${this.wsBaseUrl}/db/${encodedId}`, {
|
|
473
|
+
autoReconnect: this.autoReconnect,
|
|
474
|
+
reconnectInterval: this.reconnectInterval
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
export { HttpTransport, RemoteDatabase, RemoteError, RemoteSubscriptionBuilderImpl, SirannonClient, WebSocketTransport };
|