@delali/sirannon-db 0.1.3 → 0.1.4
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 +276 -77
- package/dist/backup-scheduler/index.d.ts +3 -0
- package/dist/backup-scheduler/index.mjs +2 -0
- package/dist/chunk-74UN4DIE.mjs +14 -0
- package/dist/{chunk-VI4UP4RR.mjs → chunk-AX66KWBR.mjs} +74 -139
- package/dist/chunk-FB2U2Q3Y.mjs +21 -0
- package/dist/chunk-O7BHI3CF.mjs +90 -0
- package/dist/chunk-PXKAKK2V.mjs +124 -0
- package/dist/client/index.d.ts +38 -2
- package/dist/core/index.d.ts +30 -142
- package/dist/core/index.mjs +229 -469
- 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/file-migrations/index.d.ts +16 -0
- package/dist/file-migrations/index.mjs +128 -0
- package/dist/index-hXiis3N-.d.ts +16 -0
- package/dist/server/index.d.ts +110 -54
- package/dist/server/index.mjs +107 -92
- package/dist/{sirannon-BJ8Yd1Uf.d.ts → sirannon-B1oTfebD.d.ts} +30 -58
- package/dist/types-BFSsG77t.d.ts +29 -0
- package/dist/types-DRkJlqex.d.ts +38 -0
- package/dist/{types-DArCObcu.d.ts → types-DtDutWRU.d.ts} +4 -1
- package/dist/vfs-INWQ5DTE.mjs +2 -0
- package/package.json +58 -7
- package/dist/protocol-BX1H-_Mz.d.ts +0 -104
|
@@ -1,91 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
var SirannonError = class extends Error {
|
|
3
|
-
constructor(message, code) {
|
|
4
|
-
super(message);
|
|
5
|
-
this.code = code;
|
|
6
|
-
this.name = "SirannonError";
|
|
7
|
-
}
|
|
8
|
-
};
|
|
9
|
-
var DatabaseNotFoundError = class extends SirannonError {
|
|
10
|
-
constructor(id) {
|
|
11
|
-
super(`Database '${id}' not found`, "DATABASE_NOT_FOUND");
|
|
12
|
-
this.name = "DatabaseNotFoundError";
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
var DatabaseAlreadyExistsError = class extends SirannonError {
|
|
16
|
-
constructor(id) {
|
|
17
|
-
super(`Database '${id}' already exists`, "DATABASE_ALREADY_EXISTS");
|
|
18
|
-
this.name = "DatabaseAlreadyExistsError";
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
var ReadOnlyError = class extends SirannonError {
|
|
22
|
-
constructor(id) {
|
|
23
|
-
super(`Database '${id}' is read-only`, "READ_ONLY");
|
|
24
|
-
this.name = "ReadOnlyError";
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
var QueryError = class extends SirannonError {
|
|
28
|
-
constructor(message, sql) {
|
|
29
|
-
super(message, "QUERY_ERROR");
|
|
30
|
-
this.sql = sql;
|
|
31
|
-
this.name = "QueryError";
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
var TransactionError = class extends SirannonError {
|
|
35
|
-
constructor(message) {
|
|
36
|
-
super(message, "TRANSACTION_ERROR");
|
|
37
|
-
this.name = "TransactionError";
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
var MigrationError = class extends SirannonError {
|
|
41
|
-
constructor(message, version, code = "MIGRATION_ERROR") {
|
|
42
|
-
super(message, code);
|
|
43
|
-
this.version = version;
|
|
44
|
-
this.name = "MigrationError";
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
var HookDeniedError = class extends SirannonError {
|
|
48
|
-
constructor(hookName, reason) {
|
|
49
|
-
super(
|
|
50
|
-
reason ? `Hook '${hookName}' denied the operation: ${reason}` : `Hook '${hookName}' denied the operation`,
|
|
51
|
-
"HOOK_DENIED"
|
|
52
|
-
);
|
|
53
|
-
this.name = "HookDeniedError";
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
var CDCError = class extends SirannonError {
|
|
57
|
-
constructor(message) {
|
|
58
|
-
super(message, "CDC_ERROR");
|
|
59
|
-
this.name = "CDCError";
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
var BackupError = class extends SirannonError {
|
|
63
|
-
constructor(message) {
|
|
64
|
-
super(message, "BACKUP_ERROR");
|
|
65
|
-
this.name = "BackupError";
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
var ConnectionPoolError = class extends SirannonError {
|
|
69
|
-
constructor(message) {
|
|
70
|
-
super(message, "CONNECTION_POOL_ERROR");
|
|
71
|
-
this.name = "ConnectionPoolError";
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
var MaxDatabasesError = class extends SirannonError {
|
|
75
|
-
constructor(max) {
|
|
76
|
-
super(`Maximum number of open databases (${max}) reached`, "MAX_DATABASES");
|
|
77
|
-
this.name = "MaxDatabasesError";
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
var ExtensionError = class extends SirannonError {
|
|
81
|
-
constructor(path, cause) {
|
|
82
|
-
super(
|
|
83
|
-
cause ? `Failed to load extension '${path}': ${cause}` : `Failed to load extension '${path}'`,
|
|
84
|
-
"EXTENSION_ERROR"
|
|
85
|
-
);
|
|
86
|
-
this.name = "ExtensionError";
|
|
87
|
-
}
|
|
88
|
-
};
|
|
1
|
+
import { CDCError } from './chunk-O7BHI3CF.mjs';
|
|
89
2
|
|
|
90
3
|
// src/core/cdc/change-tracker.ts
|
|
91
4
|
var DEFAULT_RETENTION_MS = 36e5;
|
|
@@ -107,46 +20,50 @@ var ChangeTracker = class {
|
|
|
107
20
|
this.pollBatchSize = options?.pollBatchSize ?? DEFAULT_POLL_BATCH_SIZE;
|
|
108
21
|
this.assertIdentifier(this.changesTable, "changes table name");
|
|
109
22
|
}
|
|
110
|
-
watch(
|
|
23
|
+
async watch(conn, table) {
|
|
111
24
|
this.assertIdentifier(table, "table name");
|
|
112
|
-
this.ensureChangesTable(
|
|
113
|
-
const columns = this.getColumns(
|
|
25
|
+
await this.ensureChangesTable(conn);
|
|
26
|
+
const columns = await this.getColumns(conn, table);
|
|
114
27
|
if (columns.length === 0) {
|
|
115
28
|
throw new CDCError(`Table '${table}' does not exist or has no columns`);
|
|
116
29
|
}
|
|
117
30
|
for (const col of columns) {
|
|
118
31
|
this.assertIdentifier(col, `column name in table '${table}'`);
|
|
119
32
|
}
|
|
120
|
-
const pkColumns = this.getPkColumns(
|
|
33
|
+
const pkColumns = await this.getPkColumns(conn, table);
|
|
121
34
|
const existing = this.watched.get(table);
|
|
122
35
|
if (existing) {
|
|
123
36
|
const same = existing.columns.length === columns.length && existing.columns.every((col, i) => col === columns[i]);
|
|
124
37
|
if (same) {
|
|
125
38
|
return;
|
|
126
39
|
}
|
|
127
|
-
|
|
40
|
+
await conn.transaction(async (txConn) => {
|
|
41
|
+
await this.dropTriggers(txConn, table);
|
|
42
|
+
await this.installTriggers(txConn, table, columns, pkColumns);
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
await this.installTriggers(conn, table, columns, pkColumns);
|
|
128
46
|
}
|
|
129
|
-
this.installTriggers(db, table, columns, pkColumns);
|
|
130
47
|
this.watched.set(table, { table, columns, pkColumns });
|
|
131
48
|
this.watchedTablesCache = null;
|
|
132
49
|
}
|
|
133
|
-
unwatch(
|
|
50
|
+
async unwatch(conn, table) {
|
|
134
51
|
if (!this.watched.has(table)) {
|
|
135
52
|
return;
|
|
136
53
|
}
|
|
137
|
-
this.dropTriggers(
|
|
54
|
+
await this.dropTriggers(conn, table);
|
|
138
55
|
this.watched.delete(table);
|
|
139
56
|
this.watchedTablesCache = null;
|
|
140
57
|
}
|
|
141
|
-
poll(
|
|
58
|
+
async poll(conn) {
|
|
142
59
|
if (!this.changesTableReady) {
|
|
143
|
-
this.detectChangesTable(
|
|
60
|
+
await this.detectChangesTable(conn);
|
|
144
61
|
if (!this.changesTableReady) {
|
|
145
62
|
return [];
|
|
146
63
|
}
|
|
147
64
|
}
|
|
148
|
-
const stmt = this.getStmt(
|
|
149
|
-
|
|
65
|
+
const stmt = await this.getStmt(
|
|
66
|
+
conn,
|
|
150
67
|
"poll",
|
|
151
68
|
`SELECT seq, table_name, operation, row_id, changed_at, old_data, new_data
|
|
152
69
|
FROM "${this.changesTable}"
|
|
@@ -154,7 +71,7 @@ var ChangeTracker = class {
|
|
|
154
71
|
ORDER BY seq ASC
|
|
155
72
|
LIMIT ?`
|
|
156
73
|
);
|
|
157
|
-
const rows = stmt.all(this.lastSeq, this.pollBatchSize);
|
|
74
|
+
const rows = await stmt.all(this.lastSeq, this.pollBatchSize);
|
|
158
75
|
if (rows.length === 0) {
|
|
159
76
|
return [];
|
|
160
77
|
}
|
|
@@ -172,24 +89,26 @@ var ChangeTracker = class {
|
|
|
172
89
|
this.lastSeq = rows[rows.length - 1].seq;
|
|
173
90
|
return events;
|
|
174
91
|
}
|
|
175
|
-
cleanup(
|
|
92
|
+
async cleanup(conn) {
|
|
176
93
|
if (!this.changesTableReady) {
|
|
177
|
-
this.detectChangesTable(
|
|
94
|
+
await this.detectChangesTable(conn);
|
|
178
95
|
if (!this.changesTableReady) {
|
|
179
96
|
return 0;
|
|
180
97
|
}
|
|
181
98
|
}
|
|
182
99
|
const cutoff = Date.now() / 1e3 - this.retentionMs / 1e3;
|
|
183
100
|
if (this.lastSeq > 0) {
|
|
184
|
-
const stmt2 = this.getStmt(
|
|
185
|
-
|
|
101
|
+
const stmt2 = await this.getStmt(
|
|
102
|
+
conn,
|
|
186
103
|
"cleanup_coordinated",
|
|
187
104
|
`DELETE FROM "${this.changesTable}" WHERE changed_at < ? AND seq <= ?`
|
|
188
105
|
);
|
|
189
|
-
|
|
106
|
+
const result2 = await stmt2.run(cutoff, this.lastSeq);
|
|
107
|
+
return result2.changes;
|
|
190
108
|
}
|
|
191
|
-
const stmt = this.getStmt(
|
|
192
|
-
|
|
109
|
+
const stmt = await this.getStmt(conn, "cleanup", `DELETE FROM "${this.changesTable}" WHERE changed_at < ?`);
|
|
110
|
+
const result = await stmt.run(cutoff);
|
|
111
|
+
return result.changes;
|
|
193
112
|
}
|
|
194
113
|
get watchedTables() {
|
|
195
114
|
if (!this.watchedTablesCache) {
|
|
@@ -204,17 +123,18 @@ var ChangeTracker = class {
|
|
|
204
123
|
);
|
|
205
124
|
}
|
|
206
125
|
}
|
|
207
|
-
detectChangesTable(
|
|
208
|
-
const
|
|
126
|
+
async detectChangesTable(conn) {
|
|
127
|
+
const stmt = await conn.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?");
|
|
128
|
+
const row = await stmt.get(this.changesTable);
|
|
209
129
|
if (row) {
|
|
210
130
|
this.changesTableReady = true;
|
|
211
131
|
}
|
|
212
132
|
}
|
|
213
|
-
ensureChangesTable(
|
|
133
|
+
async ensureChangesTable(conn) {
|
|
214
134
|
if (this.changesTableReady) {
|
|
215
135
|
return;
|
|
216
136
|
}
|
|
217
|
-
|
|
137
|
+
await conn.exec(`
|
|
218
138
|
CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
219
139
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
220
140
|
table_name TEXT NOT NULL,
|
|
@@ -224,34 +144,42 @@ CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
|
224
144
|
old_data TEXT,
|
|
225
145
|
new_data TEXT
|
|
226
146
|
)`);
|
|
227
|
-
|
|
147
|
+
await conn.exec(
|
|
148
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.changesTable}_changed_at" ON "${this.changesTable}" (changed_at)`
|
|
149
|
+
);
|
|
228
150
|
this.changesTableReady = true;
|
|
229
151
|
}
|
|
230
|
-
getColumns(
|
|
231
|
-
const
|
|
152
|
+
async getColumns(conn, table) {
|
|
153
|
+
const stmt = await conn.prepare(`PRAGMA table_info("${table}")`);
|
|
154
|
+
const info = await stmt.all();
|
|
232
155
|
return info.map((col) => col.name);
|
|
233
156
|
}
|
|
234
|
-
getPkColumns(
|
|
235
|
-
const
|
|
157
|
+
async getPkColumns(conn, table) {
|
|
158
|
+
const stmt = await conn.prepare(`PRAGMA table_info("${table}")`);
|
|
159
|
+
const info = await stmt.all();
|
|
236
160
|
return info.filter((col) => col.pk > 0).sort((a, b) => a.pk - b.pk).map((col) => col.name);
|
|
237
161
|
}
|
|
238
|
-
dropTriggers(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
162
|
+
async dropTriggers(conn, table) {
|
|
163
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "_sirannon_trg_${table}_insert"`);
|
|
164
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "_sirannon_trg_${table}_update"`);
|
|
165
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "_sirannon_trg_${table}_delete"`);
|
|
242
166
|
}
|
|
243
|
-
getStmt(
|
|
244
|
-
let stmts = this.stmtCache.get(
|
|
167
|
+
async getStmt(conn, key, sql) {
|
|
168
|
+
let stmts = this.stmtCache.get(conn);
|
|
245
169
|
if (!stmts) {
|
|
246
170
|
stmts = /* @__PURE__ */ new Map();
|
|
247
|
-
this.stmtCache.set(
|
|
171
|
+
this.stmtCache.set(conn, stmts);
|
|
248
172
|
}
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
173
|
+
const existing = stmts.get(key);
|
|
174
|
+
if (existing) return existing;
|
|
175
|
+
const pending = conn.prepare(sql);
|
|
176
|
+
stmts.set(key, pending);
|
|
177
|
+
try {
|
|
178
|
+
return await pending;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
stmts.delete(key);
|
|
181
|
+
throw err;
|
|
253
182
|
}
|
|
254
|
-
return stmt;
|
|
255
183
|
}
|
|
256
184
|
buildPkRef(pkColumns, ref) {
|
|
257
185
|
if (pkColumns.length === 0) {
|
|
@@ -262,12 +190,12 @@ CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
|
262
190
|
}
|
|
263
191
|
return pkColumns.map((col) => `${ref}."${this.escId(col)}"`).join(" || '-' || ");
|
|
264
192
|
}
|
|
265
|
-
installTriggers(
|
|
193
|
+
async installTriggers(conn, table, columns, pkColumns) {
|
|
266
194
|
const newJson = this.buildJsonObject(columns, "NEW");
|
|
267
195
|
const oldJson = this.buildJsonObject(columns, "OLD");
|
|
268
196
|
const newPk = this.buildPkRef(pkColumns, "NEW");
|
|
269
197
|
const oldPk = this.buildPkRef(pkColumns, "OLD");
|
|
270
|
-
|
|
198
|
+
await conn.exec(`
|
|
271
199
|
CREATE TRIGGER IF NOT EXISTS "_sirannon_trg_${table}_insert"
|
|
272
200
|
AFTER INSERT ON "${table}"
|
|
273
201
|
BEGIN
|
|
@@ -275,7 +203,7 @@ CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
|
275
203
|
VALUES ('${table}', 'INSERT', ${newPk}, ${newJson});
|
|
276
204
|
END
|
|
277
205
|
`);
|
|
278
|
-
|
|
206
|
+
await conn.exec(`
|
|
279
207
|
CREATE TRIGGER IF NOT EXISTS "_sirannon_trg_${table}_update"
|
|
280
208
|
AFTER UPDATE ON "${table}"
|
|
281
209
|
BEGIN
|
|
@@ -283,7 +211,7 @@ CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
|
283
211
|
VALUES ('${table}', 'UPDATE', ${newPk}, ${oldJson}, ${newJson});
|
|
284
212
|
END
|
|
285
213
|
`);
|
|
286
|
-
|
|
214
|
+
await conn.exec(`
|
|
287
215
|
CREATE TRIGGER IF NOT EXISTS "_sirannon_trg_${table}_delete"
|
|
288
216
|
AFTER DELETE ON "${table}"
|
|
289
217
|
BEGIN
|
|
@@ -369,15 +297,18 @@ var SubscriptionBuilderImpl = class {
|
|
|
369
297
|
return this.manager.subscribe(this.table, this.conditions, callback);
|
|
370
298
|
}
|
|
371
299
|
};
|
|
372
|
-
function startPolling(
|
|
300
|
+
function startPolling(conn, tracker, manager, intervalMs, onError) {
|
|
373
301
|
let consecutiveErrors = 0;
|
|
374
302
|
let tickCount = 0;
|
|
303
|
+
let polling = false;
|
|
375
304
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
376
305
|
const CLEANUP_INTERVAL_TICKS = 100;
|
|
377
|
-
const tick = () => {
|
|
306
|
+
const tick = async () => {
|
|
378
307
|
if (manager.size === 0) return;
|
|
308
|
+
if (polling) return;
|
|
309
|
+
polling = true;
|
|
379
310
|
try {
|
|
380
|
-
const events = tracker.poll(
|
|
311
|
+
const events = await tracker.poll(conn);
|
|
381
312
|
if (events.length > 0) {
|
|
382
313
|
manager.dispatch(events);
|
|
383
314
|
}
|
|
@@ -385,7 +316,7 @@ function startPolling(db, tracker, manager, intervalMs, onError) {
|
|
|
385
316
|
tickCount++;
|
|
386
317
|
if (tickCount >= CLEANUP_INTERVAL_TICKS) {
|
|
387
318
|
tickCount = 0;
|
|
388
|
-
tracker.cleanup(
|
|
319
|
+
await tracker.cleanup(conn);
|
|
389
320
|
}
|
|
390
321
|
} catch (err) {
|
|
391
322
|
consecutiveErrors++;
|
|
@@ -395,10 +326,14 @@ function startPolling(db, tracker, manager, intervalMs, onError) {
|
|
|
395
326
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
396
327
|
stop();
|
|
397
328
|
}
|
|
329
|
+
} finally {
|
|
330
|
+
polling = false;
|
|
398
331
|
}
|
|
399
332
|
};
|
|
400
333
|
const interval = setInterval(tick, intervalMs);
|
|
401
|
-
interval
|
|
334
|
+
if (typeof interval === "object" && "unref" in interval) {
|
|
335
|
+
interval.unref();
|
|
336
|
+
}
|
|
402
337
|
const stop = () => {
|
|
403
338
|
clearInterval(interval);
|
|
404
339
|
};
|
|
@@ -414,4 +349,4 @@ function matchesFilter(event, filter) {
|
|
|
414
349
|
return true;
|
|
415
350
|
}
|
|
416
351
|
|
|
417
|
-
export {
|
|
352
|
+
export { ChangeTracker, SubscriptionBuilderImpl, SubscriptionManager, startPolling };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/core/errors.ts
|
|
2
|
+
var SirannonError = class extends Error {
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "SirannonError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// src/core/driver/define.ts
|
|
11
|
+
function defineDriver(config) {
|
|
12
|
+
if (!config.capabilities || typeof config.open !== "function") {
|
|
13
|
+
throw new SirannonError("Driver must define capabilities and open()", "INVALID_DRIVER");
|
|
14
|
+
}
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
capabilities: Object.freeze({ ...config.capabilities }),
|
|
17
|
+
open: config.open
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { defineDriver };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// src/core/errors.ts
|
|
2
|
+
var SirannonError = class extends Error {
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "SirannonError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var DatabaseNotFoundError = class extends SirannonError {
|
|
10
|
+
constructor(id) {
|
|
11
|
+
super(`Database '${id}' not found`, "DATABASE_NOT_FOUND");
|
|
12
|
+
this.name = "DatabaseNotFoundError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var DatabaseAlreadyExistsError = class extends SirannonError {
|
|
16
|
+
constructor(id) {
|
|
17
|
+
super(`Database '${id}' already exists`, "DATABASE_ALREADY_EXISTS");
|
|
18
|
+
this.name = "DatabaseAlreadyExistsError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var ReadOnlyError = class extends SirannonError {
|
|
22
|
+
constructor(id) {
|
|
23
|
+
super(`Database '${id}' is read-only`, "READ_ONLY");
|
|
24
|
+
this.name = "ReadOnlyError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var QueryError = class extends SirannonError {
|
|
28
|
+
constructor(message, sql) {
|
|
29
|
+
super(message, "QUERY_ERROR");
|
|
30
|
+
this.sql = sql;
|
|
31
|
+
this.name = "QueryError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var TransactionError = class extends SirannonError {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message, "TRANSACTION_ERROR");
|
|
37
|
+
this.name = "TransactionError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var MigrationError = class extends SirannonError {
|
|
41
|
+
constructor(message, version, code = "MIGRATION_ERROR") {
|
|
42
|
+
super(message, code);
|
|
43
|
+
this.version = version;
|
|
44
|
+
this.name = "MigrationError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var HookDeniedError = class extends SirannonError {
|
|
48
|
+
constructor(hookName, reason) {
|
|
49
|
+
super(
|
|
50
|
+
reason ? `Hook '${hookName}' denied the operation: ${reason}` : `Hook '${hookName}' denied the operation`,
|
|
51
|
+
"HOOK_DENIED"
|
|
52
|
+
);
|
|
53
|
+
this.name = "HookDeniedError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var CDCError = class extends SirannonError {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message, "CDC_ERROR");
|
|
59
|
+
this.name = "CDCError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var BackupError = class extends SirannonError {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message, "BACKUP_ERROR");
|
|
65
|
+
this.name = "BackupError";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var ConnectionPoolError = class extends SirannonError {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message, "CONNECTION_POOL_ERROR");
|
|
71
|
+
this.name = "ConnectionPoolError";
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var MaxDatabasesError = class extends SirannonError {
|
|
75
|
+
constructor(max) {
|
|
76
|
+
super(`Maximum number of open databases (${max}) reached`, "MAX_DATABASES");
|
|
77
|
+
this.name = "MaxDatabasesError";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var ExtensionError = class extends SirannonError {
|
|
81
|
+
constructor(path, cause) {
|
|
82
|
+
super(
|
|
83
|
+
cause ? `Failed to load extension '${path}': ${cause}` : `Failed to load extension '${path}'`,
|
|
84
|
+
"EXTENSION_ERROR"
|
|
85
|
+
);
|
|
86
|
+
this.name = "ExtensionError";
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export { BackupError, CDCError, ConnectionPoolError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError, HookDeniedError, MaxDatabasesError, MigrationError, QueryError, ReadOnlyError, SirannonError, TransactionError };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { BackupError } from './chunk-O7BHI3CF.mjs';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, readdirSync, lstatSync } from 'fs';
|
|
3
|
+
import { resolve, dirname, join } from 'path';
|
|
4
|
+
import { Cron } from 'croner';
|
|
5
|
+
|
|
6
|
+
var BACKUP_FILE_PREFIX = "backup";
|
|
7
|
+
function hasControlCharacters(s) {
|
|
8
|
+
for (let i = 0; i < s.length; i++) {
|
|
9
|
+
const code = s.charCodeAt(i);
|
|
10
|
+
if (code <= 31) return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
var BackupManager = class {
|
|
15
|
+
async backup(conn, destPath) {
|
|
16
|
+
if (hasControlCharacters(destPath)) {
|
|
17
|
+
throw new BackupError("Backup path contains invalid characters");
|
|
18
|
+
}
|
|
19
|
+
const segments = destPath.split(/[/\\]/);
|
|
20
|
+
if (segments.includes("..")) {
|
|
21
|
+
throw new BackupError("Backup path must not contain directory traversal segments");
|
|
22
|
+
}
|
|
23
|
+
const resolved = resolve(destPath);
|
|
24
|
+
const dir = dirname(resolved);
|
|
25
|
+
if (!existsSync(dir)) {
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new BackupError(
|
|
30
|
+
`Failed to create backup directory '${dir}': ${err instanceof Error ? err.message : String(err)}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (existsSync(resolved)) {
|
|
35
|
+
throw new BackupError(`Backup destination '${destPath}' already exists`);
|
|
36
|
+
}
|
|
37
|
+
const escaped = resolved.replace(/'/g, "''");
|
|
38
|
+
try {
|
|
39
|
+
await conn.exec(`VACUUM INTO '${escaped}'`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
try {
|
|
42
|
+
rmSync(resolved, { force: true });
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
throw new BackupError(`Backup to '${destPath}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
generateFilename() {
|
|
49
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
50
|
+
return `${BACKUP_FILE_PREFIX}-${ts}.db`;
|
|
51
|
+
}
|
|
52
|
+
rotate(dir, maxFiles) {
|
|
53
|
+
if (maxFiles <= 0) return;
|
|
54
|
+
const resolved = resolve(dir);
|
|
55
|
+
if (!existsSync(resolved)) return;
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = readdirSync(resolved).filter((f) => f.startsWith(`${BACKUP_FILE_PREFIX}-`) && f.endsWith(".db")).map((f) => {
|
|
59
|
+
const filePath = join(resolved, f);
|
|
60
|
+
return { path: filePath, mtimeMs: lstatSync(filePath).mtimeMs };
|
|
61
|
+
}).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new BackupError(
|
|
64
|
+
`Failed to list backup files in '${dir}': ${err instanceof Error ? err.message : String(err)}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries.slice(maxFiles)) {
|
|
68
|
+
try {
|
|
69
|
+
rmSync(entry.path, { force: true });
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var DEFAULT_MAX_FILES = 5;
|
|
76
|
+
var BackupScheduler = class {
|
|
77
|
+
manager;
|
|
78
|
+
constructor(manager) {
|
|
79
|
+
this.manager = manager ?? new BackupManager();
|
|
80
|
+
}
|
|
81
|
+
schedule(conn, options) {
|
|
82
|
+
const { cron: cronExpr, destDir, maxFiles = DEFAULT_MAX_FILES, onError } = options;
|
|
83
|
+
const resolvedDir = resolve(destDir);
|
|
84
|
+
if (!existsSync(resolvedDir)) {
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(resolvedDir, { recursive: true });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new BackupError(
|
|
89
|
+
`Failed to create backup directory '${destDir}': ${err instanceof Error ? err.message : String(err)}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let job;
|
|
94
|
+
try {
|
|
95
|
+
job = new Cron(
|
|
96
|
+
cronExpr,
|
|
97
|
+
{
|
|
98
|
+
unref: true,
|
|
99
|
+
catch: (err) => {
|
|
100
|
+
if (onError) {
|
|
101
|
+
const error = err instanceof Error ? err : new BackupError(typeof err === "string" ? err : "Scheduled backup failed");
|
|
102
|
+
onError(error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async () => {
|
|
107
|
+
const filename = this.manager.generateFilename();
|
|
108
|
+
const destPath = join(resolvedDir, filename);
|
|
109
|
+
await this.manager.backup(conn, destPath);
|
|
110
|
+
this.manager.rotate(resolvedDir, maxFiles);
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new BackupError(
|
|
115
|
+
`Invalid cron expression '${cronExpr}': ${err instanceof Error ? err.message : String(err)}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return () => {
|
|
119
|
+
job.stop();
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export { BackupManager, BackupScheduler };
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** Query parameter types: named (object) or positional (array). */
|
|
2
|
+
type Params = Record<string, unknown> | unknown[];
|
|
3
|
+
/** CDC operation type. */
|
|
4
|
+
type ChangeOperation = 'insert' | 'update' | 'delete';
|
|
5
|
+
/** Event emitted when a watched table row changes. */
|
|
6
|
+
interface ChangeEvent<T = Record<string, unknown>> {
|
|
7
|
+
type: ChangeOperation;
|
|
8
|
+
table: string;
|
|
9
|
+
row: T;
|
|
10
|
+
oldRow?: T;
|
|
11
|
+
seq: bigint;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
}
|
|
14
|
+
/** Options for the client SDK. */
|
|
15
|
+
interface ClientOptions {
|
|
16
|
+
/** Transport to use. Default: 'websocket'. */
|
|
17
|
+
transport?: 'websocket' | 'http';
|
|
18
|
+
/** Custom headers for HTTP requests. */
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
/** Reconnect on WebSocket disconnect. Default: true. */
|
|
21
|
+
autoReconnect?: boolean;
|
|
22
|
+
/** Reconnect interval in ms. Default: 1000. */
|
|
23
|
+
reconnectInterval?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Response for a successful query. */
|
|
27
|
+
interface QueryResponse {
|
|
28
|
+
rows: Record<string, unknown>[];
|
|
29
|
+
}
|
|
30
|
+
/** Response for a successful execute. */
|
|
31
|
+
interface ExecuteResponse {
|
|
32
|
+
changes: number;
|
|
33
|
+
lastInsertRowId: number | string;
|
|
34
|
+
}
|
|
35
|
+
/** Response for a successful transaction. */
|
|
36
|
+
interface TransactionResponse {
|
|
37
|
+
results: ExecuteResponse[];
|
|
38
|
+
}
|
|
3
39
|
|
|
4
40
|
/**
|
|
5
41
|
* Transport layer for communicating with a sirannon-db server.
|