@delali/sirannon-db 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +655 -80
- package/dist/backup-scheduler/index.d.ts +3 -0
- package/dist/backup-scheduler/index.mjs +2 -0
- package/dist/change-tracker-CFTQ9TSn.d.ts +89 -0
- package/dist/chunk-3MCMONVP.mjs +115 -0
- package/dist/chunk-74UN4DIE.mjs +14 -0
- package/dist/chunk-ER7ODTDA.mjs +23 -0
- package/dist/chunk-FB2U2Q3Y.mjs +21 -0
- package/dist/chunk-GS7T5YMI.mjs +51 -0
- package/dist/chunk-O7BHI3CF.mjs +90 -0
- package/dist/chunk-PXKAKK2V.mjs +124 -0
- package/dist/chunk-UTO3ZAFS.mjs +514 -0
- package/dist/chunk-UVMVN3OT.mjs +111 -0
- package/dist/client/index.d.ts +137 -44
- package/dist/client/index.mjs +726 -26
- package/dist/core/index.d.ts +32 -241
- package/dist/core/index.mjs +294 -568
- package/dist/database-BVY1GqE7.d.ts +95 -0
- package/dist/driver/better-sqlite3.d.ts +8 -0
- package/dist/driver/better-sqlite3.mjs +63 -0
- package/dist/driver/bun.mjs +61 -0
- package/dist/driver/expo.mjs +55 -0
- package/dist/driver/node.d.ts +8 -0
- package/dist/driver/node.mjs +60 -0
- package/dist/driver/wa-sqlite.d.ts +34 -0
- package/dist/driver/wa-sqlite.mjs +141 -0
- package/dist/errors-C00ed08Q.d.ts +101 -0
- package/dist/file-migrations/index.d.ts +16 -0
- package/dist/file-migrations/index.mjs +128 -0
- package/dist/index-CLdNrcPz.d.ts +16 -0
- package/dist/replication/coordinator/etcd.d.ts +44 -0
- package/dist/replication/coordinator/etcd.mjs +650 -0
- package/dist/replication/index.d.ts +491 -0
- package/dist/replication/index.mjs +3784 -0
- package/dist/server/index.d.ts +121 -54
- package/dist/server/index.mjs +347 -114
- package/dist/sirannon-Cd-lK6T0.d.ts +31 -0
- package/dist/transport/grpc.d.ts +316 -0
- package/dist/transport/grpc.mjs +3341 -0
- package/dist/transport/memory.d.ts +221 -0
- package/dist/transport/memory.mjs +337 -0
- package/dist/types-B2byqt0B.d.ts +273 -0
- package/dist/types-BEu1I_9_.d.ts +139 -0
- package/dist/types-BFSsG77t.d.ts +29 -0
- package/dist/types-BeozgNPr.d.ts +26 -0
- package/dist/{types-DArCObcu.d.ts → types-D-74JiXb.d.ts} +80 -1
- package/dist/vfs-INWQ5DTE.mjs +2 -0
- package/package.json +106 -11
- package/dist/chunk-VI4UP4RR.mjs +0 -417
- package/dist/protocol-BX1H-_Mz.d.ts +0 -104
- package/dist/sirannon-BJ8Yd1Uf.d.ts +0 -148
package/dist/core/index.mjs
CHANGED
|
@@ -1,214 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import
|
|
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
|
-
|
|
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) {
|
|
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(
|
|
182
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
const applied = _MigrationRunner.getAppliedVersions(
|
|
582
|
-
const pending =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
283
|
+
await txConn.exec(migration.up);
|
|
593
284
|
} else {
|
|
594
|
-
migration.up(new Transaction(
|
|
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
|
-
|
|
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:
|
|
301
|
+
skipped: validated.length - pending.length
|
|
610
302
|
};
|
|
611
303
|
}
|
|
612
|
-
static rollback(
|
|
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
|
-
|
|
621
|
-
const
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
await txConn.exec(migration.down);
|
|
666
353
|
} else {
|
|
667
|
-
migration.down?.(new Transaction(
|
|
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
|
-
|
|
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(
|
|
723
|
-
const
|
|
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
|
|
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
|
-
|
|
447
|
+
}
|
|
448
|
+
static async create(id, path, driver, options, internals) {
|
|
449
|
+
const pool = await ConnectionPool.create({
|
|
450
|
+
driver,
|
|
756
451
|
path,
|
|
757
|
-
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.
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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.
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
577
|
+
async migrate(migrations) {
|
|
862
578
|
this.ensureOpen();
|
|
863
579
|
const writer = this.pool.acquireWriter();
|
|
864
|
-
return MigrationRunner.run(writer,
|
|
580
|
+
return MigrationRunner.run(writer, migrations);
|
|
865
581
|
}
|
|
866
|
-
rollback(
|
|
582
|
+
async rollback(migrations, version) {
|
|
867
583
|
this.ensureOpen();
|
|
868
584
|
const writer = this.pool.acquireWriter();
|
|
869
|
-
return MigrationRunner.rollback(writer,
|
|
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
|
-
|
|
885
|
-
|
|
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 = {
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
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:
|
|
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.
|
|
1217
|
-
this.
|
|
1218
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
1236
|
-
this.hookRegistry.invokeSync("beforeConnect", { databaseId: id, path });
|
|
1237
|
-
}
|
|
947
|
+
this.opening.add(id);
|
|
1238
948
|
let db;
|
|
1239
949
|
try {
|
|
1240
|
-
|
|
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 {
|
|
1072
|
+
export { ConnectionPool, Database, HookRegistry, LifecycleManager, MetricsCollector, MigrationRunner, Sirannon, createTenantResolver, sanitizeTenantId, tenantPath };
|