@delali/sirannon-db 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -77
- package/dist/backup-scheduler/index.d.ts +3 -0
- package/dist/backup-scheduler/index.mjs +2 -0
- package/dist/chunk-74UN4DIE.mjs +14 -0
- package/dist/{chunk-VI4UP4RR.mjs → chunk-AX66KWBR.mjs} +74 -139
- package/dist/chunk-FB2U2Q3Y.mjs +21 -0
- package/dist/chunk-O7BHI3CF.mjs +90 -0
- package/dist/chunk-PXKAKK2V.mjs +124 -0
- package/dist/client/index.d.ts +38 -2
- package/dist/core/index.d.ts +30 -142
- package/dist/core/index.mjs +229 -469
- package/dist/driver/better-sqlite3.d.ts +8 -0
- package/dist/driver/better-sqlite3.mjs +63 -0
- package/dist/driver/bun.mjs +61 -0
- package/dist/driver/expo.mjs +55 -0
- package/dist/driver/node.d.ts +8 -0
- package/dist/driver/node.mjs +60 -0
- package/dist/driver/wa-sqlite.d.ts +34 -0
- package/dist/driver/wa-sqlite.mjs +141 -0
- package/dist/file-migrations/index.d.ts +16 -0
- package/dist/file-migrations/index.mjs +128 -0
- package/dist/index-hXiis3N-.d.ts +16 -0
- package/dist/server/index.d.ts +110 -54
- package/dist/server/index.mjs +107 -92
- package/dist/{sirannon-BJ8Yd1Uf.d.ts → sirannon-B1oTfebD.d.ts} +30 -58
- package/dist/types-BFSsG77t.d.ts +29 -0
- package/dist/types-DRkJlqex.d.ts +38 -0
- package/dist/{types-DArCObcu.d.ts → types-DtDutWRU.d.ts} +4 -1
- package/dist/vfs-INWQ5DTE.mjs +2 -0
- package/package.json +58 -7
- package/dist/protocol-BX1H-_Mz.d.ts +0 -104
package/dist/core/index.mjs
CHANGED
|
@@ -1,214 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
1
|
+
import { SubscriptionBuilderImpl, ChangeTracker, SubscriptionManager, startPolling } from '../chunk-AX66KWBR.mjs';
|
|
2
|
+
export { defineDriver } from '../chunk-74UN4DIE.mjs';
|
|
3
|
+
import { BackupManager, BackupScheduler } from '../chunk-PXKAKK2V.mjs';
|
|
4
|
+
export { BackupManager, BackupScheduler } from '../chunk-PXKAKK2V.mjs';
|
|
5
|
+
import { ConnectionPoolError, QueryError, MigrationError, ReadOnlyError, ExtensionError, SirannonError, MaxDatabasesError, DatabaseAlreadyExistsError, DatabaseNotFoundError } from '../chunk-O7BHI3CF.mjs';
|
|
6
|
+
export { BackupError, CDCError, ConnectionPoolError, DatabaseAlreadyExistsError, DatabaseNotFoundError, ExtensionError, HookDeniedError, MaxDatabasesError, MigrationError, QueryError, ReadOnlyError, SirannonError, TransactionError } from '../chunk-O7BHI3CF.mjs';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
function
|
|
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) {
|
|
8
|
+
// src/core/connection-pool.ts
|
|
9
|
+
async function closeAllSilently(connections) {
|
|
168
10
|
for (const conn of connections) {
|
|
169
11
|
if (!conn) continue;
|
|
170
12
|
try {
|
|
171
|
-
conn.close();
|
|
13
|
+
await conn.close();
|
|
172
14
|
} catch {
|
|
173
15
|
}
|
|
174
16
|
}
|
|
175
17
|
}
|
|
176
|
-
var ConnectionPool = class {
|
|
18
|
+
var ConnectionPool = class _ConnectionPool {
|
|
177
19
|
writer;
|
|
178
20
|
readers;
|
|
179
21
|
readerIndex = 0;
|
|
180
22
|
closed = false;
|
|
181
|
-
constructor(
|
|
182
|
-
|
|
23
|
+
constructor(writer, readers) {
|
|
24
|
+
this.writer = writer;
|
|
25
|
+
this.readers = readers;
|
|
26
|
+
}
|
|
27
|
+
static async create(options) {
|
|
28
|
+
const { driver, path, readOnly = false, readPoolSize = 4, walMode = true } = options;
|
|
183
29
|
let writer = null;
|
|
184
30
|
const readers = [];
|
|
185
31
|
try {
|
|
186
32
|
if (!readOnly) {
|
|
187
|
-
writer =
|
|
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");
|
|
33
|
+
writer = await driver.open(path, { walMode });
|
|
194
34
|
}
|
|
195
|
-
const poolSize = Math.max(readPoolSize, 1);
|
|
35
|
+
const poolSize = driver.capabilities.multipleConnections ? Math.max(readPoolSize, 1) : 0;
|
|
196
36
|
for (let i = 0; i < poolSize; i++) {
|
|
197
|
-
const reader =
|
|
37
|
+
const reader = await driver.open(path, { readonly: true, walMode: false });
|
|
198
38
|
readers.push(reader);
|
|
199
|
-
reader.pragma("foreign_keys = ON");
|
|
200
39
|
}
|
|
201
40
|
} catch (err) {
|
|
202
|
-
closeAllSilently([...readers, writer]);
|
|
41
|
+
await closeAllSilently([...readers, writer]);
|
|
203
42
|
throw err;
|
|
204
43
|
}
|
|
205
|
-
|
|
206
|
-
this.readers = readers;
|
|
44
|
+
return new _ConnectionPool(writer, readers);
|
|
207
45
|
}
|
|
208
46
|
acquireReader() {
|
|
209
47
|
if (this.closed) {
|
|
210
48
|
throw new ConnectionPoolError("Connection pool is closed");
|
|
211
49
|
}
|
|
50
|
+
if (this.readers.length === 0) {
|
|
51
|
+
if (!this.writer) {
|
|
52
|
+
throw new ConnectionPoolError("No connections available");
|
|
53
|
+
}
|
|
54
|
+
return this.writer;
|
|
55
|
+
}
|
|
212
56
|
const reader = this.readers[this.readerIndex % this.readers.length];
|
|
213
57
|
this.readerIndex = (this.readerIndex + 1) % this.readers.length;
|
|
214
58
|
return reader;
|
|
@@ -228,31 +72,20 @@ var ConnectionPool = class {
|
|
|
228
72
|
get isReadOnly() {
|
|
229
73
|
return this.writer === null;
|
|
230
74
|
}
|
|
231
|
-
|
|
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() {
|
|
75
|
+
async close() {
|
|
243
76
|
if (this.closed) return;
|
|
244
77
|
this.closed = true;
|
|
245
78
|
const errors = [];
|
|
246
79
|
for (const reader of this.readers) {
|
|
247
80
|
try {
|
|
248
|
-
reader.close();
|
|
81
|
+
await reader.close();
|
|
249
82
|
} catch (err) {
|
|
250
83
|
errors.push(err);
|
|
251
84
|
}
|
|
252
85
|
}
|
|
253
86
|
if (this.writer) {
|
|
254
87
|
try {
|
|
255
|
-
this.writer.close();
|
|
88
|
+
await this.writer.close();
|
|
256
89
|
} catch (err) {
|
|
257
90
|
errors.push(err);
|
|
258
91
|
}
|
|
@@ -348,11 +181,11 @@ var HookRegistry = class {
|
|
|
348
181
|
// src/core/query-executor.ts
|
|
349
182
|
var STATEMENT_CACHE_CAPACITY = 128;
|
|
350
183
|
var statementCaches = /* @__PURE__ */ new WeakMap();
|
|
351
|
-
function getStatement(
|
|
352
|
-
let cache = statementCaches.get(
|
|
184
|
+
async function getStatement(conn, sql) {
|
|
185
|
+
let cache = statementCaches.get(conn);
|
|
353
186
|
if (!cache) {
|
|
354
187
|
cache = /* @__PURE__ */ new Map();
|
|
355
|
-
statementCaches.set(
|
|
188
|
+
statementCaches.set(conn, cache);
|
|
356
189
|
}
|
|
357
190
|
const cached = cache.get(sql);
|
|
358
191
|
if (cached) {
|
|
@@ -360,64 +193,66 @@ function getStatement(db, sql) {
|
|
|
360
193
|
cache.set(sql, cached);
|
|
361
194
|
return cached;
|
|
362
195
|
}
|
|
363
|
-
const
|
|
364
|
-
cache.set(sql,
|
|
196
|
+
const pending = conn.prepare(sql);
|
|
197
|
+
cache.set(sql, pending);
|
|
365
198
|
if (cache.size > STATEMENT_CACHE_CAPACITY) {
|
|
366
199
|
const oldest = cache.keys().next().value;
|
|
367
200
|
if (oldest !== void 0) {
|
|
368
201
|
cache.delete(oldest);
|
|
369
202
|
}
|
|
370
203
|
}
|
|
371
|
-
|
|
204
|
+
try {
|
|
205
|
+
return await pending;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
cache.delete(sql);
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
372
210
|
}
|
|
373
211
|
function bindParams(params) {
|
|
374
212
|
if (params === void 0) return [];
|
|
375
213
|
if (Array.isArray(params)) return params;
|
|
376
214
|
return [params];
|
|
377
215
|
}
|
|
378
|
-
function query(
|
|
216
|
+
async function query(conn, sql, params) {
|
|
379
217
|
try {
|
|
380
|
-
const stmt = getStatement(
|
|
381
|
-
return stmt.all(...bindParams(params));
|
|
218
|
+
const stmt = await getStatement(conn, sql);
|
|
219
|
+
return await stmt.all(...bindParams(params));
|
|
382
220
|
} catch (err) {
|
|
383
221
|
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
384
222
|
}
|
|
385
223
|
}
|
|
386
|
-
function queryOne(
|
|
224
|
+
async function queryOne(conn, sql, params) {
|
|
387
225
|
try {
|
|
388
|
-
const stmt = getStatement(
|
|
389
|
-
return stmt.get(...bindParams(params));
|
|
226
|
+
const stmt = await getStatement(conn, sql);
|
|
227
|
+
return await stmt.get(...bindParams(params));
|
|
390
228
|
} catch (err) {
|
|
391
229
|
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
392
230
|
}
|
|
393
231
|
}
|
|
394
|
-
function execute(
|
|
232
|
+
async function execute(conn, sql, params) {
|
|
395
233
|
try {
|
|
396
|
-
const stmt = getStatement(
|
|
397
|
-
const result = stmt.run(...bindParams(params));
|
|
234
|
+
const stmt = await getStatement(conn, sql);
|
|
235
|
+
const result = await stmt.run(...bindParams(params));
|
|
398
236
|
return {
|
|
399
237
|
changes: result.changes,
|
|
400
|
-
lastInsertRowId: result.
|
|
238
|
+
lastInsertRowId: result.lastInsertRowId
|
|
401
239
|
};
|
|
402
240
|
} catch (err) {
|
|
403
241
|
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
404
242
|
}
|
|
405
243
|
}
|
|
406
|
-
function executeBatch(
|
|
244
|
+
async function executeBatch(conn, sql, paramsBatch) {
|
|
407
245
|
try {
|
|
408
|
-
const stmt = getStatement(
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return results;
|
|
419
|
-
});
|
|
420
|
-
return run();
|
|
246
|
+
const stmt = await getStatement(conn, sql);
|
|
247
|
+
const results = [];
|
|
248
|
+
for (const params of paramsBatch) {
|
|
249
|
+
const result = await stmt.run(...bindParams(params));
|
|
250
|
+
results.push({
|
|
251
|
+
changes: result.changes,
|
|
252
|
+
lastInsertRowId: result.lastInsertRowId
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
421
256
|
} catch (err) {
|
|
422
257
|
throw new QueryError(err instanceof Error ? err.message : String(err), sql);
|
|
423
258
|
}
|
|
@@ -425,20 +260,20 @@ function executeBatch(db, sql, paramsBatch) {
|
|
|
425
260
|
|
|
426
261
|
// src/core/transaction.ts
|
|
427
262
|
var Transaction = class _Transaction {
|
|
428
|
-
constructor(
|
|
429
|
-
this.
|
|
263
|
+
constructor(conn) {
|
|
264
|
+
this.conn = conn;
|
|
430
265
|
}
|
|
431
266
|
_lastInsertRowId = 0;
|
|
432
|
-
query(sql, params) {
|
|
433
|
-
return query(this.
|
|
267
|
+
async query(sql, params) {
|
|
268
|
+
return query(this.conn, sql, params);
|
|
434
269
|
}
|
|
435
|
-
execute(sql, params) {
|
|
436
|
-
const result = execute(this.
|
|
270
|
+
async execute(sql, params) {
|
|
271
|
+
const result = await execute(this.conn, sql, params);
|
|
437
272
|
this._lastInsertRowId = result.lastInsertRowId;
|
|
438
273
|
return result;
|
|
439
274
|
}
|
|
440
|
-
executeBatch(sql, paramsBatch) {
|
|
441
|
-
const results = executeBatch(this.
|
|
275
|
+
async executeBatch(sql, paramsBatch) {
|
|
276
|
+
const results = await executeBatch(this.conn, sql, paramsBatch);
|
|
442
277
|
if (results.length > 0) {
|
|
443
278
|
this._lastInsertRowId = results[results.length - 1].lastInsertRowId;
|
|
444
279
|
}
|
|
@@ -447,124 +282,13 @@ var Transaction = class _Transaction {
|
|
|
447
282
|
get lastInsertRowId() {
|
|
448
283
|
return this._lastInsertRowId;
|
|
449
284
|
}
|
|
450
|
-
static run(
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
285
|
+
static async run(conn, fn) {
|
|
286
|
+
return conn.transaction(async (txConn) => {
|
|
287
|
+
const tx = new _Transaction(txConn);
|
|
288
|
+
return fn(tx);
|
|
523
289
|
});
|
|
524
290
|
}
|
|
525
|
-
|
|
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
|
-
}
|
|
291
|
+
};
|
|
568
292
|
|
|
569
293
|
// src/core/migrations/runner.ts
|
|
570
294
|
var CREATE_TRACKING_TABLE = `
|
|
@@ -575,23 +299,24 @@ var CREATE_TRACKING_TABLE = `
|
|
|
575
299
|
)
|
|
576
300
|
`;
|
|
577
301
|
var MigrationRunner = class _MigrationRunner {
|
|
578
|
-
static run(
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
const applied = _MigrationRunner.getAppliedVersions(
|
|
582
|
-
const pending =
|
|
302
|
+
static async run(conn, migrations) {
|
|
303
|
+
await conn.exec(CREATE_TRACKING_TABLE);
|
|
304
|
+
const validated = _MigrationRunner.validateMigrations(migrations);
|
|
305
|
+
const applied = await _MigrationRunner.getAppliedVersions(conn);
|
|
306
|
+
const pending = validated.filter((m) => !applied.has(m.version));
|
|
583
307
|
if (pending.length === 0) {
|
|
584
|
-
return { applied: [], skipped:
|
|
308
|
+
return { applied: [], skipped: validated.length };
|
|
585
309
|
}
|
|
586
|
-
const insertMigration = db.prepare("INSERT INTO _sirannon_migrations (version, name) VALUES (?, ?)");
|
|
587
310
|
const appliedEntries = [];
|
|
588
|
-
|
|
311
|
+
await conn.transaction(async (txConn) => {
|
|
312
|
+
const insertStmt = await txConn.prepare("INSERT INTO _sirannon_migrations (version, name) VALUES (?, ?)");
|
|
589
313
|
for (const migration of pending) {
|
|
590
314
|
try {
|
|
591
315
|
if (typeof migration.up === "string") {
|
|
592
|
-
|
|
316
|
+
await txConn.exec(migration.up);
|
|
593
317
|
} else {
|
|
594
|
-
migration.up(new Transaction(
|
|
318
|
+
const result = migration.up(new Transaction(txConn));
|
|
319
|
+
if (result instanceof Promise) await result;
|
|
595
320
|
}
|
|
596
321
|
} catch (err) {
|
|
597
322
|
if (err instanceof MigrationError) throw err;
|
|
@@ -600,16 +325,16 @@ var MigrationRunner = class _MigrationRunner {
|
|
|
600
325
|
migration.version
|
|
601
326
|
);
|
|
602
327
|
}
|
|
603
|
-
|
|
328
|
+
await insertStmt.run(migration.version, migration.name);
|
|
604
329
|
appliedEntries.push({ version: migration.version, name: migration.name });
|
|
605
330
|
}
|
|
606
|
-
})
|
|
331
|
+
});
|
|
607
332
|
return {
|
|
608
333
|
applied: appliedEntries,
|
|
609
|
-
skipped:
|
|
334
|
+
skipped: validated.length - pending.length
|
|
610
335
|
};
|
|
611
336
|
}
|
|
612
|
-
static rollback(
|
|
337
|
+
static async rollback(conn, migrations, version) {
|
|
613
338
|
if (version !== void 0 && (!Number.isSafeInteger(version) || version < 0)) {
|
|
614
339
|
throw new MigrationError(
|
|
615
340
|
`Invalid rollback target version: ${version}`,
|
|
@@ -617,8 +342,9 @@ var MigrationRunner = class _MigrationRunner {
|
|
|
617
342
|
"MIGRATION_VALIDATION_ERROR"
|
|
618
343
|
);
|
|
619
344
|
}
|
|
620
|
-
|
|
621
|
-
const
|
|
345
|
+
await conn.exec(CREATE_TRACKING_TABLE);
|
|
346
|
+
const selectStmt = await conn.prepare("SELECT version, name FROM _sirannon_migrations ORDER BY version DESC");
|
|
347
|
+
const appliedRows = await selectStmt.all();
|
|
622
348
|
if (appliedRows.length === 0) {
|
|
623
349
|
return { rolledBack: [] };
|
|
624
350
|
}
|
|
@@ -631,26 +357,20 @@ var MigrationRunner = class _MigrationRunner {
|
|
|
631
357
|
if (rollbackSet.length === 0) {
|
|
632
358
|
return { rolledBack: [] };
|
|
633
359
|
}
|
|
360
|
+
_MigrationRunner.validateMigrations(migrations);
|
|
634
361
|
const rollbackVersions = rollbackSet.map((r) => r.version);
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
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);
|
|
362
|
+
const inputByVersion = new Map(migrations.map((m) => [m.version, m]));
|
|
363
|
+
const downByVersion = /* @__PURE__ */ new Map();
|
|
364
|
+
for (const v of rollbackVersions) {
|
|
365
|
+
const m = inputByVersion.get(v);
|
|
366
|
+
if (!m || m.down === void 0) {
|
|
367
|
+
throw new MigrationError(`Migration version ${v} has no down migration`, v, "MIGRATION_NO_DOWN");
|
|
649
368
|
}
|
|
369
|
+
downByVersion.set(v, m);
|
|
650
370
|
}
|
|
651
|
-
const deleteMigration = db.prepare("DELETE FROM _sirannon_migrations WHERE version = ?");
|
|
652
371
|
const rolledBackEntries = [];
|
|
653
|
-
|
|
372
|
+
await conn.transaction(async (txConn) => {
|
|
373
|
+
const deleteStmt = await txConn.prepare("DELETE FROM _sirannon_migrations WHERE version = ?");
|
|
654
374
|
for (const entry of rollbackSet) {
|
|
655
375
|
const migration = downByVersion.get(entry.version);
|
|
656
376
|
if (!migration) {
|
|
@@ -662,9 +382,10 @@ var MigrationRunner = class _MigrationRunner {
|
|
|
662
382
|
}
|
|
663
383
|
try {
|
|
664
384
|
if (typeof migration.down === "string") {
|
|
665
|
-
|
|
385
|
+
await txConn.exec(migration.down);
|
|
666
386
|
} else {
|
|
667
|
-
migration.down?.(new Transaction(
|
|
387
|
+
const result = migration.down?.(new Transaction(txConn));
|
|
388
|
+
if (result instanceof Promise) await result;
|
|
668
389
|
}
|
|
669
390
|
} catch (err) {
|
|
670
391
|
if (err instanceof MigrationError) throw err;
|
|
@@ -674,10 +395,10 @@ var MigrationRunner = class _MigrationRunner {
|
|
|
674
395
|
"MIGRATION_ROLLBACK_ERROR"
|
|
675
396
|
);
|
|
676
397
|
}
|
|
677
|
-
|
|
398
|
+
await deleteStmt.run(entry.version);
|
|
678
399
|
rolledBackEntries.push({ version: entry.version, name: entry.name });
|
|
679
400
|
}
|
|
680
|
-
})
|
|
401
|
+
});
|
|
681
402
|
return { rolledBack: rolledBackEntries };
|
|
682
403
|
}
|
|
683
404
|
static validateMigrations(migrations) {
|
|
@@ -719,18 +440,20 @@ var MigrationRunner = class _MigrationRunner {
|
|
|
719
440
|
}
|
|
720
441
|
return [...migrations].sort((a, b) => a.version - b.version);
|
|
721
442
|
}
|
|
722
|
-
static getAppliedVersions(
|
|
723
|
-
const
|
|
443
|
+
static async getAppliedVersions(conn) {
|
|
444
|
+
const stmt = await conn.prepare("SELECT version FROM _sirannon_migrations ORDER BY version");
|
|
445
|
+
const rows = await stmt.all();
|
|
724
446
|
return new Set(rows.map((r) => r.version));
|
|
725
447
|
}
|
|
726
448
|
};
|
|
727
449
|
|
|
728
450
|
// src/core/database.ts
|
|
729
|
-
var
|
|
451
|
+
var Database = class _Database {
|
|
730
452
|
id;
|
|
731
453
|
path;
|
|
732
454
|
readOnly;
|
|
733
455
|
pool;
|
|
456
|
+
driver;
|
|
734
457
|
closeListeners = [];
|
|
735
458
|
_closed = false;
|
|
736
459
|
changeTracker = null;
|
|
@@ -744,109 +467,119 @@ var Database2 = class {
|
|
|
744
467
|
backupManager = new BackupManager();
|
|
745
468
|
backupScheduler = new BackupScheduler(this.backupManager);
|
|
746
469
|
scheduledBackupCancellers = [];
|
|
747
|
-
constructor(id, path, options, internals) {
|
|
470
|
+
constructor(id, path, pool, driver, options, internals) {
|
|
748
471
|
this.id = id;
|
|
749
472
|
this.path = path;
|
|
473
|
+
this.pool = pool;
|
|
474
|
+
this.driver = driver;
|
|
750
475
|
this.readOnly = options?.readOnly ?? false;
|
|
751
476
|
this.cdcPollInterval = options?.cdcPollInterval ?? 50;
|
|
752
477
|
this.cdcRetention = options?.cdcRetention ?? 36e5;
|
|
753
478
|
this.parentHooks = internals?.parentHooks ?? null;
|
|
754
479
|
this.metricsCollector = internals?.metrics ?? null;
|
|
755
|
-
|
|
480
|
+
}
|
|
481
|
+
static async create(id, path, driver, options, internals) {
|
|
482
|
+
const pool = await ConnectionPool.create({
|
|
483
|
+
driver,
|
|
756
484
|
path,
|
|
757
|
-
readOnly:
|
|
485
|
+
readOnly: options?.readOnly,
|
|
758
486
|
readPoolSize: options?.readPoolSize ?? 4,
|
|
759
487
|
walMode: options?.walMode ?? true
|
|
760
488
|
});
|
|
489
|
+
return new _Database(id, path, pool, driver, options, internals);
|
|
761
490
|
}
|
|
762
|
-
query(sql, params) {
|
|
491
|
+
async query(sql, params) {
|
|
763
492
|
this.ensureOpen();
|
|
764
493
|
this.fireBeforeQueryHooks(sql, params);
|
|
765
494
|
const start = performance.now();
|
|
766
495
|
try {
|
|
767
496
|
const reader = this.pool.acquireReader();
|
|
768
497
|
if (this.metricsCollector) {
|
|
769
|
-
return this.metricsCollector.trackQuery(() => query(reader, sql, params), {
|
|
498
|
+
return await this.metricsCollector.trackQuery(() => query(reader, sql, params), {
|
|
770
499
|
databaseId: this.id,
|
|
771
500
|
sql
|
|
772
501
|
});
|
|
773
502
|
}
|
|
774
|
-
return query(reader, sql, params);
|
|
503
|
+
return await query(reader, sql, params);
|
|
775
504
|
} finally {
|
|
776
505
|
this.fireAfterQueryHooks(sql, params, performance.now() - start);
|
|
777
506
|
}
|
|
778
507
|
}
|
|
779
|
-
queryOne(sql, params) {
|
|
508
|
+
async queryOne(sql, params) {
|
|
780
509
|
this.ensureOpen();
|
|
781
510
|
this.fireBeforeQueryHooks(sql, params);
|
|
782
511
|
const start = performance.now();
|
|
783
512
|
try {
|
|
784
513
|
const reader = this.pool.acquireReader();
|
|
785
514
|
if (this.metricsCollector) {
|
|
786
|
-
return this.metricsCollector.trackQuery(() => queryOne(reader, sql, params), {
|
|
515
|
+
return await this.metricsCollector.trackQuery(() => queryOne(reader, sql, params), {
|
|
787
516
|
databaseId: this.id,
|
|
788
517
|
sql
|
|
789
518
|
});
|
|
790
519
|
}
|
|
791
|
-
return queryOne(reader, sql, params);
|
|
520
|
+
return await queryOne(reader, sql, params);
|
|
792
521
|
} finally {
|
|
793
522
|
this.fireAfterQueryHooks(sql, params, performance.now() - start);
|
|
794
523
|
}
|
|
795
524
|
}
|
|
796
|
-
execute(sql, params) {
|
|
525
|
+
async execute(sql, params) {
|
|
797
526
|
this.ensureOpen();
|
|
527
|
+
if (this.readOnly) throw new ReadOnlyError(this.id);
|
|
798
528
|
this.fireBeforeQueryHooks(sql, params);
|
|
799
529
|
const start = performance.now();
|
|
800
530
|
try {
|
|
801
531
|
const writer = this.pool.acquireWriter();
|
|
802
532
|
if (this.metricsCollector) {
|
|
803
|
-
return this.metricsCollector.trackQuery(() => execute(writer, sql, params), {
|
|
533
|
+
return await this.metricsCollector.trackQuery(() => execute(writer, sql, params), {
|
|
804
534
|
databaseId: this.id,
|
|
805
535
|
sql
|
|
806
536
|
});
|
|
807
537
|
}
|
|
808
|
-
return execute(writer, sql, params);
|
|
538
|
+
return await execute(writer, sql, params);
|
|
809
539
|
} finally {
|
|
810
540
|
this.fireAfterQueryHooks(sql, params, performance.now() - start);
|
|
811
541
|
}
|
|
812
542
|
}
|
|
813
|
-
executeBatch(sql, paramsBatch) {
|
|
543
|
+
async executeBatch(sql, paramsBatch) {
|
|
814
544
|
this.ensureOpen();
|
|
545
|
+
if (this.readOnly) throw new ReadOnlyError(this.id);
|
|
815
546
|
this.fireBeforeQueryHooks(sql);
|
|
816
547
|
const start = performance.now();
|
|
817
548
|
try {
|
|
818
549
|
const writer = this.pool.acquireWriter();
|
|
550
|
+
const batchFn = () => writer.transaction(async (txConn) => executeBatch(txConn, sql, paramsBatch));
|
|
819
551
|
if (this.metricsCollector) {
|
|
820
|
-
return this.metricsCollector.trackQuery(
|
|
552
|
+
return await this.metricsCollector.trackQuery(batchFn, {
|
|
821
553
|
databaseId: this.id,
|
|
822
554
|
sql
|
|
823
555
|
});
|
|
824
556
|
}
|
|
825
|
-
return
|
|
557
|
+
return await batchFn();
|
|
826
558
|
} finally {
|
|
827
559
|
this.fireAfterQueryHooks(sql, void 0, performance.now() - start);
|
|
828
560
|
}
|
|
829
561
|
}
|
|
830
|
-
transaction(fn) {
|
|
562
|
+
async transaction(fn) {
|
|
831
563
|
this.ensureOpen();
|
|
564
|
+
if (this.readOnly) throw new ReadOnlyError(this.id);
|
|
832
565
|
const writer = this.pool.acquireWriter();
|
|
833
566
|
return Transaction.run(writer, fn);
|
|
834
567
|
}
|
|
835
|
-
watch(table) {
|
|
568
|
+
async watch(table) {
|
|
836
569
|
this.ensureOpen();
|
|
837
570
|
if (this.readOnly) {
|
|
838
571
|
throw new ReadOnlyError(this.id);
|
|
839
572
|
}
|
|
840
573
|
this.ensureCdc();
|
|
841
574
|
const writer = this.pool.acquireWriter();
|
|
842
|
-
this.changeTracker?.watch(writer, table);
|
|
575
|
+
await this.changeTracker?.watch(writer, table);
|
|
843
576
|
this.ensureCdcPolling();
|
|
844
577
|
}
|
|
845
|
-
unwatch(table) {
|
|
578
|
+
async unwatch(table) {
|
|
846
579
|
this.ensureOpen();
|
|
847
580
|
if (!this.changeTracker) return;
|
|
848
581
|
const writer = this.pool.acquireWriter();
|
|
849
|
-
this.changeTracker.unwatch(writer, table);
|
|
582
|
+
await this.changeTracker.unwatch(writer, table);
|
|
850
583
|
if (this.changeTracker.watchedTables.size === 0) {
|
|
851
584
|
this.stopCdcPollingLoop();
|
|
852
585
|
}
|
|
@@ -858,20 +591,20 @@ var Database2 = class {
|
|
|
858
591
|
if (!manager) throw new Error("subscriptionManager not initialized");
|
|
859
592
|
return new SubscriptionBuilderImpl(table, manager);
|
|
860
593
|
}
|
|
861
|
-
migrate(
|
|
594
|
+
async migrate(migrations) {
|
|
862
595
|
this.ensureOpen();
|
|
863
596
|
const writer = this.pool.acquireWriter();
|
|
864
|
-
return MigrationRunner.run(writer,
|
|
597
|
+
return MigrationRunner.run(writer, migrations);
|
|
865
598
|
}
|
|
866
|
-
rollback(
|
|
599
|
+
async rollback(migrations, version) {
|
|
867
600
|
this.ensureOpen();
|
|
868
601
|
const writer = this.pool.acquireWriter();
|
|
869
|
-
return MigrationRunner.rollback(writer,
|
|
602
|
+
return MigrationRunner.rollback(writer, migrations, version);
|
|
870
603
|
}
|
|
871
|
-
backup(destPath) {
|
|
604
|
+
async backup(destPath) {
|
|
872
605
|
this.ensureOpen();
|
|
873
606
|
const writer = this.pool.acquireWriter();
|
|
874
|
-
this.backupManager.backup(writer, destPath);
|
|
607
|
+
await this.backupManager.backup(writer, destPath);
|
|
875
608
|
}
|
|
876
609
|
scheduleBackup(options) {
|
|
877
610
|
this.ensureOpen();
|
|
@@ -879,18 +612,29 @@ var Database2 = class {
|
|
|
879
612
|
const cancel = this.backupScheduler.schedule(writer, options);
|
|
880
613
|
this.scheduledBackupCancellers.push(cancel);
|
|
881
614
|
}
|
|
882
|
-
loadExtension(extensionPath) {
|
|
615
|
+
async loadExtension(extensionPath) {
|
|
883
616
|
this.ensureOpen();
|
|
617
|
+
if (!this.driver.capabilities.extensions) {
|
|
618
|
+
throw new ExtensionError(extensionPath, "Extensions are not supported by the current driver");
|
|
619
|
+
}
|
|
884
620
|
if (!extensionPath || extensionPath.includes("\0")) {
|
|
885
621
|
throw new ExtensionError(extensionPath || "", "Extension path is empty or contains null bytes");
|
|
886
622
|
}
|
|
623
|
+
for (let i = 0; i < extensionPath.length; i++) {
|
|
624
|
+
if (extensionPath.charCodeAt(i) <= 31) {
|
|
625
|
+
throw new ExtensionError(extensionPath, "Extension path contains control characters");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
887
628
|
const segments = extensionPath.split(/[/\\]/);
|
|
888
629
|
if (segments.includes("..")) {
|
|
889
630
|
throw new ExtensionError(extensionPath, "Extension path must not contain directory traversal segments");
|
|
890
631
|
}
|
|
632
|
+
const { resolve } = await import('path');
|
|
891
633
|
const resolved = resolve(extensionPath);
|
|
892
634
|
try {
|
|
893
|
-
this.pool.
|
|
635
|
+
const writer = this.pool.acquireWriter();
|
|
636
|
+
const escaped = resolved.replace(/'/g, "''");
|
|
637
|
+
await writer.exec(`SELECT load_extension('${escaped}')`);
|
|
894
638
|
} catch (err) {
|
|
895
639
|
throw new ExtensionError(extensionPath, err instanceof Error ? err.message : String(err));
|
|
896
640
|
}
|
|
@@ -905,7 +649,7 @@ var Database2 = class {
|
|
|
905
649
|
this.ensureOpen();
|
|
906
650
|
this.closeListeners.push(fn);
|
|
907
651
|
}
|
|
908
|
-
close() {
|
|
652
|
+
async close() {
|
|
909
653
|
if (this._closed) return;
|
|
910
654
|
this._closed = true;
|
|
911
655
|
this.stopCdcPollingLoop();
|
|
@@ -918,13 +662,13 @@ var Database2 = class {
|
|
|
918
662
|
this.scheduledBackupCancellers.length = 0;
|
|
919
663
|
let poolError;
|
|
920
664
|
try {
|
|
921
|
-
this.pool.close();
|
|
665
|
+
await this.pool.close();
|
|
922
666
|
} catch (err) {
|
|
923
667
|
poolError = err;
|
|
924
668
|
}
|
|
925
669
|
for (const fn of this.closeListeners) {
|
|
926
670
|
try {
|
|
927
|
-
fn();
|
|
671
|
+
await fn();
|
|
928
672
|
} catch {
|
|
929
673
|
}
|
|
930
674
|
}
|
|
@@ -994,27 +738,22 @@ var LifecycleManager = class {
|
|
|
994
738
|
lastAccess = /* @__PURE__ */ new Map();
|
|
995
739
|
idleTimer = null;
|
|
996
740
|
_disposed = false;
|
|
741
|
+
#idleCheckPromise = null;
|
|
997
742
|
constructor(config, callbacks) {
|
|
998
743
|
this.config = config;
|
|
999
744
|
this.callbacks = callbacks;
|
|
1000
745
|
const timeout = config.idleTimeout;
|
|
1001
746
|
if (timeout && timeout > 0) {
|
|
1002
747
|
const interval = Math.min(Math.max(Math.floor(timeout / 2), 100), 6e4);
|
|
1003
|
-
this.idleTimer = setInterval(() =>
|
|
748
|
+
this.idleTimer = setInterval(async () => {
|
|
749
|
+
await this.#runIdleCheck();
|
|
750
|
+
}, interval);
|
|
1004
751
|
if (typeof this.idleTimer === "object" && "unref" in this.idleTimer) {
|
|
1005
752
|
this.idleTimer.unref();
|
|
1006
753
|
}
|
|
1007
754
|
}
|
|
1008
755
|
}
|
|
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) {
|
|
756
|
+
async resolve(id) {
|
|
1018
757
|
this.ensureNotDisposed();
|
|
1019
758
|
const resolver = this.config.autoOpen?.resolver;
|
|
1020
759
|
if (!resolver) return void 0;
|
|
@@ -1022,27 +761,21 @@ var LifecycleManager = class {
|
|
|
1022
761
|
if (!resolved) return void 0;
|
|
1023
762
|
const maxOpen = this.config.maxOpen;
|
|
1024
763
|
if (maxOpen && maxOpen > 0 && this.callbacks.count() >= maxOpen) {
|
|
1025
|
-
this.evict();
|
|
764
|
+
await this.evict();
|
|
1026
765
|
if (this.callbacks.count() >= maxOpen) {
|
|
1027
766
|
throw new MaxDatabasesError(maxOpen);
|
|
1028
767
|
}
|
|
1029
768
|
}
|
|
1030
|
-
const db = this.callbacks.open(id, resolved.path, resolved.options);
|
|
769
|
+
const db = await this.callbacks.open(id, resolved.path, resolved.options);
|
|
1031
770
|
this.markActive(id);
|
|
1032
771
|
return db;
|
|
1033
772
|
}
|
|
1034
|
-
/** Record an access for the given database ID. */
|
|
1035
773
|
markActive(id) {
|
|
1036
774
|
if (!this._disposed) {
|
|
1037
775
|
this.lastAccess.set(id, Date.now());
|
|
1038
776
|
}
|
|
1039
777
|
}
|
|
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() {
|
|
778
|
+
async checkIdle() {
|
|
1046
779
|
const timeout = this.config.idleTimeout;
|
|
1047
780
|
if (!timeout || timeout <= 0) return;
|
|
1048
781
|
const now = Date.now();
|
|
@@ -1059,7 +792,7 @@ var LifecycleManager = class {
|
|
|
1059
792
|
}
|
|
1060
793
|
for (const id of toClose) {
|
|
1061
794
|
try {
|
|
1062
|
-
this.callbacks.close(id);
|
|
795
|
+
await this.callbacks.close(id);
|
|
1063
796
|
} catch {
|
|
1064
797
|
}
|
|
1065
798
|
toRemove.push(id);
|
|
@@ -1068,11 +801,7 @@ var LifecycleManager = class {
|
|
|
1068
801
|
this.lastAccess.delete(id);
|
|
1069
802
|
}
|
|
1070
803
|
}
|
|
1071
|
-
|
|
1072
|
-
* Close the least-recently-used tracked database. Called internally
|
|
1073
|
-
* by {@link resolve} when `maxOpen` capacity is reached.
|
|
1074
|
-
*/
|
|
1075
|
-
evict() {
|
|
804
|
+
async evict() {
|
|
1076
805
|
let oldestId = null;
|
|
1077
806
|
let oldestTime = Infinity;
|
|
1078
807
|
const stale = [];
|
|
@@ -1091,25 +820,21 @@ var LifecycleManager = class {
|
|
|
1091
820
|
}
|
|
1092
821
|
if (oldestId) {
|
|
1093
822
|
try {
|
|
1094
|
-
this.callbacks.close(oldestId);
|
|
823
|
+
await this.callbacks.close(oldestId);
|
|
1095
824
|
} catch {
|
|
1096
825
|
}
|
|
1097
826
|
this.lastAccess.delete(oldestId);
|
|
1098
827
|
}
|
|
1099
828
|
}
|
|
1100
|
-
/** Remove a database from idle tracking (e.g. after an explicit close). */
|
|
1101
829
|
untrack(id) {
|
|
1102
830
|
this.lastAccess.delete(id);
|
|
1103
831
|
}
|
|
1104
|
-
/** Whether this manager has been disposed. */
|
|
1105
832
|
get disposed() {
|
|
1106
833
|
return this._disposed;
|
|
1107
834
|
}
|
|
1108
|
-
/** The number of databases currently tracked for idle management. */
|
|
1109
835
|
get trackedCount() {
|
|
1110
836
|
return this.lastAccess.size;
|
|
1111
837
|
}
|
|
1112
|
-
/** Shut down the manager: stop the idle timer and clear all state. */
|
|
1113
838
|
dispose() {
|
|
1114
839
|
if (this._disposed) return;
|
|
1115
840
|
this._disposed = true;
|
|
@@ -1124,7 +849,22 @@ var LifecycleManager = class {
|
|
|
1124
849
|
throw new SirannonError("LifecycleManager has been disposed", "LIFECYCLE_DISPOSED");
|
|
1125
850
|
}
|
|
1126
851
|
}
|
|
852
|
+
async #runIdleCheck() {
|
|
853
|
+
if (this.#idleCheckPromise) {
|
|
854
|
+
await this.#idleCheckPromise;
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
this.#idleCheckPromise = this.checkIdle();
|
|
858
|
+
try {
|
|
859
|
+
await this.#idleCheckPromise;
|
|
860
|
+
} catch {
|
|
861
|
+
} finally {
|
|
862
|
+
this.#idleCheckPromise = null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
1127
865
|
};
|
|
866
|
+
|
|
867
|
+
// src/core/lifecycle/tenant.ts
|
|
1128
868
|
var SAFE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
1129
869
|
var MAX_ID_LENGTH = 255;
|
|
1130
870
|
var MAX_FILENAME_LENGTH = 255;
|
|
@@ -1142,7 +882,7 @@ function tenantPath(basePath, tenantId, extension = ".db") {
|
|
|
1142
882
|
if (filename.length > MAX_FILENAME_LENGTH) {
|
|
1143
883
|
throw new Error(`Tenant filename exceeds maximum length of ${MAX_FILENAME_LENGTH} characters`);
|
|
1144
884
|
}
|
|
1145
|
-
return
|
|
885
|
+
return `${basePath}/${filename}`;
|
|
1146
886
|
}
|
|
1147
887
|
function createTenantResolver(options) {
|
|
1148
888
|
const ext = options.extension ?? ".db";
|
|
@@ -1153,7 +893,7 @@ function createTenantResolver(options) {
|
|
|
1153
893
|
const filename = `${sanitized}${ext}`;
|
|
1154
894
|
if (filename.length > MAX_FILENAME_LENGTH) return void 0;
|
|
1155
895
|
return {
|
|
1156
|
-
path:
|
|
896
|
+
path: `${options.basePath}/${filename}`,
|
|
1157
897
|
options: defaultOpts
|
|
1158
898
|
};
|
|
1159
899
|
};
|
|
@@ -1165,14 +905,14 @@ var MetricsCollector = class {
|
|
|
1165
905
|
constructor(config) {
|
|
1166
906
|
this.config = config ?? {};
|
|
1167
907
|
}
|
|
1168
|
-
trackQuery(fn, context) {
|
|
908
|
+
async trackQuery(fn, context) {
|
|
1169
909
|
if (!this.config.onQueryComplete) {
|
|
1170
910
|
return fn();
|
|
1171
911
|
}
|
|
1172
912
|
const start = performance.now();
|
|
1173
913
|
let failed = false;
|
|
1174
914
|
try {
|
|
1175
|
-
return fn();
|
|
915
|
+
return await fn();
|
|
1176
916
|
} catch (err) {
|
|
1177
917
|
failed = true;
|
|
1178
918
|
throw err;
|
|
@@ -1213,9 +953,10 @@ var MetricsCollector = class {
|
|
|
1213
953
|
var Sirannon = class {
|
|
1214
954
|
constructor(options) {
|
|
1215
955
|
this.options = options;
|
|
1216
|
-
this.
|
|
1217
|
-
this.
|
|
1218
|
-
this.
|
|
956
|
+
this._driver = options.driver;
|
|
957
|
+
this.hookRegistry = new HookRegistry(options.hooks);
|
|
958
|
+
this.metricsCollector = options.metrics ? new MetricsCollector(options.metrics) : null;
|
|
959
|
+
this.lifecycleManager = options.lifecycle ? new LifecycleManager(options.lifecycle, {
|
|
1219
960
|
open: (id, path, opts) => this.open(id, path, opts),
|
|
1220
961
|
close: (id) => this.close(id),
|
|
1221
962
|
count: () => this.dbs.size,
|
|
@@ -1223,31 +964,44 @@ var Sirannon = class {
|
|
|
1223
964
|
}) : null;
|
|
1224
965
|
}
|
|
1225
966
|
dbs = /* @__PURE__ */ new Map();
|
|
967
|
+
opening = /* @__PURE__ */ new Set();
|
|
1226
968
|
_shutdown = false;
|
|
969
|
+
_driver;
|
|
1227
970
|
hookRegistry;
|
|
1228
971
|
metricsCollector;
|
|
1229
972
|
lifecycleManager;
|
|
1230
|
-
|
|
973
|
+
get driver() {
|
|
974
|
+
return this._driver;
|
|
975
|
+
}
|
|
976
|
+
async open(id, path, options) {
|
|
1231
977
|
this.ensureRunning();
|
|
1232
|
-
if (this.dbs.has(id)) {
|
|
978
|
+
if (this.dbs.has(id) || this.opening.has(id)) {
|
|
1233
979
|
throw new DatabaseAlreadyExistsError(id);
|
|
1234
980
|
}
|
|
1235
|
-
|
|
1236
|
-
this.hookRegistry.invokeSync("beforeConnect", { databaseId: id, path });
|
|
1237
|
-
}
|
|
981
|
+
this.opening.add(id);
|
|
1238
982
|
let db;
|
|
1239
983
|
try {
|
|
1240
|
-
|
|
984
|
+
if (this.hookRegistry.has("beforeConnect")) {
|
|
985
|
+
this.hookRegistry.invokeSync("beforeConnect", { databaseId: id, path });
|
|
986
|
+
}
|
|
987
|
+
db = await Database.create(id, path, this._driver, options, {
|
|
1241
988
|
parentHooks: this.hookRegistry,
|
|
1242
989
|
metrics: this.metricsCollector ?? void 0
|
|
1243
990
|
});
|
|
1244
991
|
} catch (err) {
|
|
992
|
+
this.opening.delete(id);
|
|
1245
993
|
if (err instanceof SirannonError) throw err;
|
|
1246
994
|
throw new SirannonError(
|
|
1247
995
|
`Failed to open database '${id}' at '${path}': ${err instanceof Error ? err.message : String(err)}`,
|
|
1248
996
|
"DATABASE_OPEN_FAILED"
|
|
1249
997
|
);
|
|
1250
998
|
}
|
|
999
|
+
this.opening.delete(id);
|
|
1000
|
+
if (this._shutdown) {
|
|
1001
|
+
await db.close().catch(() => {
|
|
1002
|
+
});
|
|
1003
|
+
throw new SirannonError("Sirannon has been shut down", "SHUTDOWN");
|
|
1004
|
+
}
|
|
1251
1005
|
db.addCloseListener(() => {
|
|
1252
1006
|
this.dbs.delete(id);
|
|
1253
1007
|
this.lifecycleManager?.untrack(id);
|
|
@@ -1280,13 +1034,13 @@ var Sirannon = class {
|
|
|
1280
1034
|
});
|
|
1281
1035
|
return db;
|
|
1282
1036
|
}
|
|
1283
|
-
close(id) {
|
|
1037
|
+
async close(id) {
|
|
1284
1038
|
this.ensureRunning();
|
|
1285
1039
|
const db = this.dbs.get(id);
|
|
1286
1040
|
if (!db) {
|
|
1287
1041
|
throw new DatabaseNotFoundError(id);
|
|
1288
1042
|
}
|
|
1289
|
-
db.close();
|
|
1043
|
+
await db.close();
|
|
1290
1044
|
}
|
|
1291
1045
|
get(id) {
|
|
1292
1046
|
const db = this.dbs.get(id);
|
|
@@ -1295,6 +1049,12 @@ var Sirannon = class {
|
|
|
1295
1049
|
return db;
|
|
1296
1050
|
}
|
|
1297
1051
|
if (this._shutdown) return void 0;
|
|
1052
|
+
return void 0;
|
|
1053
|
+
}
|
|
1054
|
+
async resolve(id) {
|
|
1055
|
+
const db = this.get(id);
|
|
1056
|
+
if (db) return db;
|
|
1057
|
+
if (this._shutdown) return void 0;
|
|
1298
1058
|
return this.lifecycleManager?.resolve(id);
|
|
1299
1059
|
}
|
|
1300
1060
|
has(id) {
|
|
@@ -1303,7 +1063,7 @@ var Sirannon = class {
|
|
|
1303
1063
|
databases() {
|
|
1304
1064
|
return new Map(this.dbs);
|
|
1305
1065
|
}
|
|
1306
|
-
shutdown() {
|
|
1066
|
+
async shutdown() {
|
|
1307
1067
|
if (this._shutdown) return;
|
|
1308
1068
|
this._shutdown = true;
|
|
1309
1069
|
this.lifecycleManager?.dispose();
|
|
@@ -1311,7 +1071,7 @@ var Sirannon = class {
|
|
|
1311
1071
|
const snapshot = [...this.dbs.values()];
|
|
1312
1072
|
for (const db of snapshot) {
|
|
1313
1073
|
try {
|
|
1314
|
-
db.close();
|
|
1074
|
+
await db.close();
|
|
1315
1075
|
} catch (err) {
|
|
1316
1076
|
errors.push(err);
|
|
1317
1077
|
}
|
|
@@ -1343,4 +1103,4 @@ var Sirannon = class {
|
|
|
1343
1103
|
}
|
|
1344
1104
|
};
|
|
1345
1105
|
|
|
1346
|
-
export {
|
|
1106
|
+
export { ConnectionPool, Database, HookRegistry, LifecycleManager, MetricsCollector, MigrationRunner, Sirannon, Transaction, createTenantResolver, execute, executeBatch, query, queryOne, sanitizeTenantId, tenantPath };
|