@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.
Files changed (51) hide show
  1. package/README.md +655 -80
  2. package/dist/backup-scheduler/index.d.ts +3 -0
  3. package/dist/backup-scheduler/index.mjs +2 -0
  4. package/dist/change-tracker-CFTQ9TSn.d.ts +89 -0
  5. package/dist/chunk-3MCMONVP.mjs +115 -0
  6. package/dist/chunk-74UN4DIE.mjs +14 -0
  7. package/dist/chunk-ER7ODTDA.mjs +23 -0
  8. package/dist/chunk-FB2U2Q3Y.mjs +21 -0
  9. package/dist/chunk-GS7T5YMI.mjs +51 -0
  10. package/dist/chunk-O7BHI3CF.mjs +90 -0
  11. package/dist/chunk-PXKAKK2V.mjs +124 -0
  12. package/dist/chunk-UTO3ZAFS.mjs +514 -0
  13. package/dist/chunk-UVMVN3OT.mjs +111 -0
  14. package/dist/client/index.d.ts +137 -44
  15. package/dist/client/index.mjs +726 -26
  16. package/dist/core/index.d.ts +32 -241
  17. package/dist/core/index.mjs +294 -568
  18. package/dist/database-BVY1GqE7.d.ts +95 -0
  19. package/dist/driver/better-sqlite3.d.ts +8 -0
  20. package/dist/driver/better-sqlite3.mjs +63 -0
  21. package/dist/driver/bun.mjs +61 -0
  22. package/dist/driver/expo.mjs +55 -0
  23. package/dist/driver/node.d.ts +8 -0
  24. package/dist/driver/node.mjs +60 -0
  25. package/dist/driver/wa-sqlite.d.ts +34 -0
  26. package/dist/driver/wa-sqlite.mjs +141 -0
  27. package/dist/errors-C00ed08Q.d.ts +101 -0
  28. package/dist/file-migrations/index.d.ts +16 -0
  29. package/dist/file-migrations/index.mjs +128 -0
  30. package/dist/index-CLdNrcPz.d.ts +16 -0
  31. package/dist/replication/coordinator/etcd.d.ts +44 -0
  32. package/dist/replication/coordinator/etcd.mjs +650 -0
  33. package/dist/replication/index.d.ts +491 -0
  34. package/dist/replication/index.mjs +3784 -0
  35. package/dist/server/index.d.ts +121 -54
  36. package/dist/server/index.mjs +347 -114
  37. package/dist/sirannon-Cd-lK6T0.d.ts +31 -0
  38. package/dist/transport/grpc.d.ts +316 -0
  39. package/dist/transport/grpc.mjs +3341 -0
  40. package/dist/transport/memory.d.ts +221 -0
  41. package/dist/transport/memory.mjs +337 -0
  42. package/dist/types-B2byqt0B.d.ts +273 -0
  43. package/dist/types-BEu1I_9_.d.ts +139 -0
  44. package/dist/types-BFSsG77t.d.ts +29 -0
  45. package/dist/types-BeozgNPr.d.ts +26 -0
  46. package/dist/{types-DArCObcu.d.ts → types-D-74JiXb.d.ts} +80 -1
  47. package/dist/vfs-INWQ5DTE.mjs +2 -0
  48. package/package.json +106 -11
  49. package/dist/chunk-VI4UP4RR.mjs +0 -417
  50. package/dist/protocol-BX1H-_Mz.d.ts +0 -104
  51. package/dist/sirannon-BJ8Yd1Uf.d.ts +0 -148
@@ -0,0 +1,3 @@
1
+ export { a as BackupScheduler } from '../index-CLdNrcPz.js';
2
+ export { m as BackupScheduleOptions } from '../types-D-74JiXb.js';
3
+ import '../types-BFSsG77t.js';
@@ -0,0 +1,2 @@
1
+ export { BackupScheduler } from '../chunk-PXKAKK2V.mjs';
2
+ import '../chunk-O7BHI3CF.mjs';
@@ -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 };