@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
@@ -1,214 +1,62 @@
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';
1
+ import { SubscriptionBuilderImpl, ChangeTracker, SubscriptionManager, startPolling } from '../chunk-UTO3ZAFS.mjs';
2
+ export { ChangeTracker } from '../chunk-UTO3ZAFS.mjs';
3
+ export { defineDriver } from '../chunk-74UN4DIE.mjs';
4
+ import { BackupManager, BackupScheduler } from '../chunk-PXKAKK2V.mjs';
5
+ export { BackupManager, BackupScheduler } from '../chunk-PXKAKK2V.mjs';
6
+ import { Transaction, query, queryOne, execute, executeBatch } from '../chunk-3MCMONVP.mjs';
7
+ export { Transaction, execute, executeBatch, query, queryOne } from '../chunk-3MCMONVP.mjs';
8
+ import '../chunk-GS7T5YMI.mjs';
9
+ import { ConnectionPoolError, MigrationError, ReadOnlyError, SirannonError, MaxDatabasesError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError } from '../chunk-O7BHI3CF.mjs';
10
+ export { BackupError, CDCError, ConnectionPoolError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError, HookDeniedError, MaxDatabasesError, MigrationError, QueryError, ReadOnlyError, SirannonError, TransactionError } from '../chunk-O7BHI3CF.mjs';
7
11
 
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) {
12
+ // src/core/connection-pool.ts
13
+ async function closeAllSilently(connections) {
168
14
  for (const conn of connections) {
169
15
  if (!conn) continue;
170
16
  try {
171
- conn.close();
17
+ await conn.close();
172
18
  } catch {
173
19
  }
174
20
  }
175
21
  }
176
- var ConnectionPool = class {
22
+ var ConnectionPool = class _ConnectionPool {
177
23
  writer;
178
24
  readers;
179
25
  readerIndex = 0;
180
26
  closed = false;
181
- constructor(options) {
182
- const { path, readOnly = false, readPoolSize = 4, walMode = true } = options;
27
+ constructor(writer, readers) {
28
+ this.writer = writer;
29
+ this.readers = readers;
30
+ }
31
+ static async create(options) {
32
+ const { driver, path, readOnly = false, readPoolSize = 4, walMode = true } = options;
183
33
  let writer = null;
184
34
  const readers = [];
185
35
  try {
186
36
  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");
37
+ writer = await driver.open(path, { walMode });
194
38
  }
195
- const poolSize = Math.max(readPoolSize, 1);
39
+ const poolSize = driver.capabilities.multipleConnections ? Math.max(readPoolSize, 1) : 0;
196
40
  for (let i = 0; i < poolSize; i++) {
197
- const reader = new Database(path, { readonly: true });
41
+ const reader = await driver.open(path, { readonly: true, walMode: false });
198
42
  readers.push(reader);
199
- reader.pragma("foreign_keys = ON");
200
43
  }
201
44
  } catch (err) {
202
- closeAllSilently([...readers, writer]);
45
+ await closeAllSilently([...readers, writer]);
203
46
  throw err;
204
47
  }
205
- this.writer = writer;
206
- this.readers = readers;
48
+ return new _ConnectionPool(writer, readers);
207
49
  }
208
50
  acquireReader() {
209
51
  if (this.closed) {
210
52
  throw new ConnectionPoolError("Connection pool is closed");
211
53
  }
54
+ if (this.readers.length === 0) {
55
+ if (!this.writer) {
56
+ throw new ConnectionPoolError("No connections available");
57
+ }
58
+ return this.writer;
59
+ }
212
60
  const reader = this.readers[this.readerIndex % this.readers.length];
213
61
  this.readerIndex = (this.readerIndex + 1) % this.readers.length;
214
62
  return reader;
@@ -228,31 +76,20 @@ var ConnectionPool = class {
228
76
  get isReadOnly() {
229
77
  return this.writer === null;
230
78
  }
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() {
79
+ async close() {
243
80
  if (this.closed) return;
244
81
  this.closed = true;
245
82
  const errors = [];
246
83
  for (const reader of this.readers) {
247
84
  try {
248
- reader.close();
85
+ await reader.close();
249
86
  } catch (err) {
250
87
  errors.push(err);
251
88
  }
252
89
  }
253
90
  if (this.writer) {
254
91
  try {
255
- this.writer.close();
92
+ await this.writer.close();
256
93
  } catch (err) {
257
94
  errors.push(err);
258
95
  }
@@ -263,6 +100,81 @@ var ConnectionPool = class {
263
100
  }
264
101
  };
265
102
 
103
+ // src/core/cdc/ddl-handler.ts
104
+ var DDL_PREFIX_RE = /^\s*(CREATE\s+TABLE|ALTER\s+TABLE\s+\S+\s+ADD\s+COLUMN|DROP\s+TABLE|CREATE\s+INDEX|DROP\s+INDEX)\b/i;
105
+ var DROP_TABLE_RE = /^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\s*;?\s*$/i;
106
+ function isCdcRelevantDdl(sql) {
107
+ return DDL_PREFIX_RE.test(sql);
108
+ }
109
+ function extractDroppedTable(sql) {
110
+ const m = DROP_TABLE_RE.exec(sql);
111
+ return m?.[1] ?? null;
112
+ }
113
+ async function applyDdlSideEffects(tracker, writerConn, sql) {
114
+ if (tracker.watchedTables.size === 0) {
115
+ return;
116
+ }
117
+ await tracker.refreshAllTriggersUsingConnection(writerConn);
118
+ const dropped = extractDroppedTable(sql);
119
+ if (dropped !== null) {
120
+ await tracker.pruneDroppedTables(writerConn, [dropped]);
121
+ }
122
+ }
123
+
124
+ // src/core/cdc/cdc-aware-transaction.ts
125
+ var CdcAwareTransaction = class extends Transaction {
126
+ constructor(txConn, tracker, state) {
127
+ super(txConn);
128
+ this.tracker = tracker;
129
+ this.state = state;
130
+ this.txConn = txConn;
131
+ }
132
+ txConn;
133
+ async execute(sql, params) {
134
+ const isDdl = isCdcRelevantDdl(sql);
135
+ const result = await super.execute(sql, params);
136
+ if (!isDdl) {
137
+ return result;
138
+ }
139
+ this.state.sawDdl = true;
140
+ const dropped = extractDroppedTable(sql);
141
+ if (dropped !== null) {
142
+ this.state.droppedTables.push(dropped);
143
+ }
144
+ if (this.tracker.watchedTables.size > 0) {
145
+ await this.tracker.refreshAllTriggersUsingConnection(this.txConn);
146
+ }
147
+ return result;
148
+ }
149
+ };
150
+
151
+ // src/core/extension-loader.ts
152
+ async function loadExtension(driver, writer, extensionPath) {
153
+ if (!driver.capabilities.extensions) {
154
+ throw new ExtensionError(extensionPath, "Extensions are not supported by the current driver");
155
+ }
156
+ if (!extensionPath || extensionPath.includes("\0")) {
157
+ throw new ExtensionError(extensionPath || "", "Extension path is empty or contains null bytes");
158
+ }
159
+ for (let i = 0; i < extensionPath.length; i++) {
160
+ if (extensionPath.charCodeAt(i) <= 31) {
161
+ throw new ExtensionError(extensionPath, "Extension path contains control characters");
162
+ }
163
+ }
164
+ const segments = extensionPath.split(/[/\\]/);
165
+ if (segments.includes("..")) {
166
+ throw new ExtensionError(extensionPath, "Extension path must not contain directory traversal segments");
167
+ }
168
+ const { resolve } = await import('path');
169
+ const resolved = resolve(extensionPath);
170
+ try {
171
+ const escaped = resolved.replace(/'/g, "''");
172
+ await writer.exec(`SELECT load_extension('${escaped}')`);
173
+ } catch (err) {
174
+ throw new ExtensionError(extensionPath, err instanceof Error ? err.message : String(err));
175
+ }
176
+ }
177
+
266
178
  // src/core/hooks/registry.ts
267
179
  var HOOK_CONFIG_MAP = {
268
180
  onBeforeQuery: "beforeQuery",
@@ -345,227 +257,6 @@ var HookRegistry = class {
345
257
  }
346
258
  };
347
259
 
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
260
  // src/core/migrations/runner.ts
570
261
  var CREATE_TRACKING_TABLE = `
571
262
  CREATE TABLE IF NOT EXISTS _sirannon_migrations (
@@ -575,23 +266,24 @@ var CREATE_TRACKING_TABLE = `
575
266
  )
576
267
  `;
577
268
  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));
269
+ static async run(conn, migrations) {
270
+ await conn.exec(CREATE_TRACKING_TABLE);
271
+ const validated = _MigrationRunner.validateMigrations(migrations);
272
+ const applied = await _MigrationRunner.getAppliedVersions(conn);
273
+ const pending = validated.filter((m) => !applied.has(m.version));
583
274
  if (pending.length === 0) {
584
- return { applied: [], skipped: migrations.length };
275
+ return { applied: [], skipped: validated.length };
585
276
  }
586
- const insertMigration = db.prepare("INSERT INTO _sirannon_migrations (version, name) VALUES (?, ?)");
587
277
  const appliedEntries = [];
588
- db.transaction(() => {
278
+ await conn.transaction(async (txConn) => {
279
+ const insertStmt = await txConn.prepare("INSERT INTO _sirannon_migrations (version, name) VALUES (?, ?)");
589
280
  for (const migration of pending) {
590
281
  try {
591
282
  if (typeof migration.up === "string") {
592
- db.exec(migration.up);
283
+ await txConn.exec(migration.up);
593
284
  } else {
594
- migration.up(new Transaction(db));
285
+ const result = migration.up(new Transaction(txConn));
286
+ if (result instanceof Promise) await result;
595
287
  }
596
288
  } catch (err) {
597
289
  if (err instanceof MigrationError) throw err;
@@ -600,16 +292,16 @@ var MigrationRunner = class _MigrationRunner {
600
292
  migration.version
601
293
  );
602
294
  }
603
- insertMigration.run(migration.version, migration.name);
295
+ await insertStmt.run(migration.version, migration.name);
604
296
  appliedEntries.push({ version: migration.version, name: migration.name });
605
297
  }
606
- })();
298
+ });
607
299
  return {
608
300
  applied: appliedEntries,
609
- skipped: migrations.length - pending.length
301
+ skipped: validated.length - pending.length
610
302
  };
611
303
  }
612
- static rollback(db, input, version) {
304
+ static async rollback(conn, migrations, version) {
613
305
  if (version !== void 0 && (!Number.isSafeInteger(version) || version < 0)) {
614
306
  throw new MigrationError(
615
307
  `Invalid rollback target version: ${version}`,
@@ -617,8 +309,9 @@ var MigrationRunner = class _MigrationRunner {
617
309
  "MIGRATION_VALIDATION_ERROR"
618
310
  );
619
311
  }
620
- db.exec(CREATE_TRACKING_TABLE);
621
- const appliedRows = db.prepare("SELECT version, name FROM _sirannon_migrations ORDER BY version DESC").all();
312
+ await conn.exec(CREATE_TRACKING_TABLE);
313
+ const selectStmt = await conn.prepare("SELECT version, name FROM _sirannon_migrations ORDER BY version DESC");
314
+ const appliedRows = await selectStmt.all();
622
315
  if (appliedRows.length === 0) {
623
316
  return { rolledBack: [] };
624
317
  }
@@ -631,26 +324,20 @@ var MigrationRunner = class _MigrationRunner {
631
324
  if (rollbackSet.length === 0) {
632
325
  return { rolledBack: [] };
633
326
  }
327
+ _MigrationRunner.validateMigrations(migrations);
634
328
  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);
329
+ const inputByVersion = new Map(migrations.map((m) => [m.version, m]));
330
+ const downByVersion = /* @__PURE__ */ new Map();
331
+ for (const v of rollbackVersions) {
332
+ const m = inputByVersion.get(v);
333
+ if (!m || m.down === void 0) {
334
+ throw new MigrationError(`Migration version ${v} has no down migration`, v, "MIGRATION_NO_DOWN");
649
335
  }
336
+ downByVersion.set(v, m);
650
337
  }
651
- const deleteMigration = db.prepare("DELETE FROM _sirannon_migrations WHERE version = ?");
652
338
  const rolledBackEntries = [];
653
- db.transaction(() => {
339
+ await conn.transaction(async (txConn) => {
340
+ const deleteStmt = await txConn.prepare("DELETE FROM _sirannon_migrations WHERE version = ?");
654
341
  for (const entry of rollbackSet) {
655
342
  const migration = downByVersion.get(entry.version);
656
343
  if (!migration) {
@@ -662,9 +349,10 @@ var MigrationRunner = class _MigrationRunner {
662
349
  }
663
350
  try {
664
351
  if (typeof migration.down === "string") {
665
- db.exec(migration.down);
352
+ await txConn.exec(migration.down);
666
353
  } else {
667
- migration.down?.(new Transaction(db));
354
+ const result = migration.down?.(new Transaction(txConn));
355
+ if (result instanceof Promise) await result;
668
356
  }
669
357
  } catch (err) {
670
358
  if (err instanceof MigrationError) throw err;
@@ -674,10 +362,10 @@ var MigrationRunner = class _MigrationRunner {
674
362
  "MIGRATION_ROLLBACK_ERROR"
675
363
  );
676
364
  }
677
- deleteMigration.run(entry.version);
365
+ await deleteStmt.run(entry.version);
678
366
  rolledBackEntries.push({ version: entry.version, name: entry.name });
679
367
  }
680
- })();
368
+ });
681
369
  return { rolledBack: rolledBackEntries };
682
370
  }
683
371
  static validateMigrations(migrations) {
@@ -719,18 +407,20 @@ var MigrationRunner = class _MigrationRunner {
719
407
  }
720
408
  return [...migrations].sort((a, b) => a.version - b.version);
721
409
  }
722
- static getAppliedVersions(db) {
723
- const rows = db.prepare("SELECT version FROM _sirannon_migrations ORDER BY version").all();
410
+ static async getAppliedVersions(conn) {
411
+ const stmt = await conn.prepare("SELECT version FROM _sirannon_migrations ORDER BY version");
412
+ const rows = await stmt.all();
724
413
  return new Set(rows.map((r) => r.version));
725
414
  }
726
415
  };
727
416
 
728
417
  // src/core/database.ts
729
- var Database2 = class {
418
+ var Database = class _Database {
730
419
  id;
731
420
  path;
732
421
  readOnly;
733
422
  pool;
423
+ driver;
734
424
  closeListeners = [];
735
425
  _closed = false;
736
426
  changeTracker = null;
@@ -744,109 +434,135 @@ var Database2 = class {
744
434
  backupManager = new BackupManager();
745
435
  backupScheduler = new BackupScheduler(this.backupManager);
746
436
  scheduledBackupCancellers = [];
747
- constructor(id, path, options, internals) {
437
+ constructor(id, path, pool, driver, options, internals) {
748
438
  this.id = id;
749
439
  this.path = path;
440
+ this.pool = pool;
441
+ this.driver = driver;
750
442
  this.readOnly = options?.readOnly ?? false;
751
443
  this.cdcPollInterval = options?.cdcPollInterval ?? 50;
752
444
  this.cdcRetention = options?.cdcRetention ?? 36e5;
753
445
  this.parentHooks = internals?.parentHooks ?? null;
754
446
  this.metricsCollector = internals?.metrics ?? null;
755
- this.pool = new ConnectionPool({
447
+ }
448
+ static async create(id, path, driver, options, internals) {
449
+ const pool = await ConnectionPool.create({
450
+ driver,
756
451
  path,
757
- readOnly: this.readOnly,
452
+ readOnly: options?.readOnly,
758
453
  readPoolSize: options?.readPoolSize ?? 4,
759
454
  walMode: options?.walMode ?? true
760
455
  });
456
+ return new _Database(id, path, pool, driver, options, internals);
761
457
  }
762
- query(sql, params) {
458
+ async query(sql, params, options) {
763
459
  this.ensureOpen();
764
- this.fireBeforeQueryHooks(sql, params);
460
+ this.fireBeforeQueryHooks(sql, params, options);
765
461
  const start = performance.now();
766
462
  try {
767
463
  const reader = this.pool.acquireReader();
768
464
  if (this.metricsCollector) {
769
- return this.metricsCollector.trackQuery(() => query(reader, sql, params), {
465
+ return await this.metricsCollector.trackQuery(() => query(reader, sql, params), {
770
466
  databaseId: this.id,
771
467
  sql
772
468
  });
773
469
  }
774
- return query(reader, sql, params);
470
+ return await query(reader, sql, params);
775
471
  } finally {
776
472
  this.fireAfterQueryHooks(sql, params, performance.now() - start);
777
473
  }
778
474
  }
779
- queryOne(sql, params) {
475
+ async queryOne(sql, params, options) {
780
476
  this.ensureOpen();
781
- this.fireBeforeQueryHooks(sql, params);
477
+ this.fireBeforeQueryHooks(sql, params, options);
782
478
  const start = performance.now();
783
479
  try {
784
480
  const reader = this.pool.acquireReader();
785
481
  if (this.metricsCollector) {
786
- return this.metricsCollector.trackQuery(() => queryOne(reader, sql, params), {
482
+ return await this.metricsCollector.trackQuery(() => queryOne(reader, sql, params), {
787
483
  databaseId: this.id,
788
484
  sql
789
485
  });
790
486
  }
791
- return queryOne(reader, sql, params);
487
+ return await queryOne(reader, sql, params);
792
488
  } finally {
793
489
  this.fireAfterQueryHooks(sql, params, performance.now() - start);
794
490
  }
795
491
  }
796
- execute(sql, params) {
492
+ async execute(sql, params, options) {
797
493
  this.ensureOpen();
798
- this.fireBeforeQueryHooks(sql, params);
494
+ if (this.readOnly) throw new ReadOnlyError(this.id);
495
+ this.fireBeforeQueryHooks(sql, params, options);
799
496
  const start = performance.now();
800
497
  try {
801
498
  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);
499
+ const result = this.metricsCollector ? await this.metricsCollector.trackQuery(() => execute(writer, sql, params), {
500
+ databaseId: this.id,
501
+ sql
502
+ }) : await execute(writer, sql, params);
503
+ await this.maybeApplyDdlSideEffects(writer, sql);
504
+ return result;
809
505
  } finally {
810
506
  this.fireAfterQueryHooks(sql, params, performance.now() - start);
811
507
  }
812
508
  }
813
- executeBatch(sql, paramsBatch) {
509
+ async executeBatch(sql, paramsBatch, options) {
814
510
  this.ensureOpen();
815
- this.fireBeforeQueryHooks(sql);
511
+ if (this.readOnly) throw new ReadOnlyError(this.id);
512
+ this.fireBeforeQueryHooks(sql, void 0, options);
816
513
  const start = performance.now();
817
514
  try {
818
515
  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);
516
+ const batchFn = () => writer.transaction(async (txConn) => executeBatch(txConn, sql, paramsBatch));
517
+ const results = this.metricsCollector ? await this.metricsCollector.trackQuery(batchFn, {
518
+ databaseId: this.id,
519
+ sql
520
+ }) : await batchFn();
521
+ await this.maybeApplyDdlSideEffects(writer, sql);
522
+ return results;
826
523
  } finally {
827
524
  this.fireAfterQueryHooks(sql, void 0, performance.now() - start);
828
525
  }
829
526
  }
830
- transaction(fn) {
527
+ async transaction(fn) {
831
528
  this.ensureOpen();
529
+ if (this.readOnly) throw new ReadOnlyError(this.id);
832
530
  const writer = this.pool.acquireWriter();
833
- return Transaction.run(writer, fn);
531
+ const tracker = this.changeTracker;
532
+ if (!tracker) {
533
+ return Transaction.run(writer, fn);
534
+ }
535
+ const state = { sawDdl: false, droppedTables: [] };
536
+ const result = await writer.transaction(async (txConn) => {
537
+ const tx = new CdcAwareTransaction(txConn, tracker, state);
538
+ return fn(tx);
539
+ });
540
+ if (state.sawDdl && state.droppedTables.length > 0) {
541
+ await tracker.pruneDroppedTables(writer, state.droppedTables);
542
+ }
543
+ return result;
834
544
  }
835
- watch(table) {
545
+ async maybeApplyDdlSideEffects(writer, sql) {
546
+ const tracker = this.changeTracker;
547
+ if (!tracker) return;
548
+ if (!isCdcRelevantDdl(sql)) return;
549
+ await applyDdlSideEffects(tracker, writer, sql);
550
+ }
551
+ async watch(table) {
836
552
  this.ensureOpen();
837
553
  if (this.readOnly) {
838
554
  throw new ReadOnlyError(this.id);
839
555
  }
840
556
  this.ensureCdc();
841
557
  const writer = this.pool.acquireWriter();
842
- this.changeTracker?.watch(writer, table);
558
+ await this.changeTracker?.watch(writer, table);
843
559
  this.ensureCdcPolling();
844
560
  }
845
- unwatch(table) {
561
+ async unwatch(table) {
846
562
  this.ensureOpen();
847
563
  if (!this.changeTracker) return;
848
564
  const writer = this.pool.acquireWriter();
849
- this.changeTracker.unwatch(writer, table);
565
+ await this.changeTracker.unwatch(writer, table);
850
566
  if (this.changeTracker.watchedTables.size === 0) {
851
567
  this.stopCdcPollingLoop();
852
568
  }
@@ -858,20 +574,20 @@ var Database2 = class {
858
574
  if (!manager) throw new Error("subscriptionManager not initialized");
859
575
  return new SubscriptionBuilderImpl(table, manager);
860
576
  }
861
- migrate(input) {
577
+ async migrate(migrations) {
862
578
  this.ensureOpen();
863
579
  const writer = this.pool.acquireWriter();
864
- return MigrationRunner.run(writer, input);
580
+ return MigrationRunner.run(writer, migrations);
865
581
  }
866
- rollback(input, version) {
582
+ async rollback(migrations, version) {
867
583
  this.ensureOpen();
868
584
  const writer = this.pool.acquireWriter();
869
- return MigrationRunner.rollback(writer, input, version);
585
+ return MigrationRunner.rollback(writer, migrations, version);
870
586
  }
871
- backup(destPath) {
587
+ async backup(destPath) {
872
588
  this.ensureOpen();
873
589
  const writer = this.pool.acquireWriter();
874
- this.backupManager.backup(writer, destPath);
590
+ await this.backupManager.backup(writer, destPath);
875
591
  }
876
592
  scheduleBackup(options) {
877
593
  this.ensureOpen();
@@ -879,21 +595,10 @@ var Database2 = class {
879
595
  const cancel = this.backupScheduler.schedule(writer, options);
880
596
  this.scheduledBackupCancellers.push(cancel);
881
597
  }
882
- loadExtension(extensionPath) {
598
+ async loadExtension(extensionPath) {
883
599
  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
- }
600
+ const writer = this.pool.acquireWriter();
601
+ await loadExtension(this.driver, writer, extensionPath);
897
602
  }
898
603
  onBeforeQuery(hook) {
899
604
  this.hookRegistry.register("beforeQuery", hook);
@@ -905,7 +610,7 @@ var Database2 = class {
905
610
  this.ensureOpen();
906
611
  this.closeListeners.push(fn);
907
612
  }
908
- close() {
613
+ async close() {
909
614
  if (this._closed) return;
910
615
  this._closed = true;
911
616
  this.stopCdcPollingLoop();
@@ -918,13 +623,13 @@ var Database2 = class {
918
623
  this.scheduledBackupCancellers.length = 0;
919
624
  let poolError;
920
625
  try {
921
- this.pool.close();
626
+ await this.pool.close();
922
627
  } catch (err) {
923
628
  poolError = err;
924
629
  }
925
630
  for (const fn of this.closeListeners) {
926
631
  try {
927
- fn();
632
+ await fn();
928
633
  } catch {
929
634
  }
930
635
  }
@@ -963,11 +668,17 @@ var Database2 = class {
963
668
  this.stopCdcPolling = null;
964
669
  }
965
670
  }
966
- fireBeforeQueryHooks(sql, params) {
671
+ fireBeforeQueryHooks(sql, params, options) {
967
672
  const hasParent = this.parentHooks?.has("beforeQuery");
968
673
  const hasLocal = this.hookRegistry.has("beforeQuery");
969
674
  if (!hasParent && !hasLocal) return;
970
- const ctx = { databaseId: this.id, sql, params };
675
+ const ctx = {
676
+ databaseId: this.id,
677
+ sql,
678
+ params,
679
+ writeConcern: options?.writeConcern,
680
+ readConcern: options?.readConcern
681
+ };
971
682
  this.parentHooks?.invokeSync("beforeQuery", ctx);
972
683
  this.hookRegistry.invokeSync("beforeQuery", ctx);
973
684
  }
@@ -994,27 +705,21 @@ var LifecycleManager = class {
994
705
  lastAccess = /* @__PURE__ */ new Map();
995
706
  idleTimer = null;
996
707
  _disposed = false;
708
+ #idleCheckPromise = null;
997
709
  constructor(config, callbacks) {
998
710
  this.config = config;
999
711
  this.callbacks = callbacks;
1000
712
  const timeout = config.idleTimeout;
1001
713
  if (timeout && timeout > 0) {
1002
714
  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
- }
715
+ const timer = setInterval(async () => {
716
+ await this.#runIdleCheck();
717
+ }, interval);
718
+ timer.unref?.();
719
+ this.idleTimer = timer;
1007
720
  }
1008
721
  }
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) {
722
+ async resolve(id) {
1018
723
  this.ensureNotDisposed();
1019
724
  const resolver = this.config.autoOpen?.resolver;
1020
725
  if (!resolver) return void 0;
@@ -1022,27 +727,21 @@ var LifecycleManager = class {
1022
727
  if (!resolved) return void 0;
1023
728
  const maxOpen = this.config.maxOpen;
1024
729
  if (maxOpen && maxOpen > 0 && this.callbacks.count() >= maxOpen) {
1025
- this.evict();
730
+ await this.evict();
1026
731
  if (this.callbacks.count() >= maxOpen) {
1027
732
  throw new MaxDatabasesError(maxOpen);
1028
733
  }
1029
734
  }
1030
- const db = this.callbacks.open(id, resolved.path, resolved.options);
735
+ const db = await this.callbacks.open(id, resolved.path, resolved.options);
1031
736
  this.markActive(id);
1032
737
  return db;
1033
738
  }
1034
- /** Record an access for the given database ID. */
1035
739
  markActive(id) {
1036
740
  if (!this._disposed) {
1037
741
  this.lastAccess.set(id, Date.now());
1038
742
  }
1039
743
  }
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() {
744
+ async checkIdle() {
1046
745
  const timeout = this.config.idleTimeout;
1047
746
  if (!timeout || timeout <= 0) return;
1048
747
  const now = Date.now();
@@ -1059,7 +758,7 @@ var LifecycleManager = class {
1059
758
  }
1060
759
  for (const id of toClose) {
1061
760
  try {
1062
- this.callbacks.close(id);
761
+ await this.callbacks.close(id);
1063
762
  } catch {
1064
763
  }
1065
764
  toRemove.push(id);
@@ -1068,11 +767,7 @@ var LifecycleManager = class {
1068
767
  this.lastAccess.delete(id);
1069
768
  }
1070
769
  }
1071
- /**
1072
- * Close the least-recently-used tracked database. Called internally
1073
- * by {@link resolve} when `maxOpen` capacity is reached.
1074
- */
1075
- evict() {
770
+ async evict() {
1076
771
  let oldestId = null;
1077
772
  let oldestTime = Infinity;
1078
773
  const stale = [];
@@ -1091,25 +786,21 @@ var LifecycleManager = class {
1091
786
  }
1092
787
  if (oldestId) {
1093
788
  try {
1094
- this.callbacks.close(oldestId);
789
+ await this.callbacks.close(oldestId);
1095
790
  } catch {
1096
791
  }
1097
792
  this.lastAccess.delete(oldestId);
1098
793
  }
1099
794
  }
1100
- /** Remove a database from idle tracking (e.g. after an explicit close). */
1101
795
  untrack(id) {
1102
796
  this.lastAccess.delete(id);
1103
797
  }
1104
- /** Whether this manager has been disposed. */
1105
798
  get disposed() {
1106
799
  return this._disposed;
1107
800
  }
1108
- /** The number of databases currently tracked for idle management. */
1109
801
  get trackedCount() {
1110
802
  return this.lastAccess.size;
1111
803
  }
1112
- /** Shut down the manager: stop the idle timer and clear all state. */
1113
804
  dispose() {
1114
805
  if (this._disposed) return;
1115
806
  this._disposed = true;
@@ -1124,7 +815,22 @@ var LifecycleManager = class {
1124
815
  throw new SirannonError("LifecycleManager has been disposed", "LIFECYCLE_DISPOSED");
1125
816
  }
1126
817
  }
818
+ async #runIdleCheck() {
819
+ if (this.#idleCheckPromise) {
820
+ await this.#idleCheckPromise;
821
+ return;
822
+ }
823
+ this.#idleCheckPromise = this.checkIdle();
824
+ try {
825
+ await this.#idleCheckPromise;
826
+ } catch {
827
+ } finally {
828
+ this.#idleCheckPromise = null;
829
+ }
830
+ }
1127
831
  };
832
+
833
+ // src/core/lifecycle/tenant.ts
1128
834
  var SAFE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
1129
835
  var MAX_ID_LENGTH = 255;
1130
836
  var MAX_FILENAME_LENGTH = 255;
@@ -1142,7 +848,7 @@ function tenantPath(basePath, tenantId, extension = ".db") {
1142
848
  if (filename.length > MAX_FILENAME_LENGTH) {
1143
849
  throw new Error(`Tenant filename exceeds maximum length of ${MAX_FILENAME_LENGTH} characters`);
1144
850
  }
1145
- return join(basePath, filename);
851
+ return `${basePath}/${filename}`;
1146
852
  }
1147
853
  function createTenantResolver(options) {
1148
854
  const ext = options.extension ?? ".db";
@@ -1153,7 +859,7 @@ function createTenantResolver(options) {
1153
859
  const filename = `${sanitized}${ext}`;
1154
860
  if (filename.length > MAX_FILENAME_LENGTH) return void 0;
1155
861
  return {
1156
- path: join(options.basePath, filename),
862
+ path: `${options.basePath}/${filename}`,
1157
863
  options: defaultOpts
1158
864
  };
1159
865
  };
@@ -1165,14 +871,14 @@ var MetricsCollector = class {
1165
871
  constructor(config) {
1166
872
  this.config = config ?? {};
1167
873
  }
1168
- trackQuery(fn, context) {
874
+ async trackQuery(fn, context) {
1169
875
  if (!this.config.onQueryComplete) {
1170
876
  return fn();
1171
877
  }
1172
878
  const start = performance.now();
1173
879
  let failed = false;
1174
880
  try {
1175
- return fn();
881
+ return await fn();
1176
882
  } catch (err) {
1177
883
  failed = true;
1178
884
  throw err;
@@ -1213,9 +919,10 @@ var MetricsCollector = class {
1213
919
  var Sirannon = class {
1214
920
  constructor(options) {
1215
921
  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, {
922
+ this._driver = options.driver;
923
+ this.hookRegistry = new HookRegistry(options.hooks);
924
+ this.metricsCollector = options.metrics ? new MetricsCollector(options.metrics) : null;
925
+ this.lifecycleManager = options.lifecycle ? new LifecycleManager(options.lifecycle, {
1219
926
  open: (id, path, opts) => this.open(id, path, opts),
1220
927
  close: (id) => this.close(id),
1221
928
  count: () => this.dbs.size,
@@ -1223,31 +930,44 @@ var Sirannon = class {
1223
930
  }) : null;
1224
931
  }
1225
932
  dbs = /* @__PURE__ */ new Map();
933
+ opening = /* @__PURE__ */ new Set();
1226
934
  _shutdown = false;
935
+ _driver;
1227
936
  hookRegistry;
1228
937
  metricsCollector;
1229
938
  lifecycleManager;
1230
- open(id, path, options) {
939
+ get driver() {
940
+ return this._driver;
941
+ }
942
+ async open(id, path, options) {
1231
943
  this.ensureRunning();
1232
- if (this.dbs.has(id)) {
944
+ if (this.dbs.has(id) || this.opening.has(id)) {
1233
945
  throw new DatabaseAlreadyExistsError(id);
1234
946
  }
1235
- if (this.hookRegistry.has("beforeConnect")) {
1236
- this.hookRegistry.invokeSync("beforeConnect", { databaseId: id, path });
1237
- }
947
+ this.opening.add(id);
1238
948
  let db;
1239
949
  try {
1240
- db = new Database2(id, path, options, {
950
+ if (this.hookRegistry.has("beforeConnect")) {
951
+ this.hookRegistry.invokeSync("beforeConnect", { databaseId: id, path });
952
+ }
953
+ db = await Database.create(id, path, this._driver, options, {
1241
954
  parentHooks: this.hookRegistry,
1242
955
  metrics: this.metricsCollector ?? void 0
1243
956
  });
1244
957
  } catch (err) {
958
+ this.opening.delete(id);
1245
959
  if (err instanceof SirannonError) throw err;
1246
960
  throw new SirannonError(
1247
961
  `Failed to open database '${id}' at '${path}': ${err instanceof Error ? err.message : String(err)}`,
1248
962
  "DATABASE_OPEN_FAILED"
1249
963
  );
1250
964
  }
965
+ this.opening.delete(id);
966
+ if (this._shutdown) {
967
+ await db.close().catch(() => {
968
+ });
969
+ throw new SirannonError("Sirannon has been shut down", "SHUTDOWN");
970
+ }
1251
971
  db.addCloseListener(() => {
1252
972
  this.dbs.delete(id);
1253
973
  this.lifecycleManager?.untrack(id);
@@ -1280,13 +1000,13 @@ var Sirannon = class {
1280
1000
  });
1281
1001
  return db;
1282
1002
  }
1283
- close(id) {
1003
+ async close(id) {
1284
1004
  this.ensureRunning();
1285
1005
  const db = this.dbs.get(id);
1286
1006
  if (!db) {
1287
1007
  throw new DatabaseNotFoundError(id);
1288
1008
  }
1289
- db.close();
1009
+ await db.close();
1290
1010
  }
1291
1011
  get(id) {
1292
1012
  const db = this.dbs.get(id);
@@ -1295,6 +1015,12 @@ var Sirannon = class {
1295
1015
  return db;
1296
1016
  }
1297
1017
  if (this._shutdown) return void 0;
1018
+ return void 0;
1019
+ }
1020
+ async resolve(id) {
1021
+ const db = this.get(id);
1022
+ if (db) return db;
1023
+ if (this._shutdown) return void 0;
1298
1024
  return this.lifecycleManager?.resolve(id);
1299
1025
  }
1300
1026
  has(id) {
@@ -1303,7 +1029,7 @@ var Sirannon = class {
1303
1029
  databases() {
1304
1030
  return new Map(this.dbs);
1305
1031
  }
1306
- shutdown() {
1032
+ async shutdown() {
1307
1033
  if (this._shutdown) return;
1308
1034
  this._shutdown = true;
1309
1035
  this.lifecycleManager?.dispose();
@@ -1311,7 +1037,7 @@ var Sirannon = class {
1311
1037
  const snapshot = [...this.dbs.values()];
1312
1038
  for (const db of snapshot) {
1313
1039
  try {
1314
- db.close();
1040
+ await db.close();
1315
1041
  } catch (err) {
1316
1042
  errors.push(err);
1317
1043
  }
@@ -1343,4 +1069,4 @@ var Sirannon = class {
1343
1069
  }
1344
1070
  };
1345
1071
 
1346
- export { BackupManager, BackupScheduler, ConnectionPool, Database2 as Database, HookRegistry, LifecycleManager, MetricsCollector, MigrationRunner, Sirannon, Transaction, createTenantResolver, execute, executeBatch, query, queryOne, sanitizeTenantId, tenantPath };
1072
+ export { ConnectionPool, Database, HookRegistry, LifecycleManager, MetricsCollector, MigrationRunner, Sirannon, createTenantResolver, sanitizeTenantId, tenantPath };