@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.
@@ -0,0 +1,1346 @@
1
+ import { BackupError, ConnectionPoolError, QueryError, MigrationError, ReadOnlyError, SubscriptionBuilderImpl, ExtensionError, SirannonError, ChangeTracker, SubscriptionManager, startPolling, MaxDatabasesError, DatabaseAlreadyExistsError, DatabaseNotFoundError } from '../chunk-VI4UP4RR.mjs';
2
+ export { BackupError, CDCError, ConnectionPoolError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError, HookDeniedError, MaxDatabasesError, MigrationError, QueryError, ReadOnlyError, SirannonError, TransactionError } from '../chunk-VI4UP4RR.mjs';
3
+ import { existsSync, mkdirSync, rmSync, readdirSync, lstatSync, readFileSync, statSync } from 'fs';
4
+ import { resolve, dirname, join } from 'path';
5
+ import { Cron } from 'croner';
6
+ import Database from 'better-sqlite3';
7
+
8
+ var BACKUP_FILE_PREFIX = "backup";
9
+ function hasControlCharacters(s) {
10
+ for (let i = 0; i < s.length; i++) {
11
+ const code = s.charCodeAt(i);
12
+ if (code <= 31 && code !== 9 && code !== 10 && code !== 13) {
13
+ return true;
14
+ }
15
+ }
16
+ return false;
17
+ }
18
+ var BackupManager = class {
19
+ /**
20
+ * Creates a one-shot backup of the database using VACUUM INTO.
21
+ * Produces a fresh, defragmented copy at the specified destination path.
22
+ *
23
+ * The destination file must not already exist. Parent directories are
24
+ * created automatically when missing.
25
+ *
26
+ * Note: there is a narrow TOCTOU window between the existence check and
27
+ * the VACUUM INTO statement. In the unlikely event that another process
28
+ * creates a file at the same path during that window, VACUUM INTO may
29
+ * silently overwrite it depending on the SQLite version.
30
+ */
31
+ backup(db, destPath) {
32
+ if (hasControlCharacters(destPath)) {
33
+ throw new BackupError("Backup path contains invalid characters");
34
+ }
35
+ const resolved = resolve(destPath);
36
+ const dir = dirname(resolved);
37
+ if (!existsSync(dir)) {
38
+ try {
39
+ mkdirSync(dir, { recursive: true });
40
+ } catch (err) {
41
+ throw new BackupError(
42
+ `Failed to create backup directory '${dir}': ${err instanceof Error ? err.message : String(err)}`
43
+ );
44
+ }
45
+ }
46
+ if (existsSync(resolved)) {
47
+ throw new BackupError(`Backup destination '${destPath}' already exists`);
48
+ }
49
+ const escaped = resolved.replace(/'/g, "''");
50
+ try {
51
+ db.exec(`VACUUM INTO '${escaped}'`);
52
+ } catch (err) {
53
+ try {
54
+ rmSync(resolved, { force: true });
55
+ } catch {
56
+ }
57
+ throw new BackupError(`Backup to '${destPath}' failed: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ }
60
+ /**
61
+ * Generates a timestamped backup filename.
62
+ *
63
+ * Format: `backup-YYYY-MM-DDTHH-MM-SS-mmmZ.db`
64
+ *
65
+ * Colons and dots in the ISO timestamp are replaced with hyphens so the
66
+ * filename is safe on every major filesystem.
67
+ */
68
+ generateFilename() {
69
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
70
+ return `${BACKUP_FILE_PREFIX}-${ts}.db`;
71
+ }
72
+ /**
73
+ * Removes old backup files in {@link dir}, keeping the {@link maxFiles}
74
+ * most recent entries. Only files matching the `backup-*.db` naming
75
+ * convention are considered; other files in the directory are left alone.
76
+ *
77
+ * When {@link maxFiles} is zero or negative the method is a no-op.
78
+ * A non-existent directory is silently ignored.
79
+ */
80
+ rotate(dir, maxFiles) {
81
+ if (maxFiles <= 0) return;
82
+ const resolved = resolve(dir);
83
+ if (!existsSync(resolved)) return;
84
+ let entries;
85
+ try {
86
+ entries = readdirSync(resolved).filter((f) => f.startsWith(`${BACKUP_FILE_PREFIX}-`) && f.endsWith(".db")).map((f) => {
87
+ const filePath = join(resolved, f);
88
+ return { path: filePath, mtimeMs: lstatSync(filePath).mtimeMs };
89
+ }).sort((a, b) => b.mtimeMs - a.mtimeMs);
90
+ } catch (err) {
91
+ throw new BackupError(
92
+ `Failed to list backup files in '${dir}': ${err instanceof Error ? err.message : String(err)}`
93
+ );
94
+ }
95
+ for (const entry of entries.slice(maxFiles)) {
96
+ try {
97
+ rmSync(entry.path, { force: true });
98
+ } catch {
99
+ }
100
+ }
101
+ }
102
+ };
103
+ var DEFAULT_MAX_FILES = 5;
104
+ var BackupScheduler = class {
105
+ manager;
106
+ constructor(manager) {
107
+ this.manager = manager ?? new BackupManager();
108
+ }
109
+ /**
110
+ * Schedules periodic backups on a cron expression.
111
+ *
112
+ * Each tick creates a timestamped backup file inside {@link options.destDir}
113
+ * and rotates old files so no more than {@link options.maxFiles} (default 5)
114
+ * are retained.
115
+ *
116
+ * Provide {@link options.onError} to receive notification when a scheduled
117
+ * backup fails. Without it, errors are silently discarded to prevent
118
+ * unhandled exceptions from crashing the process.
119
+ *
120
+ * The underlying timer is unreferenced so it won't keep the Node.js
121
+ * process alive on its own (consistent with the CDC polling timer).
122
+ *
123
+ * Returns a cancel function that stops the scheduled job immediately.
124
+ */
125
+ schedule(db, options) {
126
+ const { cron: cronExpr, destDir, maxFiles = DEFAULT_MAX_FILES, onError } = options;
127
+ const resolvedDir = resolve(destDir);
128
+ if (!existsSync(resolvedDir)) {
129
+ try {
130
+ mkdirSync(resolvedDir, { recursive: true });
131
+ } catch (err) {
132
+ throw new BackupError(
133
+ `Failed to create backup directory '${destDir}': ${err instanceof Error ? err.message : String(err)}`
134
+ );
135
+ }
136
+ }
137
+ let job;
138
+ try {
139
+ job = new Cron(
140
+ cronExpr,
141
+ {
142
+ unref: true,
143
+ catch: (err) => {
144
+ if (onError) {
145
+ const error = err instanceof Error ? err : new BackupError(typeof err === "string" ? err : "Scheduled backup failed");
146
+ onError(error);
147
+ }
148
+ }
149
+ },
150
+ () => {
151
+ const filename = this.manager.generateFilename();
152
+ const destPath = join(resolvedDir, filename);
153
+ this.manager.backup(db, destPath);
154
+ this.manager.rotate(resolvedDir, maxFiles);
155
+ }
156
+ );
157
+ } catch (err) {
158
+ throw new BackupError(
159
+ `Invalid cron expression '${cronExpr}': ${err instanceof Error ? err.message : String(err)}`
160
+ );
161
+ }
162
+ return () => {
163
+ job.stop();
164
+ };
165
+ }
166
+ };
167
+ function closeAllSilently(connections) {
168
+ for (const conn of connections) {
169
+ if (!conn) continue;
170
+ try {
171
+ conn.close();
172
+ } catch {
173
+ }
174
+ }
175
+ }
176
+ var ConnectionPool = class {
177
+ writer;
178
+ readers;
179
+ readerIndex = 0;
180
+ closed = false;
181
+ constructor(options) {
182
+ const { path, readOnly = false, readPoolSize = 4, walMode = true } = options;
183
+ let writer = null;
184
+ const readers = [];
185
+ try {
186
+ if (!readOnly) {
187
+ writer = new Database(path);
188
+ if (walMode) {
189
+ writer.pragma("journal_mode = WAL");
190
+ }
191
+ writer.pragma("synchronous = NORMAL");
192
+ writer.pragma("foreign_keys = ON");
193
+ writer.pragma("busy_timeout = 5000");
194
+ }
195
+ const poolSize = Math.max(readPoolSize, 1);
196
+ for (let i = 0; i < poolSize; i++) {
197
+ const reader = new Database(path, { readonly: true });
198
+ readers.push(reader);
199
+ reader.pragma("foreign_keys = ON");
200
+ }
201
+ } catch (err) {
202
+ closeAllSilently([...readers, writer]);
203
+ throw err;
204
+ }
205
+ this.writer = writer;
206
+ this.readers = readers;
207
+ }
208
+ acquireReader() {
209
+ if (this.closed) {
210
+ throw new ConnectionPoolError("Connection pool is closed");
211
+ }
212
+ const reader = this.readers[this.readerIndex % this.readers.length];
213
+ this.readerIndex = (this.readerIndex + 1) % this.readers.length;
214
+ return reader;
215
+ }
216
+ acquireWriter() {
217
+ if (this.closed) {
218
+ throw new ConnectionPoolError("Connection pool is closed");
219
+ }
220
+ if (!this.writer) {
221
+ throw new ConnectionPoolError("No write connection available (database is read-only)");
222
+ }
223
+ return this.writer;
224
+ }
225
+ get readerCount() {
226
+ return this.readers.length;
227
+ }
228
+ get isReadOnly() {
229
+ return this.writer === null;
230
+ }
231
+ loadExtension(extensionPath) {
232
+ if (this.closed) {
233
+ throw new ConnectionPoolError("Connection pool is closed");
234
+ }
235
+ if (this.writer) {
236
+ this.writer.loadExtension(extensionPath);
237
+ }
238
+ for (const reader of this.readers) {
239
+ reader.loadExtension(extensionPath);
240
+ }
241
+ }
242
+ close() {
243
+ if (this.closed) return;
244
+ this.closed = true;
245
+ const errors = [];
246
+ for (const reader of this.readers) {
247
+ try {
248
+ reader.close();
249
+ } catch (err) {
250
+ errors.push(err);
251
+ }
252
+ }
253
+ if (this.writer) {
254
+ try {
255
+ this.writer.close();
256
+ } catch (err) {
257
+ errors.push(err);
258
+ }
259
+ }
260
+ if (errors.length > 0) {
261
+ throw new ConnectionPoolError(`Failed to close ${errors.length} connection(s)`);
262
+ }
263
+ }
264
+ };
265
+
266
+ // src/core/hooks/registry.ts
267
+ var HOOK_CONFIG_MAP = {
268
+ onBeforeQuery: "beforeQuery",
269
+ onAfterQuery: "afterQuery",
270
+ onBeforeConnect: "beforeConnect",
271
+ onDatabaseOpen: "databaseOpen",
272
+ onDatabaseClose: "databaseClose",
273
+ onBeforeSubscribe: "beforeSubscribe"
274
+ };
275
+ var HookRegistry = class {
276
+ hooks = /* @__PURE__ */ new Map();
277
+ constructor(config) {
278
+ if (config) {
279
+ this.loadConfig(config);
280
+ }
281
+ }
282
+ register(event, hook) {
283
+ const stored = hook;
284
+ this.addHook(event, stored);
285
+ let disposed = false;
286
+ return () => {
287
+ if (disposed) return;
288
+ disposed = true;
289
+ const current = this.hooks.get(event);
290
+ if (!current) return;
291
+ const idx = current.indexOf(stored);
292
+ if (idx !== -1) current.splice(idx, 1);
293
+ };
294
+ }
295
+ async invoke(event, ctx) {
296
+ const list = this.hooks.get(event);
297
+ if (!list || list.length === 0) return;
298
+ const snapshot = list.slice();
299
+ for (const hook of snapshot) {
300
+ await hook(ctx);
301
+ }
302
+ }
303
+ invokeSync(event, ctx) {
304
+ const list = this.hooks.get(event);
305
+ if (!list || list.length === 0) return;
306
+ const snapshot = list.slice();
307
+ for (const hook of snapshot) {
308
+ const result = hook(ctx);
309
+ if (result != null && typeof result.then === "function") {
310
+ throw new Error(`Hook for '${event}' returned a Promise. Use invoke() for async hooks.`);
311
+ }
312
+ }
313
+ }
314
+ has(event) {
315
+ const list = this.hooks.get(event);
316
+ return list !== void 0 && list.length > 0;
317
+ }
318
+ count(event) {
319
+ return this.hooks.get(event)?.length ?? 0;
320
+ }
321
+ clear(event) {
322
+ if (event) {
323
+ this.hooks.delete(event);
324
+ } else {
325
+ this.hooks.clear();
326
+ }
327
+ }
328
+ addHook(event, hook) {
329
+ const list = this.hooks.get(event);
330
+ if (list) {
331
+ list.push(hook);
332
+ } else {
333
+ this.hooks.set(event, [hook]);
334
+ }
335
+ }
336
+ loadConfig(config) {
337
+ for (const [configKey, event] of Object.entries(HOOK_CONFIG_MAP)) {
338
+ const value = config[configKey];
339
+ if (!value) continue;
340
+ const hooks = Array.isArray(value) ? value : [value];
341
+ for (const hook of hooks) {
342
+ this.addHook(event, hook);
343
+ }
344
+ }
345
+ }
346
+ };
347
+
348
+ // src/core/query-executor.ts
349
+ var STATEMENT_CACHE_CAPACITY = 128;
350
+ var statementCaches = /* @__PURE__ */ new WeakMap();
351
+ function getStatement(db, sql) {
352
+ let cache = statementCaches.get(db);
353
+ if (!cache) {
354
+ cache = /* @__PURE__ */ new Map();
355
+ statementCaches.set(db, cache);
356
+ }
357
+ const cached = cache.get(sql);
358
+ if (cached) {
359
+ cache.delete(sql);
360
+ cache.set(sql, cached);
361
+ return cached;
362
+ }
363
+ const stmt = db.prepare(sql);
364
+ cache.set(sql, stmt);
365
+ if (cache.size > STATEMENT_CACHE_CAPACITY) {
366
+ const oldest = cache.keys().next().value;
367
+ if (oldest !== void 0) {
368
+ cache.delete(oldest);
369
+ }
370
+ }
371
+ return stmt;
372
+ }
373
+ function bindParams(params) {
374
+ if (params === void 0) return [];
375
+ if (Array.isArray(params)) return params;
376
+ return [params];
377
+ }
378
+ function query(db, sql, params) {
379
+ try {
380
+ const stmt = getStatement(db, sql);
381
+ return stmt.all(...bindParams(params));
382
+ } catch (err) {
383
+ throw new QueryError(err instanceof Error ? err.message : String(err), sql);
384
+ }
385
+ }
386
+ function queryOne(db, sql, params) {
387
+ try {
388
+ const stmt = getStatement(db, sql);
389
+ return stmt.get(...bindParams(params));
390
+ } catch (err) {
391
+ throw new QueryError(err instanceof Error ? err.message : String(err), sql);
392
+ }
393
+ }
394
+ function execute(db, sql, params) {
395
+ try {
396
+ const stmt = getStatement(db, sql);
397
+ const result = stmt.run(...bindParams(params));
398
+ return {
399
+ changes: result.changes,
400
+ lastInsertRowId: result.lastInsertRowid
401
+ };
402
+ } catch (err) {
403
+ throw new QueryError(err instanceof Error ? err.message : String(err), sql);
404
+ }
405
+ }
406
+ function executeBatch(db, sql, paramsBatch) {
407
+ try {
408
+ const stmt = getStatement(db, sql);
409
+ const run = db.transaction(() => {
410
+ const results = [];
411
+ for (const params of paramsBatch) {
412
+ const result = stmt.run(...bindParams(params));
413
+ results.push({
414
+ changes: result.changes,
415
+ lastInsertRowId: result.lastInsertRowid
416
+ });
417
+ }
418
+ return results;
419
+ });
420
+ return run();
421
+ } catch (err) {
422
+ throw new QueryError(err instanceof Error ? err.message : String(err), sql);
423
+ }
424
+ }
425
+
426
+ // src/core/transaction.ts
427
+ var Transaction = class _Transaction {
428
+ constructor(db) {
429
+ this.db = db;
430
+ }
431
+ _lastInsertRowId = 0;
432
+ query(sql, params) {
433
+ return query(this.db, sql, params);
434
+ }
435
+ execute(sql, params) {
436
+ const result = execute(this.db, sql, params);
437
+ this._lastInsertRowId = result.lastInsertRowId;
438
+ return result;
439
+ }
440
+ executeBatch(sql, paramsBatch) {
441
+ const results = executeBatch(this.db, sql, paramsBatch);
442
+ if (results.length > 0) {
443
+ this._lastInsertRowId = results[results.length - 1].lastInsertRowId;
444
+ }
445
+ return results;
446
+ }
447
+ get lastInsertRowId() {
448
+ return this._lastInsertRowId;
449
+ }
450
+ static run(db, fn) {
451
+ const tx = new _Transaction(db);
452
+ return db.transaction(() => fn(tx))();
453
+ }
454
+ };
455
+ var MIGRATION_FILENAME_PATTERN = /^(\d+)_(\w+)\.(up|down)\.sql$/;
456
+ function scanDirectory(dirPath) {
457
+ if (dirPath.includes("\0")) {
458
+ throw new MigrationError("Migration path contains null bytes", 0, "MIGRATION_VALIDATION_ERROR");
459
+ }
460
+ const segments = dirPath.split(/[/\\]/);
461
+ if (segments.includes("..")) {
462
+ throw new MigrationError(
463
+ "Migration path must not contain directory traversal segments",
464
+ 0,
465
+ "MIGRATION_VALIDATION_ERROR"
466
+ );
467
+ }
468
+ const resolvedPath = resolve(dirPath);
469
+ let stat;
470
+ try {
471
+ stat = statSync(resolvedPath);
472
+ } catch {
473
+ throw new MigrationError(`Migrations path does not exist: ${resolvedPath}`, 0);
474
+ }
475
+ if (!stat.isDirectory()) {
476
+ throw new MigrationError(`Migrations path is not a directory: ${resolvedPath}`, 0);
477
+ }
478
+ const entries = readdirSync(resolvedPath, { withFileTypes: true });
479
+ const grouped = /* @__PURE__ */ new Map();
480
+ for (const entry of entries) {
481
+ if (!entry.isFile()) continue;
482
+ const match = MIGRATION_FILENAME_PATTERN.exec(entry.name);
483
+ if (!match) continue;
484
+ const version = parseInt(match[1], 10);
485
+ const name = match[2];
486
+ const direction = match[3];
487
+ if (!Number.isFinite(version) || version <= 0 || !Number.isSafeInteger(version)) {
488
+ throw new MigrationError(`Invalid migration version: ${match[1]}`, version, "MIGRATION_VALIDATION_ERROR");
489
+ }
490
+ const existing = grouped.get(version);
491
+ if (existing && existing.name !== name) {
492
+ throw new MigrationError(
493
+ `Duplicate migration version ${version}: '${existing.name}' and '${name}'`,
494
+ version,
495
+ "MIGRATION_DUPLICATE_VERSION"
496
+ );
497
+ }
498
+ const filePath = join(resolvedPath, entry.name);
499
+ if (!existing) {
500
+ grouped.set(version, {
501
+ name,
502
+ [direction === "up" ? "upPath" : "downPath"]: filePath
503
+ });
504
+ } else {
505
+ if (direction === "up") existing.upPath = filePath;
506
+ else existing.downPath = filePath;
507
+ }
508
+ }
509
+ const results = [];
510
+ for (const [version, entry] of grouped) {
511
+ if (!entry.upPath) {
512
+ throw new MigrationError(
513
+ `Migration version ${version} (${entry.name}) is missing an .up.sql file`,
514
+ version,
515
+ "MIGRATION_VALIDATION_ERROR"
516
+ );
517
+ }
518
+ results.push({
519
+ version,
520
+ name: entry.name,
521
+ upPath: entry.upPath,
522
+ downPath: entry.downPath ?? null
523
+ });
524
+ }
525
+ results.sort((a, b) => a.version - b.version);
526
+ return results;
527
+ }
528
+ function readUpMigrations(scanned) {
529
+ return scanned.map((entry) => {
530
+ const sql = readFileSync(entry.upPath, "utf-8").trim();
531
+ if (sql.length === 0) {
532
+ throw new MigrationError(`Migration file is empty: ${entry.upPath}`, entry.version, "MIGRATION_VALIDATION_ERROR");
533
+ }
534
+ return {
535
+ version: entry.version,
536
+ name: entry.name,
537
+ up: sql
538
+ };
539
+ });
540
+ }
541
+ function readDownMigrations(scanned, versions) {
542
+ const versionSet = new Set(versions);
543
+ const filtered = scanned.filter((s) => versionSet.has(s.version));
544
+ return filtered.map((entry) => {
545
+ if (!entry.downPath) {
546
+ throw new MigrationError(
547
+ `Migration version ${entry.version} (${entry.name}) has no .down.sql file`,
548
+ entry.version,
549
+ "MIGRATION_NO_DOWN"
550
+ );
551
+ }
552
+ const downSql = readFileSync(entry.downPath, "utf-8").trim();
553
+ if (downSql.length === 0) {
554
+ throw new MigrationError(
555
+ `Down migration file is empty: ${entry.downPath}`,
556
+ entry.version,
557
+ "MIGRATION_VALIDATION_ERROR"
558
+ );
559
+ }
560
+ return {
561
+ version: entry.version,
562
+ name: entry.name,
563
+ up: "",
564
+ down: downSql
565
+ };
566
+ });
567
+ }
568
+
569
+ // src/core/migrations/runner.ts
570
+ var CREATE_TRACKING_TABLE = `
571
+ CREATE TABLE IF NOT EXISTS _sirannon_migrations (
572
+ version INTEGER PRIMARY KEY,
573
+ name TEXT NOT NULL,
574
+ applied_at REAL NOT NULL DEFAULT (unixepoch('subsec'))
575
+ )
576
+ `;
577
+ var MigrationRunner = class _MigrationRunner {
578
+ static run(db, input) {
579
+ db.exec(CREATE_TRACKING_TABLE);
580
+ const migrations = typeof input === "string" ? readUpMigrations(scanDirectory(input)) : _MigrationRunner.validateMigrations(input);
581
+ const applied = _MigrationRunner.getAppliedVersions(db);
582
+ const pending = migrations.filter((m) => !applied.has(m.version));
583
+ if (pending.length === 0) {
584
+ return { applied: [], skipped: migrations.length };
585
+ }
586
+ const insertMigration = db.prepare("INSERT INTO _sirannon_migrations (version, name) VALUES (?, ?)");
587
+ const appliedEntries = [];
588
+ db.transaction(() => {
589
+ for (const migration of pending) {
590
+ try {
591
+ if (typeof migration.up === "string") {
592
+ db.exec(migration.up);
593
+ } else {
594
+ migration.up(new Transaction(db));
595
+ }
596
+ } catch (err) {
597
+ if (err instanceof MigrationError) throw err;
598
+ throw new MigrationError(
599
+ `Migration ${migration.version}_${migration.name} failed: ${err instanceof Error ? err.message : String(err)}`,
600
+ migration.version
601
+ );
602
+ }
603
+ insertMigration.run(migration.version, migration.name);
604
+ appliedEntries.push({ version: migration.version, name: migration.name });
605
+ }
606
+ })();
607
+ return {
608
+ applied: appliedEntries,
609
+ skipped: migrations.length - pending.length
610
+ };
611
+ }
612
+ static rollback(db, input, version) {
613
+ if (version !== void 0 && (!Number.isSafeInteger(version) || version < 0)) {
614
+ throw new MigrationError(
615
+ `Invalid rollback target version: ${version}`,
616
+ typeof version === "number" && Number.isFinite(version) ? version : 0,
617
+ "MIGRATION_VALIDATION_ERROR"
618
+ );
619
+ }
620
+ db.exec(CREATE_TRACKING_TABLE);
621
+ const appliedRows = db.prepare("SELECT version, name FROM _sirannon_migrations ORDER BY version DESC").all();
622
+ if (appliedRows.length === 0) {
623
+ return { rolledBack: [] };
624
+ }
625
+ let rollbackSet;
626
+ if (version === void 0) {
627
+ rollbackSet = [appliedRows[0]];
628
+ } else {
629
+ rollbackSet = appliedRows.filter((row) => row.version > version);
630
+ }
631
+ if (rollbackSet.length === 0) {
632
+ return { rolledBack: [] };
633
+ }
634
+ const rollbackVersions = rollbackSet.map((r) => r.version);
635
+ let downByVersion;
636
+ if (typeof input === "string") {
637
+ const scanned = scanDirectory(input);
638
+ const downMigrations = readDownMigrations(scanned, rollbackVersions);
639
+ downByVersion = new Map(downMigrations.map((m) => [m.version, m]));
640
+ } else {
641
+ const inputByVersion = new Map(input.map((m) => [m.version, m]));
642
+ downByVersion = /* @__PURE__ */ new Map();
643
+ for (const v of rollbackVersions) {
644
+ const m = inputByVersion.get(v);
645
+ if (!m || m.down === void 0) {
646
+ throw new MigrationError(`Migration version ${v} has no down migration`, v, "MIGRATION_NO_DOWN");
647
+ }
648
+ downByVersion.set(v, m);
649
+ }
650
+ }
651
+ const deleteMigration = db.prepare("DELETE FROM _sirannon_migrations WHERE version = ?");
652
+ const rolledBackEntries = [];
653
+ db.transaction(() => {
654
+ for (const entry of rollbackSet) {
655
+ const migration = downByVersion.get(entry.version);
656
+ if (!migration) {
657
+ throw new MigrationError(
658
+ `No down migration found for version ${entry.version}`,
659
+ entry.version,
660
+ "MIGRATION_ROLLBACK_ERROR"
661
+ );
662
+ }
663
+ try {
664
+ if (typeof migration.down === "string") {
665
+ db.exec(migration.down);
666
+ } else {
667
+ migration.down?.(new Transaction(db));
668
+ }
669
+ } catch (err) {
670
+ if (err instanceof MigrationError) throw err;
671
+ throw new MigrationError(
672
+ `Rollback of migration ${entry.version}_${entry.name} failed: ${err instanceof Error ? err.message : String(err)}`,
673
+ entry.version,
674
+ "MIGRATION_ROLLBACK_ERROR"
675
+ );
676
+ }
677
+ deleteMigration.run(entry.version);
678
+ rolledBackEntries.push({ version: entry.version, name: entry.name });
679
+ }
680
+ })();
681
+ return { rolledBack: rolledBackEntries };
682
+ }
683
+ static validateMigrations(migrations) {
684
+ const seenVersions = /* @__PURE__ */ new Map();
685
+ for (const m of migrations) {
686
+ if (!Number.isSafeInteger(m.version) || m.version <= 0) {
687
+ throw new MigrationError(
688
+ `Invalid migration version: ${m.version}`,
689
+ typeof m.version === "number" && Number.isFinite(m.version) ? m.version : 0,
690
+ "MIGRATION_VALIDATION_ERROR"
691
+ );
692
+ }
693
+ if (!/^\w+$/.test(m.name)) {
694
+ throw new MigrationError(`Invalid migration name: '${m.name}'`, m.version, "MIGRATION_VALIDATION_ERROR");
695
+ }
696
+ const existing = seenVersions.get(m.version);
697
+ if (existing) {
698
+ throw new MigrationError(
699
+ `Duplicate migration version ${m.version}: '${existing}' and '${m.name}'`,
700
+ m.version,
701
+ "MIGRATION_DUPLICATE_VERSION"
702
+ );
703
+ }
704
+ seenVersions.set(m.version, m.name);
705
+ if (typeof m.up === "string" && m.up.trim().length === 0) {
706
+ throw new MigrationError(
707
+ `Migration ${m.version}_${m.name} has empty up SQL`,
708
+ m.version,
709
+ "MIGRATION_VALIDATION_ERROR"
710
+ );
711
+ }
712
+ if (m.down !== void 0 && typeof m.down === "string" && m.down.trim().length === 0) {
713
+ throw new MigrationError(
714
+ `Migration ${m.version}_${m.name} has empty down SQL`,
715
+ m.version,
716
+ "MIGRATION_VALIDATION_ERROR"
717
+ );
718
+ }
719
+ }
720
+ return [...migrations].sort((a, b) => a.version - b.version);
721
+ }
722
+ static getAppliedVersions(db) {
723
+ const rows = db.prepare("SELECT version FROM _sirannon_migrations ORDER BY version").all();
724
+ return new Set(rows.map((r) => r.version));
725
+ }
726
+ };
727
+
728
+ // src/core/database.ts
729
+ var Database2 = class {
730
+ id;
731
+ path;
732
+ readOnly;
733
+ pool;
734
+ closeListeners = [];
735
+ _closed = false;
736
+ changeTracker = null;
737
+ subscriptionManager = null;
738
+ stopCdcPolling = null;
739
+ cdcPollInterval;
740
+ cdcRetention;
741
+ hookRegistry = new HookRegistry();
742
+ parentHooks;
743
+ metricsCollector;
744
+ backupManager = new BackupManager();
745
+ backupScheduler = new BackupScheduler(this.backupManager);
746
+ scheduledBackupCancellers = [];
747
+ constructor(id, path, options, internals) {
748
+ this.id = id;
749
+ this.path = path;
750
+ this.readOnly = options?.readOnly ?? false;
751
+ this.cdcPollInterval = options?.cdcPollInterval ?? 50;
752
+ this.cdcRetention = options?.cdcRetention ?? 36e5;
753
+ this.parentHooks = internals?.parentHooks ?? null;
754
+ this.metricsCollector = internals?.metrics ?? null;
755
+ this.pool = new ConnectionPool({
756
+ path,
757
+ readOnly: this.readOnly,
758
+ readPoolSize: options?.readPoolSize ?? 4,
759
+ walMode: options?.walMode ?? true
760
+ });
761
+ }
762
+ query(sql, params) {
763
+ this.ensureOpen();
764
+ this.fireBeforeQueryHooks(sql, params);
765
+ const start = performance.now();
766
+ try {
767
+ const reader = this.pool.acquireReader();
768
+ if (this.metricsCollector) {
769
+ return this.metricsCollector.trackQuery(() => query(reader, sql, params), {
770
+ databaseId: this.id,
771
+ sql
772
+ });
773
+ }
774
+ return query(reader, sql, params);
775
+ } finally {
776
+ this.fireAfterQueryHooks(sql, params, performance.now() - start);
777
+ }
778
+ }
779
+ queryOne(sql, params) {
780
+ this.ensureOpen();
781
+ this.fireBeforeQueryHooks(sql, params);
782
+ const start = performance.now();
783
+ try {
784
+ const reader = this.pool.acquireReader();
785
+ if (this.metricsCollector) {
786
+ return this.metricsCollector.trackQuery(() => queryOne(reader, sql, params), {
787
+ databaseId: this.id,
788
+ sql
789
+ });
790
+ }
791
+ return queryOne(reader, sql, params);
792
+ } finally {
793
+ this.fireAfterQueryHooks(sql, params, performance.now() - start);
794
+ }
795
+ }
796
+ execute(sql, params) {
797
+ this.ensureOpen();
798
+ this.fireBeforeQueryHooks(sql, params);
799
+ const start = performance.now();
800
+ try {
801
+ const writer = this.pool.acquireWriter();
802
+ if (this.metricsCollector) {
803
+ return this.metricsCollector.trackQuery(() => execute(writer, sql, params), {
804
+ databaseId: this.id,
805
+ sql
806
+ });
807
+ }
808
+ return execute(writer, sql, params);
809
+ } finally {
810
+ this.fireAfterQueryHooks(sql, params, performance.now() - start);
811
+ }
812
+ }
813
+ executeBatch(sql, paramsBatch) {
814
+ this.ensureOpen();
815
+ this.fireBeforeQueryHooks(sql);
816
+ const start = performance.now();
817
+ try {
818
+ const writer = this.pool.acquireWriter();
819
+ if (this.metricsCollector) {
820
+ return this.metricsCollector.trackQuery(() => executeBatch(writer, sql, paramsBatch), {
821
+ databaseId: this.id,
822
+ sql
823
+ });
824
+ }
825
+ return executeBatch(writer, sql, paramsBatch);
826
+ } finally {
827
+ this.fireAfterQueryHooks(sql, void 0, performance.now() - start);
828
+ }
829
+ }
830
+ transaction(fn) {
831
+ this.ensureOpen();
832
+ const writer = this.pool.acquireWriter();
833
+ return Transaction.run(writer, fn);
834
+ }
835
+ watch(table) {
836
+ this.ensureOpen();
837
+ if (this.readOnly) {
838
+ throw new ReadOnlyError(this.id);
839
+ }
840
+ this.ensureCdc();
841
+ const writer = this.pool.acquireWriter();
842
+ this.changeTracker?.watch(writer, table);
843
+ this.ensureCdcPolling();
844
+ }
845
+ unwatch(table) {
846
+ this.ensureOpen();
847
+ if (!this.changeTracker) return;
848
+ const writer = this.pool.acquireWriter();
849
+ this.changeTracker.unwatch(writer, table);
850
+ if (this.changeTracker.watchedTables.size === 0) {
851
+ this.stopCdcPollingLoop();
852
+ }
853
+ }
854
+ on(table) {
855
+ this.ensureOpen();
856
+ this.ensureCdc();
857
+ const manager = this.subscriptionManager;
858
+ if (!manager) throw new Error("subscriptionManager not initialized");
859
+ return new SubscriptionBuilderImpl(table, manager);
860
+ }
861
+ migrate(input) {
862
+ this.ensureOpen();
863
+ const writer = this.pool.acquireWriter();
864
+ return MigrationRunner.run(writer, input);
865
+ }
866
+ rollback(input, version) {
867
+ this.ensureOpen();
868
+ const writer = this.pool.acquireWriter();
869
+ return MigrationRunner.rollback(writer, input, version);
870
+ }
871
+ backup(destPath) {
872
+ this.ensureOpen();
873
+ const writer = this.pool.acquireWriter();
874
+ this.backupManager.backup(writer, destPath);
875
+ }
876
+ scheduleBackup(options) {
877
+ this.ensureOpen();
878
+ const writer = this.pool.acquireWriter();
879
+ const cancel = this.backupScheduler.schedule(writer, options);
880
+ this.scheduledBackupCancellers.push(cancel);
881
+ }
882
+ loadExtension(extensionPath) {
883
+ this.ensureOpen();
884
+ if (!extensionPath || extensionPath.includes("\0")) {
885
+ throw new ExtensionError(extensionPath || "", "Extension path is empty or contains null bytes");
886
+ }
887
+ const segments = extensionPath.split(/[/\\]/);
888
+ if (segments.includes("..")) {
889
+ throw new ExtensionError(extensionPath, "Extension path must not contain directory traversal segments");
890
+ }
891
+ const resolved = resolve(extensionPath);
892
+ try {
893
+ this.pool.loadExtension(resolved);
894
+ } catch (err) {
895
+ throw new ExtensionError(extensionPath, err instanceof Error ? err.message : String(err));
896
+ }
897
+ }
898
+ onBeforeQuery(hook) {
899
+ this.hookRegistry.register("beforeQuery", hook);
900
+ }
901
+ onAfterQuery(hook) {
902
+ this.hookRegistry.register("afterQuery", hook);
903
+ }
904
+ addCloseListener(fn) {
905
+ this.ensureOpen();
906
+ this.closeListeners.push(fn);
907
+ }
908
+ close() {
909
+ if (this._closed) return;
910
+ this._closed = true;
911
+ this.stopCdcPollingLoop();
912
+ for (const cancel of this.scheduledBackupCancellers) {
913
+ try {
914
+ cancel();
915
+ } catch {
916
+ }
917
+ }
918
+ this.scheduledBackupCancellers.length = 0;
919
+ let poolError;
920
+ try {
921
+ this.pool.close();
922
+ } catch (err) {
923
+ poolError = err;
924
+ }
925
+ for (const fn of this.closeListeners) {
926
+ try {
927
+ fn();
928
+ } catch {
929
+ }
930
+ }
931
+ if (poolError) {
932
+ throw poolError;
933
+ }
934
+ }
935
+ get closed() {
936
+ return this._closed;
937
+ }
938
+ get readerCount() {
939
+ return this.pool.readerCount;
940
+ }
941
+ ensureOpen() {
942
+ if (this._closed) {
943
+ throw new SirannonError(`Database '${this.id}' is closed`, "DATABASE_CLOSED");
944
+ }
945
+ }
946
+ ensureCdc() {
947
+ if (!this.changeTracker) {
948
+ this.changeTracker = new ChangeTracker({ retention: this.cdcRetention });
949
+ }
950
+ if (!this.subscriptionManager) {
951
+ this.subscriptionManager = new SubscriptionManager();
952
+ }
953
+ }
954
+ ensureCdcPolling() {
955
+ if (this.stopCdcPolling) return;
956
+ if (!this.changeTracker || !this.subscriptionManager) return;
957
+ const writer = this.pool.acquireWriter();
958
+ this.stopCdcPolling = startPolling(writer, this.changeTracker, this.subscriptionManager, this.cdcPollInterval);
959
+ }
960
+ stopCdcPollingLoop() {
961
+ if (this.stopCdcPolling) {
962
+ this.stopCdcPolling();
963
+ this.stopCdcPolling = null;
964
+ }
965
+ }
966
+ fireBeforeQueryHooks(sql, params) {
967
+ const hasParent = this.parentHooks?.has("beforeQuery");
968
+ const hasLocal = this.hookRegistry.has("beforeQuery");
969
+ if (!hasParent && !hasLocal) return;
970
+ const ctx = { databaseId: this.id, sql, params };
971
+ this.parentHooks?.invokeSync("beforeQuery", ctx);
972
+ this.hookRegistry.invokeSync("beforeQuery", ctx);
973
+ }
974
+ fireAfterQueryHooks(sql, params, durationMs) {
975
+ const hasParent = this.parentHooks?.has("afterQuery");
976
+ const hasLocal = this.hookRegistry.has("afterQuery");
977
+ if (!hasParent && !hasLocal) return;
978
+ const ctx = { databaseId: this.id, sql, params, durationMs };
979
+ try {
980
+ this.parentHooks?.invokeSync("afterQuery", ctx);
981
+ } catch {
982
+ }
983
+ try {
984
+ this.hookRegistry.invokeSync("afterQuery", ctx);
985
+ } catch {
986
+ }
987
+ }
988
+ };
989
+
990
+ // src/core/lifecycle/manager.ts
991
+ var LifecycleManager = class {
992
+ config;
993
+ callbacks;
994
+ lastAccess = /* @__PURE__ */ new Map();
995
+ idleTimer = null;
996
+ _disposed = false;
997
+ constructor(config, callbacks) {
998
+ this.config = config;
999
+ this.callbacks = callbacks;
1000
+ const timeout = config.idleTimeout;
1001
+ if (timeout && timeout > 0) {
1002
+ const interval = Math.min(Math.max(Math.floor(timeout / 2), 100), 6e4);
1003
+ this.idleTimer = setInterval(() => this.checkIdle(), interval);
1004
+ if (typeof this.idleTimer === "object" && "unref" in this.idleTimer) {
1005
+ this.idleTimer.unref();
1006
+ }
1007
+ }
1008
+ }
1009
+ /**
1010
+ * Attempt to auto-open a database by ID using the configured resolver.
1011
+ * Returns the opened Database, or `undefined` when no resolver is
1012
+ * configured or the resolver does not recognise the ID.
1013
+ *
1014
+ * Throws {@link MaxDatabasesError} when the registry is at capacity and
1015
+ * eviction cannot free a slot.
1016
+ */
1017
+ resolve(id) {
1018
+ this.ensureNotDisposed();
1019
+ const resolver = this.config.autoOpen?.resolver;
1020
+ if (!resolver) return void 0;
1021
+ const resolved = resolver(id);
1022
+ if (!resolved) return void 0;
1023
+ const maxOpen = this.config.maxOpen;
1024
+ if (maxOpen && maxOpen > 0 && this.callbacks.count() >= maxOpen) {
1025
+ this.evict();
1026
+ if (this.callbacks.count() >= maxOpen) {
1027
+ throw new MaxDatabasesError(maxOpen);
1028
+ }
1029
+ }
1030
+ const db = this.callbacks.open(id, resolved.path, resolved.options);
1031
+ this.markActive(id);
1032
+ return db;
1033
+ }
1034
+ /** Record an access for the given database ID. */
1035
+ markActive(id) {
1036
+ if (!this._disposed) {
1037
+ this.lastAccess.set(id, Date.now());
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Close every database whose last access was longer ago than the
1042
+ * configured idle timeout. Also cleans up tracking entries for
1043
+ * databases that were closed externally.
1044
+ */
1045
+ checkIdle() {
1046
+ const timeout = this.config.idleTimeout;
1047
+ if (!timeout || timeout <= 0) return;
1048
+ const now = Date.now();
1049
+ const toRemove = [];
1050
+ const toClose = [];
1051
+ for (const [id, lastTime] of this.lastAccess) {
1052
+ if (!this.callbacks.has(id)) {
1053
+ toRemove.push(id);
1054
+ continue;
1055
+ }
1056
+ if (now - lastTime >= timeout) {
1057
+ toClose.push(id);
1058
+ }
1059
+ }
1060
+ for (const id of toClose) {
1061
+ try {
1062
+ this.callbacks.close(id);
1063
+ } catch {
1064
+ }
1065
+ toRemove.push(id);
1066
+ }
1067
+ for (const id of toRemove) {
1068
+ this.lastAccess.delete(id);
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Close the least-recently-used tracked database. Called internally
1073
+ * by {@link resolve} when `maxOpen` capacity is reached.
1074
+ */
1075
+ evict() {
1076
+ let oldestId = null;
1077
+ let oldestTime = Infinity;
1078
+ const stale = [];
1079
+ for (const [id, time] of this.lastAccess) {
1080
+ if (!this.callbacks.has(id)) {
1081
+ stale.push(id);
1082
+ continue;
1083
+ }
1084
+ if (time < oldestTime) {
1085
+ oldestTime = time;
1086
+ oldestId = id;
1087
+ }
1088
+ }
1089
+ for (const id of stale) {
1090
+ this.lastAccess.delete(id);
1091
+ }
1092
+ if (oldestId) {
1093
+ try {
1094
+ this.callbacks.close(oldestId);
1095
+ } catch {
1096
+ }
1097
+ this.lastAccess.delete(oldestId);
1098
+ }
1099
+ }
1100
+ /** Remove a database from idle tracking (e.g. after an explicit close). */
1101
+ untrack(id) {
1102
+ this.lastAccess.delete(id);
1103
+ }
1104
+ /** Whether this manager has been disposed. */
1105
+ get disposed() {
1106
+ return this._disposed;
1107
+ }
1108
+ /** The number of databases currently tracked for idle management. */
1109
+ get trackedCount() {
1110
+ return this.lastAccess.size;
1111
+ }
1112
+ /** Shut down the manager: stop the idle timer and clear all state. */
1113
+ dispose() {
1114
+ if (this._disposed) return;
1115
+ this._disposed = true;
1116
+ if (this.idleTimer !== null) {
1117
+ clearInterval(this.idleTimer);
1118
+ this.idleTimer = null;
1119
+ }
1120
+ this.lastAccess.clear();
1121
+ }
1122
+ ensureNotDisposed() {
1123
+ if (this._disposed) {
1124
+ throw new SirannonError("LifecycleManager has been disposed", "LIFECYCLE_DISPOSED");
1125
+ }
1126
+ }
1127
+ };
1128
+ var SAFE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
1129
+ var MAX_ID_LENGTH = 255;
1130
+ var MAX_FILENAME_LENGTH = 255;
1131
+ function sanitizeTenantId(id) {
1132
+ if (!id || id.length > MAX_ID_LENGTH) return void 0;
1133
+ if (!SAFE_ID_PATTERN.test(id)) return void 0;
1134
+ return id;
1135
+ }
1136
+ function tenantPath(basePath, tenantId, extension = ".db") {
1137
+ const sanitized = sanitizeTenantId(tenantId);
1138
+ if (!sanitized) {
1139
+ throw new Error(`Invalid tenant ID: '${tenantId}'`);
1140
+ }
1141
+ const filename = `${sanitized}${extension}`;
1142
+ if (filename.length > MAX_FILENAME_LENGTH) {
1143
+ throw new Error(`Tenant filename exceeds maximum length of ${MAX_FILENAME_LENGTH} characters`);
1144
+ }
1145
+ return join(basePath, filename);
1146
+ }
1147
+ function createTenantResolver(options) {
1148
+ const ext = options.extension ?? ".db";
1149
+ const defaultOpts = options.defaultOptions;
1150
+ return (id) => {
1151
+ const sanitized = sanitizeTenantId(id);
1152
+ if (!sanitized) return void 0;
1153
+ const filename = `${sanitized}${ext}`;
1154
+ if (filename.length > MAX_FILENAME_LENGTH) return void 0;
1155
+ return {
1156
+ path: join(options.basePath, filename),
1157
+ options: defaultOpts
1158
+ };
1159
+ };
1160
+ }
1161
+
1162
+ // src/core/metrics/collector.ts
1163
+ var MetricsCollector = class {
1164
+ config;
1165
+ constructor(config) {
1166
+ this.config = config ?? {};
1167
+ }
1168
+ trackQuery(fn, context) {
1169
+ if (!this.config.onQueryComplete) {
1170
+ return fn();
1171
+ }
1172
+ const start = performance.now();
1173
+ let failed = false;
1174
+ try {
1175
+ return fn();
1176
+ } catch (err) {
1177
+ failed = true;
1178
+ throw err;
1179
+ } finally {
1180
+ const durationMs = performance.now() - start;
1181
+ try {
1182
+ this.config.onQueryComplete({
1183
+ ...context,
1184
+ durationMs,
1185
+ error: failed
1186
+ });
1187
+ } catch {
1188
+ }
1189
+ }
1190
+ }
1191
+ trackConnection(metrics) {
1192
+ try {
1193
+ if (metrics.event === "open") {
1194
+ this.config.onConnectionOpen?.(metrics);
1195
+ } else {
1196
+ this.config.onConnectionClose?.(metrics);
1197
+ }
1198
+ } catch {
1199
+ }
1200
+ }
1201
+ trackCDCEvent(metrics) {
1202
+ try {
1203
+ this.config.onCDCEvent?.(metrics);
1204
+ } catch {
1205
+ }
1206
+ }
1207
+ get active() {
1208
+ return !!(this.config.onQueryComplete || this.config.onConnectionOpen || this.config.onConnectionClose || this.config.onCDCEvent);
1209
+ }
1210
+ };
1211
+
1212
+ // src/core/sirannon.ts
1213
+ var Sirannon = class {
1214
+ constructor(options) {
1215
+ this.options = options;
1216
+ this.hookRegistry = new HookRegistry(options?.hooks);
1217
+ this.metricsCollector = options?.metrics ? new MetricsCollector(options.metrics) : null;
1218
+ this.lifecycleManager = options?.lifecycle ? new LifecycleManager(options.lifecycle, {
1219
+ open: (id, path, opts) => this.open(id, path, opts),
1220
+ close: (id) => this.close(id),
1221
+ count: () => this.dbs.size,
1222
+ has: (id) => this.dbs.has(id)
1223
+ }) : null;
1224
+ }
1225
+ dbs = /* @__PURE__ */ new Map();
1226
+ _shutdown = false;
1227
+ hookRegistry;
1228
+ metricsCollector;
1229
+ lifecycleManager;
1230
+ open(id, path, options) {
1231
+ this.ensureRunning();
1232
+ if (this.dbs.has(id)) {
1233
+ throw new DatabaseAlreadyExistsError(id);
1234
+ }
1235
+ if (this.hookRegistry.has("beforeConnect")) {
1236
+ this.hookRegistry.invokeSync("beforeConnect", { databaseId: id, path });
1237
+ }
1238
+ let db;
1239
+ try {
1240
+ db = new Database2(id, path, options, {
1241
+ parentHooks: this.hookRegistry,
1242
+ metrics: this.metricsCollector ?? void 0
1243
+ });
1244
+ } catch (err) {
1245
+ if (err instanceof SirannonError) throw err;
1246
+ throw new SirannonError(
1247
+ `Failed to open database '${id}' at '${path}': ${err instanceof Error ? err.message : String(err)}`,
1248
+ "DATABASE_OPEN_FAILED"
1249
+ );
1250
+ }
1251
+ db.addCloseListener(() => {
1252
+ this.dbs.delete(id);
1253
+ this.lifecycleManager?.untrack(id);
1254
+ if (this.hookRegistry.has("databaseClose")) {
1255
+ try {
1256
+ this.hookRegistry.invokeSync("databaseClose", { databaseId: id, path });
1257
+ } catch {
1258
+ }
1259
+ }
1260
+ this.metricsCollector?.trackConnection({
1261
+ databaseId: id,
1262
+ path,
1263
+ readerCount: 0,
1264
+ event: "close"
1265
+ });
1266
+ });
1267
+ this.dbs.set(id, db);
1268
+ this.lifecycleManager?.markActive(id);
1269
+ if (this.hookRegistry.has("databaseOpen")) {
1270
+ try {
1271
+ this.hookRegistry.invokeSync("databaseOpen", { databaseId: id, path });
1272
+ } catch {
1273
+ }
1274
+ }
1275
+ this.metricsCollector?.trackConnection({
1276
+ databaseId: id,
1277
+ path,
1278
+ readerCount: db.readerCount,
1279
+ event: "open"
1280
+ });
1281
+ return db;
1282
+ }
1283
+ close(id) {
1284
+ this.ensureRunning();
1285
+ const db = this.dbs.get(id);
1286
+ if (!db) {
1287
+ throw new DatabaseNotFoundError(id);
1288
+ }
1289
+ db.close();
1290
+ }
1291
+ get(id) {
1292
+ const db = this.dbs.get(id);
1293
+ if (db) {
1294
+ this.lifecycleManager?.markActive(id);
1295
+ return db;
1296
+ }
1297
+ if (this._shutdown) return void 0;
1298
+ return this.lifecycleManager?.resolve(id);
1299
+ }
1300
+ has(id) {
1301
+ return this.dbs.has(id);
1302
+ }
1303
+ databases() {
1304
+ return new Map(this.dbs);
1305
+ }
1306
+ shutdown() {
1307
+ if (this._shutdown) return;
1308
+ this._shutdown = true;
1309
+ this.lifecycleManager?.dispose();
1310
+ const errors = [];
1311
+ const snapshot = [...this.dbs.values()];
1312
+ for (const db of snapshot) {
1313
+ try {
1314
+ db.close();
1315
+ } catch (err) {
1316
+ errors.push(err);
1317
+ }
1318
+ }
1319
+ this.dbs.clear();
1320
+ if (errors.length > 0) {
1321
+ throw new SirannonError(`Shutdown completed with ${errors.length} error(s)`, "SHUTDOWN_ERROR");
1322
+ }
1323
+ }
1324
+ onBeforeQuery(hook) {
1325
+ this.hookRegistry.register("beforeQuery", hook);
1326
+ }
1327
+ onAfterQuery(hook) {
1328
+ this.hookRegistry.register("afterQuery", hook);
1329
+ }
1330
+ onBeforeConnect(hook) {
1331
+ this.hookRegistry.register("beforeConnect", hook);
1332
+ }
1333
+ onDatabaseOpen(hook) {
1334
+ this.hookRegistry.register("databaseOpen", hook);
1335
+ }
1336
+ onDatabaseClose(hook) {
1337
+ this.hookRegistry.register("databaseClose", hook);
1338
+ }
1339
+ ensureRunning() {
1340
+ if (this._shutdown) {
1341
+ throw new SirannonError("Sirannon has been shut down", "SHUTDOWN");
1342
+ }
1343
+ }
1344
+ };
1345
+
1346
+ export { BackupManager, BackupScheduler, ConnectionPool, Database2 as Database, HookRegistry, LifecycleManager, MetricsCollector, MigrationRunner, Sirannon, Transaction, createTenantResolver, execute, executeBatch, query, queryOne, sanitizeTenantId, tenantPath };