@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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { a as SQLiteConnection } from './types-BFSsG77t.js';
|
|
2
|
+
import { C as ChangeEvent } from './types-D-74JiXb.js';
|
|
3
|
+
|
|
4
|
+
interface ChangeTrackerOptions {
|
|
5
|
+
retention?: number;
|
|
6
|
+
changesTable?: string;
|
|
7
|
+
pollBatchSize?: number;
|
|
8
|
+
replication?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare class ChangeTracker {
|
|
12
|
+
private readonly watched;
|
|
13
|
+
private lastSeq;
|
|
14
|
+
private readonly retentionMs;
|
|
15
|
+
private readonly changesTable;
|
|
16
|
+
private readonly pollBatchSize;
|
|
17
|
+
private readonly replication;
|
|
18
|
+
private changesTableReady;
|
|
19
|
+
private watchedTablesCache;
|
|
20
|
+
private readonly stmtCache;
|
|
21
|
+
private pruneBoundary;
|
|
22
|
+
constructor(options?: ChangeTrackerOptions);
|
|
23
|
+
watch(conn: SQLiteConnection, table: string): Promise<void>;
|
|
24
|
+
unwatch(conn: SQLiteConnection, table: string): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Rebuilds CDC triggers for every watched table directly on the supplied
|
|
27
|
+
* connection, without opening a nested transaction.
|
|
28
|
+
*
|
|
29
|
+
* Required when a caller has already issued `BEGIN` on `conn` and runs a
|
|
30
|
+
* DDL statement that changes a watched table's column list: the next DML
|
|
31
|
+
* inside the same transaction must see triggers compiled against the new
|
|
32
|
+
* columns, otherwise CDC `new_data` silently omits them. Callers that are
|
|
33
|
+
* not inside an active transaction may also use this method; CREATE
|
|
34
|
+
* TRIGGER and DROP TRIGGER statements are committed individually by the
|
|
35
|
+
* driver in that case.
|
|
36
|
+
*
|
|
37
|
+
* Failure semantics:
|
|
38
|
+
* - If a watched table no longer exists (e.g. it was just dropped by the
|
|
39
|
+
* DDL), it is skipped. The watched-map entry is left alone so a separate
|
|
40
|
+
* cleanup path can handle it; throwing here would roll back the user's
|
|
41
|
+
* transaction over a benign condition.
|
|
42
|
+
* - If reading column metadata succeeds but the column list is unchanged,
|
|
43
|
+
* no triggers are touched.
|
|
44
|
+
* - If reading column metadata succeeds and the column list differs from
|
|
45
|
+
* the cached one, the existing triggers are dropped and reinstalled on
|
|
46
|
+
* `conn`. The cached column list is updated on success.
|
|
47
|
+
* - Any other error (driver failure, identifier validation failure) is
|
|
48
|
+
* rethrown so the caller's transaction can roll back deterministically.
|
|
49
|
+
*/
|
|
50
|
+
refreshAllTriggersUsingConnection(conn: SQLiteConnection): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Removes the supplied tables from the watched map and drops any leftover
|
|
53
|
+
* CDC triggers carrying their identifier on the supplied connection.
|
|
54
|
+
*
|
|
55
|
+
* Intended to be called after a DDL transaction that dropped one or more
|
|
56
|
+
* watched tables has committed. On rollback the caller must not invoke
|
|
57
|
+
* this method; the rollback semantics rely on the caller discarding its
|
|
58
|
+
* captured drop list before reaching this call.
|
|
59
|
+
*
|
|
60
|
+
* Idempotent: tables not currently in the watched map are silently
|
|
61
|
+
* skipped. `dropCdcTriggers` issues `DROP TRIGGER IF EXISTS` so calling
|
|
62
|
+
* twice in succession produces the same state.
|
|
63
|
+
*
|
|
64
|
+
* Defence in depth: even after a `DROP TABLE`, the in-transaction trigger
|
|
65
|
+
* refresh path may have observed a freshly-created table of the same name
|
|
66
|
+
* (the DROP and CREATE happened inside the same transaction) and
|
|
67
|
+
* re-installed triggers compiled against the new schema. Those triggers
|
|
68
|
+
* are dropped here so the recreated table starts with a clean slate and
|
|
69
|
+
* the caller must explicitly `watch` it again.
|
|
70
|
+
*/
|
|
71
|
+
pruneDroppedTables(conn: SQLiteConnection, tables: readonly string[]): Promise<void>;
|
|
72
|
+
poll(conn: SQLiteConnection): Promise<ChangeEvent[]>;
|
|
73
|
+
advanceToLatest(conn: SQLiteConnection): Promise<void>;
|
|
74
|
+
cleanup(conn: SQLiteConnection): Promise<number>;
|
|
75
|
+
setPruneBoundary(seq: bigint): void;
|
|
76
|
+
clearPruneBoundary(): void;
|
|
77
|
+
get watchedTables(): ReadonlySet<string>;
|
|
78
|
+
private computeSeqBound;
|
|
79
|
+
private assertIdentifier;
|
|
80
|
+
private detectChangesTable;
|
|
81
|
+
private ensureChangesTable;
|
|
82
|
+
private getColumns;
|
|
83
|
+
private getPkColumns;
|
|
84
|
+
private dropTriggers;
|
|
85
|
+
private getStmt;
|
|
86
|
+
private installTriggers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { ChangeTracker as C, type ChangeTrackerOptions as a };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { QueryError } from './chunk-O7BHI3CF.mjs';
|
|
2
|
+
|
|
3
|
+
// src/core/query-executor.ts
|
|
4
|
+
var STATEMENT_CACHE_CAPACITY = 128;
|
|
5
|
+
var statementCaches = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
async function getStatement(conn, sql) {
|
|
7
|
+
let cache = statementCaches.get(conn);
|
|
8
|
+
if (!cache) {
|
|
9
|
+
cache = /* @__PURE__ */ new Map();
|
|
10
|
+
statementCaches.set(conn, cache);
|
|
11
|
+
}
|
|
12
|
+
const cached = cache.get(sql);
|
|
13
|
+
if (cached) {
|
|
14
|
+
cache.delete(sql);
|
|
15
|
+
cache.set(sql, cached);
|
|
16
|
+
return cached;
|
|
17
|
+
}
|
|
18
|
+
const pending = conn.prepare(sql);
|
|
19
|
+
cache.set(sql, pending);
|
|
20
|
+
if (cache.size > STATEMENT_CACHE_CAPACITY) {
|
|
21
|
+
const oldest = cache.keys().next().value;
|
|
22
|
+
if (oldest !== void 0) {
|
|
23
|
+
cache.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return await pending;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
cache.delete(sql);
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function bindParams(params) {
|
|
34
|
+
if (params === void 0) return [];
|
|
35
|
+
if (Array.isArray(params)) return params;
|
|
36
|
+
return [params];
|
|
37
|
+
}
|
|
38
|
+
async function query(conn, sql, params) {
|
|
39
|
+
try {
|
|
40
|
+
const stmt = await getStatement(conn, sql);
|
|
41
|
+
return await stmt.all(...bindParams(params));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function queryOne(conn, sql, params) {
|
|
47
|
+
try {
|
|
48
|
+
const stmt = await getStatement(conn, sql);
|
|
49
|
+
return await stmt.get(...bindParams(params));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function execute(conn, sql, params) {
|
|
55
|
+
try {
|
|
56
|
+
const stmt = await getStatement(conn, sql);
|
|
57
|
+
const result = await stmt.run(...bindParams(params));
|
|
58
|
+
return {
|
|
59
|
+
changes: result.changes,
|
|
60
|
+
lastInsertRowId: result.lastInsertRowId
|
|
61
|
+
};
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function executeBatch(conn, sql, paramsBatch) {
|
|
67
|
+
try {
|
|
68
|
+
const stmt = await getStatement(conn, sql);
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const params of paramsBatch) {
|
|
71
|
+
const result = await stmt.run(...bindParams(params));
|
|
72
|
+
results.push({
|
|
73
|
+
changes: result.changes,
|
|
74
|
+
lastInsertRowId: result.lastInsertRowId
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/core/transaction.ts
|
|
84
|
+
var Transaction = class _Transaction {
|
|
85
|
+
constructor(conn) {
|
|
86
|
+
this.conn = conn;
|
|
87
|
+
}
|
|
88
|
+
_lastInsertRowId = 0;
|
|
89
|
+
async query(sql, params) {
|
|
90
|
+
return query(this.conn, sql, params);
|
|
91
|
+
}
|
|
92
|
+
async execute(sql, params) {
|
|
93
|
+
const result = await execute(this.conn, sql, params);
|
|
94
|
+
this._lastInsertRowId = result.lastInsertRowId;
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
async executeBatch(sql, paramsBatch) {
|
|
98
|
+
const results = await executeBatch(this.conn, sql, paramsBatch);
|
|
99
|
+
if (results.length > 0) {
|
|
100
|
+
this._lastInsertRowId = results[results.length - 1].lastInsertRowId;
|
|
101
|
+
}
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
get lastInsertRowId() {
|
|
105
|
+
return this._lastInsertRowId;
|
|
106
|
+
}
|
|
107
|
+
static async run(conn, fn) {
|
|
108
|
+
return conn.transaction(async (txConn) => {
|
|
109
|
+
const tx = new _Transaction(txConn);
|
|
110
|
+
return fn(tx);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export { Transaction, execute, executeBatch, query, queryOne };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SirannonError } from './chunk-O7BHI3CF.mjs';
|
|
2
|
+
|
|
3
|
+
// src/core/driver/define.ts
|
|
4
|
+
function defineDriver(config) {
|
|
5
|
+
if (!config.capabilities || typeof config.open !== "function") {
|
|
6
|
+
throw new SirannonError("Driver must define capabilities and open()", "INVALID_DRIVER");
|
|
7
|
+
}
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
capabilities: Object.freeze({ ...config.capabilities }),
|
|
10
|
+
open: config.open
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { defineDriver };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/replication/coordinator/compatibility.ts
|
|
2
|
+
function compatibilityAllowsPromotion(groupCompatibility, sessionCompatibility) {
|
|
3
|
+
if (!groupCompatibility) return true;
|
|
4
|
+
return compatibleMajorVersion(groupCompatibility.packageVersion, sessionCompatibility?.packageVersion) && compatibleMajorVersion(groupCompatibility.protocolVersion, sessionCompatibility?.protocolVersion) && compatibleMajorVersion(groupCompatibility.specVersion, sessionCompatibility?.specVersion);
|
|
5
|
+
}
|
|
6
|
+
function compatibleMajorVersion(required, candidate) {
|
|
7
|
+
if (!required) return true;
|
|
8
|
+
if (!candidate) return false;
|
|
9
|
+
const requiredMajor = parseMajorVersion(required);
|
|
10
|
+
const candidateMajor = parseMajorVersion(candidate);
|
|
11
|
+
if (requiredMajor === null || candidateMajor === null) {
|
|
12
|
+
return required === candidate;
|
|
13
|
+
}
|
|
14
|
+
return requiredMajor === candidateMajor;
|
|
15
|
+
}
|
|
16
|
+
function parseMajorVersion(version) {
|
|
17
|
+
const match = /^v?(\d+)/.exec(version);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
const major = match[1];
|
|
20
|
+
return major === void 0 ? null : Number(major);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { compatibilityAllowsPromotion };
|
|
@@ -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,51 @@
|
|
|
1
|
+
// src/core/cdc/encoding.ts
|
|
2
|
+
var BLOB_TAG = "__sirannon_blob";
|
|
3
|
+
var INT_TAG = "__sirannon_int";
|
|
4
|
+
var SAFE_INT_BOUND_TEXT = "9007199254740991";
|
|
5
|
+
var HEX_BYTES_RE = /^[0-9a-fA-F]*$/;
|
|
6
|
+
function decodeTaggedValues(value) {
|
|
7
|
+
if (value === null || typeof value !== "object") return value;
|
|
8
|
+
if (isBinaryValue(value)) return value;
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return value.map(decodeTaggedValues);
|
|
11
|
+
}
|
|
12
|
+
const obj = value;
|
|
13
|
+
const keys = Object.keys(obj);
|
|
14
|
+
if (keys.length === 1) {
|
|
15
|
+
const tag = keys[0];
|
|
16
|
+
const tagValue = obj[tag];
|
|
17
|
+
if (tag === BLOB_TAG && typeof tagValue === "string") {
|
|
18
|
+
return decodeHexBytes(tagValue);
|
|
19
|
+
}
|
|
20
|
+
if (tag === INT_TAG && typeof tagValue === "string") {
|
|
21
|
+
return BigInt(tagValue);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const k of keys) {
|
|
26
|
+
out[k] = decodeTaggedValues(obj[k]);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function isBinaryValue(value) {
|
|
31
|
+
return value instanceof Uint8Array || typeof Buffer !== "undefined" && Buffer.isBuffer(value);
|
|
32
|
+
}
|
|
33
|
+
function decodeHexBytes(hex) {
|
|
34
|
+
if (hex.length % 2 !== 0 || !HEX_BYTES_RE.test(hex)) {
|
|
35
|
+
throw new Error("Invalid CDC BLOB hex payload");
|
|
36
|
+
}
|
|
37
|
+
if (typeof Buffer !== "undefined") {
|
|
38
|
+
return Buffer.from(hex, "hex");
|
|
39
|
+
}
|
|
40
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
41
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
42
|
+
const byte = Number.parseInt(hex.slice(i, i + 2), 16);
|
|
43
|
+
if (Number.isNaN(byte)) {
|
|
44
|
+
throw new Error("Invalid CDC BLOB hex payload");
|
|
45
|
+
}
|
|
46
|
+
bytes[i / 2] = byte;
|
|
47
|
+
}
|
|
48
|
+
return bytes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { SAFE_INT_BOUND_TEXT, decodeTaggedValues };
|
|
@@ -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 };
|