@gl-life/gl-life-database 1.0.0
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/API.md +1486 -0
- package/LICENSE +190 -0
- package/README.md +480 -0
- package/dist/cache/index.d.ts +4 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/invalidation.d.ts +156 -0
- package/dist/cache/invalidation.d.ts.map +1 -0
- package/dist/cache/kv-cache.d.ts +79 -0
- package/dist/cache/kv-cache.d.ts.map +1 -0
- package/dist/cache/memory-cache.d.ts +68 -0
- package/dist/cache/memory-cache.d.ts.map +1 -0
- package/dist/cloudforge/d1-adapter.d.ts +67 -0
- package/dist/cloudforge/d1-adapter.d.ts.map +1 -0
- package/dist/cloudforge/do-storage.d.ts +51 -0
- package/dist/cloudforge/do-storage.d.ts.map +1 -0
- package/dist/cloudforge/index.d.ts +4 -0
- package/dist/cloudforge/index.d.ts.map +1 -0
- package/dist/cloudforge/r2-backup.d.ts +38 -0
- package/dist/cloudforge/r2-backup.d.ts.map +1 -0
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/index.d.ts.map +1 -0
- package/dist/connection/manager.d.ts +54 -0
- package/dist/connection/manager.d.ts.map +1 -0
- package/dist/index.cjs +4762 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4701 -0
- package/dist/index.js.map +1 -0
- package/dist/migration/index.d.ts +4 -0
- package/dist/migration/index.d.ts.map +1 -0
- package/dist/migration/loader.d.ts +88 -0
- package/dist/migration/loader.d.ts.map +1 -0
- package/dist/migration/runner.d.ts +91 -0
- package/dist/migration/runner.d.ts.map +1 -0
- package/dist/migration/seeder.d.ts +95 -0
- package/dist/migration/seeder.d.ts.map +1 -0
- package/dist/query/builder.d.ts +47 -0
- package/dist/query/builder.d.ts.map +1 -0
- package/dist/query/index.d.ts +3 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/raw.d.ts +92 -0
- package/dist/query/raw.d.ts.map +1 -0
- package/dist/tenant/context.d.ts +52 -0
- package/dist/tenant/context.d.ts.map +1 -0
- package/dist/tenant/index.d.ts +4 -0
- package/dist/tenant/index.d.ts.map +1 -0
- package/dist/tenant/query-wrapper.d.ts +96 -0
- package/dist/tenant/query-wrapper.d.ts.map +1 -0
- package/dist/tenant/schema-manager.d.ts +185 -0
- package/dist/tenant/schema-manager.d.ts.map +1 -0
- package/dist/transaction/index.d.ts +2 -0
- package/dist/transaction/index.d.ts.map +1 -0
- package/dist/transaction/transaction.d.ts +51 -0
- package/dist/transaction/transaction.d.ts.map +1 -0
- package/dist/types/cache.d.ts +214 -0
- package/dist/types/cache.d.ts.map +1 -0
- package/dist/types/cloudforge.d.ts +753 -0
- package/dist/types/cloudforge.d.ts.map +1 -0
- package/dist/types/connection.d.ts +91 -0
- package/dist/types/connection.d.ts.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/migration.d.ts +225 -0
- package/dist/types/migration.d.ts.map +1 -0
- package/dist/types/plugin.d.ts +432 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/query-builder.d.ts +217 -0
- package/dist/types/query-builder.d.ts.map +1 -0
- package/dist/types/seed.d.ts +187 -0
- package/dist/types/seed.d.ts.map +1 -0
- package/dist/types/tenant.d.ts +140 -0
- package/dist/types/tenant.d.ts.map +1 -0
- package/dist/types/transaction.d.ts +144 -0
- package/dist/types/transaction.d.ts.map +1 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4701 @@
|
|
|
1
|
+
import { Result, Option } from '@gl-life/gl-life-core';
|
|
2
|
+
export { Config, EventBus, Logger, Option, Result } from '@gl-life/gl-life-core';
|
|
3
|
+
import crypto, { randomBytes } from 'crypto';
|
|
4
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { gzipSync, gunzipSync } from 'zlib';
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
var InternalConnection = class {
|
|
11
|
+
id;
|
|
12
|
+
config;
|
|
13
|
+
connected = false;
|
|
14
|
+
lastPing = null;
|
|
15
|
+
constructor(id, config) {
|
|
16
|
+
this.id = id;
|
|
17
|
+
this.config = config;
|
|
18
|
+
}
|
|
19
|
+
get isConnected() {
|
|
20
|
+
return this.connected;
|
|
21
|
+
}
|
|
22
|
+
async connect() {
|
|
23
|
+
try {
|
|
24
|
+
this.connected = true;
|
|
25
|
+
this.lastPing = /* @__PURE__ */ new Date();
|
|
26
|
+
return Result.ok(void 0);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return Result.err({
|
|
29
|
+
type: "CONNECTION_FAILED",
|
|
30
|
+
message: `Failed to connect: ${error}`,
|
|
31
|
+
cause: error
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async disconnect() {
|
|
36
|
+
try {
|
|
37
|
+
this.connected = false;
|
|
38
|
+
this.lastPing = null;
|
|
39
|
+
return Result.ok(void 0);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return Result.err({
|
|
42
|
+
type: "CONNECTION_FAILED",
|
|
43
|
+
message: `Failed to disconnect: ${error}`,
|
|
44
|
+
cause: error
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async execute(sql, params) {
|
|
49
|
+
if (!this.connected) {
|
|
50
|
+
return Result.err({
|
|
51
|
+
type: "CONNECTION_CLOSED",
|
|
52
|
+
message: "Connection is not active"
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return Result.ok({});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return Result.err({
|
|
59
|
+
type: "UNKNOWN",
|
|
60
|
+
message: `Query execution failed: ${error}`,
|
|
61
|
+
cause: error
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async ping() {
|
|
66
|
+
if (!this.connected) {
|
|
67
|
+
return Result.ok(false);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
this.lastPing = /* @__PURE__ */ new Date();
|
|
71
|
+
return Result.ok(true);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return Result.err({
|
|
74
|
+
type: "UNKNOWN",
|
|
75
|
+
message: `Ping failed: ${error}`,
|
|
76
|
+
cause: error
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var DatabaseConnectionManager = class {
|
|
82
|
+
config;
|
|
83
|
+
logger;
|
|
84
|
+
pool = [];
|
|
85
|
+
available = [];
|
|
86
|
+
active = /* @__PURE__ */ new Set();
|
|
87
|
+
initialized = false;
|
|
88
|
+
destroyed = false;
|
|
89
|
+
connectionIdCounter = 0;
|
|
90
|
+
constructor(config, logger) {
|
|
91
|
+
if (!config.name || config.name.trim() === "") {
|
|
92
|
+
throw new Error("Database name is required");
|
|
93
|
+
}
|
|
94
|
+
if (config.maxConnections !== void 0 && config.maxConnections < 0) {
|
|
95
|
+
throw new Error("maxConnections must be >= 0");
|
|
96
|
+
}
|
|
97
|
+
this.config = {
|
|
98
|
+
maxConnections: 10,
|
|
99
|
+
timeout: 3e4,
|
|
100
|
+
autoReconnect: false,
|
|
101
|
+
maxRetries: 3,
|
|
102
|
+
retryDelay: 1e3,
|
|
103
|
+
...config
|
|
104
|
+
};
|
|
105
|
+
this.logger = logger;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get manager configuration
|
|
109
|
+
*/
|
|
110
|
+
getConfig() {
|
|
111
|
+
return { ...this.config };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get number of active connections
|
|
115
|
+
*/
|
|
116
|
+
getActiveCount() {
|
|
117
|
+
return this.active.size;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Initialize the connection manager
|
|
121
|
+
*/
|
|
122
|
+
async initialize() {
|
|
123
|
+
if (this.initialized) {
|
|
124
|
+
return Result.ok(void 0);
|
|
125
|
+
}
|
|
126
|
+
if (this.destroyed) {
|
|
127
|
+
return Result.err({
|
|
128
|
+
type: "CONNECTION_CLOSED",
|
|
129
|
+
message: "Manager has been destroyed"
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
this.logger.info("Initializing DatabaseConnectionManager", {
|
|
133
|
+
name: this.config.name,
|
|
134
|
+
maxConnections: this.config.maxConnections
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
this.initialized = true;
|
|
138
|
+
this.logger.info("DatabaseConnectionManager initialized successfully");
|
|
139
|
+
return Result.ok(void 0);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.initialized = false;
|
|
142
|
+
return Result.err({
|
|
143
|
+
type: "CONNECTION_FAILED",
|
|
144
|
+
message: `Initialization failed: ${error}`,
|
|
145
|
+
cause: error
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Acquire a connection from the pool
|
|
151
|
+
*/
|
|
152
|
+
async acquire() {
|
|
153
|
+
if (this.destroyed) {
|
|
154
|
+
this.logger.error("Cannot acquire connection: manager destroyed");
|
|
155
|
+
return Result.err({
|
|
156
|
+
type: "CONNECTION_CLOSED",
|
|
157
|
+
message: "Manager has been destroyed"
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (!this.initialized) {
|
|
161
|
+
return Result.err({
|
|
162
|
+
type: "CONNECTION_CLOSED",
|
|
163
|
+
message: "Manager not initialized"
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
this.logger.debug("Acquiring connection from pool", {
|
|
167
|
+
available: this.available.length,
|
|
168
|
+
active: this.active.size,
|
|
169
|
+
total: this.pool.length
|
|
170
|
+
});
|
|
171
|
+
if (this.available.length > 0) {
|
|
172
|
+
const conn2 = this.available.pop();
|
|
173
|
+
this.active.add(conn2);
|
|
174
|
+
const pingResult = await conn2.ping();
|
|
175
|
+
if (pingResult.isOk() && pingResult.unwrap()) {
|
|
176
|
+
this.logger.debug("Reusing existing connection", { id: conn2.id });
|
|
177
|
+
return Result.ok(conn2);
|
|
178
|
+
} else {
|
|
179
|
+
if (this.config.autoReconnect) {
|
|
180
|
+
const reconnectResult = await conn2.connect();
|
|
181
|
+
if (reconnectResult.isOk()) {
|
|
182
|
+
this.logger.debug("Reconnected unhealthy connection", { id: conn2.id });
|
|
183
|
+
return Result.ok(conn2);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.active.delete(conn2);
|
|
187
|
+
this.pool = this.pool.filter((c) => c.id !== conn2.id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const maxConnections = this.config.maxConnections;
|
|
191
|
+
if (this.pool.length >= maxConnections) {
|
|
192
|
+
this.logger.error("Connection pool limit reached", {
|
|
193
|
+
max: maxConnections,
|
|
194
|
+
current: this.pool.length
|
|
195
|
+
});
|
|
196
|
+
return Result.err({
|
|
197
|
+
type: "CONNECTION_FAILED",
|
|
198
|
+
message: `Connection pool limit reached (${maxConnections})`
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const connId = `conn-${++this.connectionIdCounter}`;
|
|
202
|
+
const conn = new InternalConnection(connId, this.config);
|
|
203
|
+
const connectResult = await conn.connect();
|
|
204
|
+
if (connectResult.isErr()) {
|
|
205
|
+
return connectResult;
|
|
206
|
+
}
|
|
207
|
+
this.pool.push(conn);
|
|
208
|
+
this.active.add(conn);
|
|
209
|
+
this.logger.debug("Created new connection", {
|
|
210
|
+
id: conn.id,
|
|
211
|
+
poolSize: this.pool.length
|
|
212
|
+
});
|
|
213
|
+
return Result.ok(conn);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Release a connection back to the pool
|
|
217
|
+
*/
|
|
218
|
+
async release(connection) {
|
|
219
|
+
if (!connection || !connection.id) {
|
|
220
|
+
return Result.err({
|
|
221
|
+
type: "DATABASE_NOT_FOUND",
|
|
222
|
+
message: "Invalid connection provided"
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const conn = this.pool.find((c) => c.id === connection.id);
|
|
226
|
+
if (!conn) {
|
|
227
|
+
return Result.err({
|
|
228
|
+
type: "DATABASE_NOT_FOUND",
|
|
229
|
+
message: "Connection not found in pool"
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
this.active.delete(conn);
|
|
233
|
+
this.available.push(conn);
|
|
234
|
+
this.logger.debug("Released connection back to pool", {
|
|
235
|
+
id: connection.id,
|
|
236
|
+
available: this.available.length
|
|
237
|
+
});
|
|
238
|
+
return Result.ok(void 0);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Perform health check on all connections
|
|
242
|
+
*/
|
|
243
|
+
async healthCheck() {
|
|
244
|
+
if (this.destroyed) {
|
|
245
|
+
return Result.ok(false);
|
|
246
|
+
}
|
|
247
|
+
if (!this.initialized) {
|
|
248
|
+
return Result.ok(false);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
if (this.pool.length === 0) {
|
|
252
|
+
const testConn = await this.acquire();
|
|
253
|
+
if (testConn.isErr()) {
|
|
254
|
+
return Result.ok(false);
|
|
255
|
+
}
|
|
256
|
+
await this.release(testConn.unwrap());
|
|
257
|
+
return Result.ok(true);
|
|
258
|
+
}
|
|
259
|
+
const checks = this.pool.map((conn) => conn.ping());
|
|
260
|
+
const results = await Promise.all(checks);
|
|
261
|
+
const allHealthy = results.every((r) => r.isOk() && r.unwrap() === true);
|
|
262
|
+
if (!allHealthy && this.config.autoReconnect) {
|
|
263
|
+
for (let i = 0; i < this.pool.length; i++) {
|
|
264
|
+
const result = results[i];
|
|
265
|
+
if (result.isErr() || !result.unwrap()) {
|
|
266
|
+
await this.pool[i].connect();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return Result.ok(allHealthy);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return Result.err({
|
|
273
|
+
type: "UNKNOWN",
|
|
274
|
+
message: `Health check failed: ${error}`,
|
|
275
|
+
cause: error
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Destroy the connection manager and close all connections
|
|
281
|
+
*/
|
|
282
|
+
async destroy() {
|
|
283
|
+
if (this.destroyed) {
|
|
284
|
+
return Result.ok(void 0);
|
|
285
|
+
}
|
|
286
|
+
this.logger.info("Destroying DatabaseConnectionManager", {
|
|
287
|
+
totalConnections: this.pool.length,
|
|
288
|
+
activeConnections: this.active.size
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
const closePromises = this.pool.map((conn) => conn.disconnect());
|
|
292
|
+
await Promise.all(closePromises);
|
|
293
|
+
this.pool = [];
|
|
294
|
+
this.available = [];
|
|
295
|
+
this.active.clear();
|
|
296
|
+
this.initialized = false;
|
|
297
|
+
this.destroyed = true;
|
|
298
|
+
this.logger.info("DatabaseConnectionManager destroyed successfully");
|
|
299
|
+
return Result.ok(void 0);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return Result.err({
|
|
302
|
+
type: "UNKNOWN",
|
|
303
|
+
message: `Destroy failed: ${error}`,
|
|
304
|
+
cause: error
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
var TypeSafeQueryBuilder = class _TypeSafeQueryBuilder {
|
|
310
|
+
tableName;
|
|
311
|
+
metaDataService;
|
|
312
|
+
logger;
|
|
313
|
+
selectedColumns = [];
|
|
314
|
+
whereConditions = [];
|
|
315
|
+
joinClauses = [];
|
|
316
|
+
orderByClauses = [];
|
|
317
|
+
groupByFields = [];
|
|
318
|
+
havingCondition = null;
|
|
319
|
+
limitValue = null;
|
|
320
|
+
offsetValue = null;
|
|
321
|
+
constructor(tableName, metaDataService, logger) {
|
|
322
|
+
if (!tableName || tableName.trim() === "") {
|
|
323
|
+
throw new Error("Table name is required");
|
|
324
|
+
}
|
|
325
|
+
this.tableName = tableName;
|
|
326
|
+
this.metaDataService = metaDataService;
|
|
327
|
+
this.logger = logger;
|
|
328
|
+
}
|
|
329
|
+
table(table) {
|
|
330
|
+
this.tableName = table;
|
|
331
|
+
return this;
|
|
332
|
+
}
|
|
333
|
+
select(...columns) {
|
|
334
|
+
this.selectedColumns.push(...columns);
|
|
335
|
+
return this;
|
|
336
|
+
}
|
|
337
|
+
where(field, operator, value) {
|
|
338
|
+
this.whereConditions.push({ field, operator, value, isOr: false });
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
orWhere(field, operator, value) {
|
|
342
|
+
this.whereConditions.push({ field, operator, value, isOr: true });
|
|
343
|
+
return this;
|
|
344
|
+
}
|
|
345
|
+
whereIn(field, values) {
|
|
346
|
+
this.whereConditions.push({ field, operator: "IN", value: values, isOr: false });
|
|
347
|
+
return this;
|
|
348
|
+
}
|
|
349
|
+
join(table, leftField, rightField, type = "INNER") {
|
|
350
|
+
this.joinClauses.push({ type, table, left: leftField, right: rightField });
|
|
351
|
+
return this;
|
|
352
|
+
}
|
|
353
|
+
orderBy(field, direction = "ASC") {
|
|
354
|
+
this.orderByClauses.push({ field, direction });
|
|
355
|
+
return this;
|
|
356
|
+
}
|
|
357
|
+
groupBy(...fields) {
|
|
358
|
+
this.groupByFields.push(...fields);
|
|
359
|
+
return this;
|
|
360
|
+
}
|
|
361
|
+
having(condition) {
|
|
362
|
+
this.havingCondition = condition;
|
|
363
|
+
return this;
|
|
364
|
+
}
|
|
365
|
+
limit(limit) {
|
|
366
|
+
this.limitValue = limit;
|
|
367
|
+
return this;
|
|
368
|
+
}
|
|
369
|
+
offset(offset) {
|
|
370
|
+
this.offsetValue = offset;
|
|
371
|
+
return this;
|
|
372
|
+
}
|
|
373
|
+
async insert(data) {
|
|
374
|
+
try {
|
|
375
|
+
this.logger.debug("Insert operation", { table: this.tableName, data });
|
|
376
|
+
return Result.ok(data);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return Result.err({
|
|
379
|
+
type: "EXECUTION_FAILED",
|
|
380
|
+
message: `Insert failed: ${error}`,
|
|
381
|
+
cause: error
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async insertMany(data) {
|
|
386
|
+
try {
|
|
387
|
+
this.logger.debug("Insert many operation", { table: this.tableName, count: data.length });
|
|
388
|
+
return Result.ok(data);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return Result.err({
|
|
391
|
+
type: "EXECUTION_FAILED",
|
|
392
|
+
message: `Insert many failed: ${error}`,
|
|
393
|
+
cause: error
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async update(data) {
|
|
398
|
+
try {
|
|
399
|
+
this.logger.debug("Update operation", { table: this.tableName, data });
|
|
400
|
+
return Result.ok(1);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return Result.err({
|
|
403
|
+
type: "EXECUTION_FAILED",
|
|
404
|
+
message: `Update failed: ${error}`,
|
|
405
|
+
cause: error
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async delete() {
|
|
410
|
+
try {
|
|
411
|
+
this.logger.debug("Delete operation", { table: this.tableName });
|
|
412
|
+
return Result.ok(1);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return Result.err({
|
|
415
|
+
type: "EXECUTION_FAILED",
|
|
416
|
+
message: `Delete failed: ${error}`,
|
|
417
|
+
cause: error
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async first() {
|
|
422
|
+
try {
|
|
423
|
+
this.logger.debug("First operation", { table: this.tableName });
|
|
424
|
+
return Result.ok(null);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
return Result.err({
|
|
427
|
+
type: "EXECUTION_FAILED",
|
|
428
|
+
message: `First failed: ${error}`,
|
|
429
|
+
cause: error
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async get() {
|
|
434
|
+
try {
|
|
435
|
+
this.logger.debug("Get operation", { table: this.tableName });
|
|
436
|
+
return Result.ok([]);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
return Result.err({
|
|
439
|
+
type: "EXECUTION_FAILED",
|
|
440
|
+
message: `Get failed: ${error}`,
|
|
441
|
+
cause: error
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async count(field = "*") {
|
|
446
|
+
try {
|
|
447
|
+
this.logger.debug("Count operation", { table: this.tableName, field });
|
|
448
|
+
return Result.ok(0);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return Result.err({
|
|
451
|
+
type: "EXECUTION_FAILED",
|
|
452
|
+
message: `Count failed: ${error}`,
|
|
453
|
+
cause: error
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
toSQL() {
|
|
458
|
+
const columns = this.selectedColumns.length > 0 ? this.selectedColumns.join(", ") : "*";
|
|
459
|
+
let sql = `SELECT ${columns} FROM ${this.tableName}`;
|
|
460
|
+
if (this.joinClauses.length > 0) {
|
|
461
|
+
sql += " " + this.joinClauses.map((j) => `${j.type} JOIN ${j.table} ON ${j.left} = ${j.right}`).join(" ");
|
|
462
|
+
}
|
|
463
|
+
if (this.whereConditions.length > 0) {
|
|
464
|
+
const whereParts = this.whereConditions.map((w, i) => {
|
|
465
|
+
const prefix = i === 0 ? "WHERE" : w.isOr ? "OR" : "AND";
|
|
466
|
+
return `${prefix} ${w.field} ${w.operator} ?`;
|
|
467
|
+
});
|
|
468
|
+
sql += " " + whereParts.join(" ");
|
|
469
|
+
}
|
|
470
|
+
if (this.groupByFields.length > 0) {
|
|
471
|
+
sql += ` GROUP BY ${this.groupByFields.join(", ")}`;
|
|
472
|
+
}
|
|
473
|
+
if (this.havingCondition) {
|
|
474
|
+
sql += ` HAVING ${this.havingCondition}`;
|
|
475
|
+
}
|
|
476
|
+
if (this.orderByClauses.length > 0) {
|
|
477
|
+
sql += " ORDER BY " + this.orderByClauses.map((o) => `${o.field} ${o.direction}`).join(", ");
|
|
478
|
+
}
|
|
479
|
+
if (this.limitValue !== null) {
|
|
480
|
+
sql += ` LIMIT ${this.limitValue}`;
|
|
481
|
+
}
|
|
482
|
+
if (this.offsetValue !== null) {
|
|
483
|
+
sql += ` OFFSET ${this.offsetValue}`;
|
|
484
|
+
}
|
|
485
|
+
return sql;
|
|
486
|
+
}
|
|
487
|
+
getBindings() {
|
|
488
|
+
return this.whereConditions.map((w) => w.value).filter((v) => v !== void 0);
|
|
489
|
+
}
|
|
490
|
+
clone() {
|
|
491
|
+
const cloned = new _TypeSafeQueryBuilder(this.tableName, this.metaDataService, this.logger);
|
|
492
|
+
cloned.selectedColumns = [...this.selectedColumns];
|
|
493
|
+
cloned.whereConditions = [...this.whereConditions];
|
|
494
|
+
cloned.joinClauses = [...this.joinClauses];
|
|
495
|
+
cloned.orderByClauses = [...this.orderByClauses];
|
|
496
|
+
cloned.groupByFields = [...this.groupByFields];
|
|
497
|
+
cloned.havingCondition = this.havingCondition;
|
|
498
|
+
cloned.limitValue = this.limitValue;
|
|
499
|
+
cloned.offsetValue = this.offsetValue;
|
|
500
|
+
return cloned;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
var RawSQLExecutor = class {
|
|
504
|
+
connection;
|
|
505
|
+
logger;
|
|
506
|
+
constructor(connection, logger) {
|
|
507
|
+
this.connection = connection;
|
|
508
|
+
this.logger = logger;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Execute a raw SQL query with parameters
|
|
512
|
+
*
|
|
513
|
+
* Uses parameterized queries to prevent SQL injection.
|
|
514
|
+
* All parameter values are properly escaped by the database driver.
|
|
515
|
+
*
|
|
516
|
+
* @param sql - SQL query string with ? placeholders
|
|
517
|
+
* @param params - Parameter values to bind to placeholders
|
|
518
|
+
* @param options - Execution options
|
|
519
|
+
* @returns Result containing array of rows or error
|
|
520
|
+
*/
|
|
521
|
+
async execute(sql, params = [], options) {
|
|
522
|
+
const validationResult = this.validateSQL(sql, params);
|
|
523
|
+
if (validationResult.isErr()) {
|
|
524
|
+
return validationResult;
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
this.logger.debug("Executing raw SQL", {
|
|
528
|
+
sql: sql.substring(0, 100),
|
|
529
|
+
paramCount: params.length
|
|
530
|
+
});
|
|
531
|
+
const result = await this.connection.execute(sql, params);
|
|
532
|
+
if (result.isErr()) {
|
|
533
|
+
return Result.err({
|
|
534
|
+
type: "EXECUTION_FAILED",
|
|
535
|
+
message: "Query execution failed",
|
|
536
|
+
cause: result
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const data = result.unwrap();
|
|
540
|
+
let rows;
|
|
541
|
+
if (data && typeof data === "object" && "rows" in data) {
|
|
542
|
+
rows = data.rows;
|
|
543
|
+
} else if (Array.isArray(data)) {
|
|
544
|
+
rows = data;
|
|
545
|
+
} else {
|
|
546
|
+
rows = [];
|
|
547
|
+
}
|
|
548
|
+
this.logger.debug("Query executed successfully", {
|
|
549
|
+
rowCount: rows.length
|
|
550
|
+
});
|
|
551
|
+
return Result.ok(rows);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
this.logger.error("Raw SQL execution failed", {
|
|
554
|
+
error,
|
|
555
|
+
sql: sql.substring(0, 100)
|
|
556
|
+
});
|
|
557
|
+
return Result.err({
|
|
558
|
+
type: "EXECUTION_FAILED",
|
|
559
|
+
message: `Query execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
560
|
+
cause: error
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Execute a query and return the first row or null
|
|
566
|
+
*
|
|
567
|
+
* @param sql - SQL query string
|
|
568
|
+
* @param params - Parameter values
|
|
569
|
+
* @param options - Execution options
|
|
570
|
+
* @returns Result containing single row or null
|
|
571
|
+
*/
|
|
572
|
+
async executeOne(sql, params = [], options) {
|
|
573
|
+
const result = await this.execute(sql, params, options);
|
|
574
|
+
if (result.isErr()) {
|
|
575
|
+
return result;
|
|
576
|
+
}
|
|
577
|
+
const rows = result.unwrap();
|
|
578
|
+
return Result.ok(rows.length > 0 ? rows[0] : null);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Execute multiple queries in batch
|
|
582
|
+
*
|
|
583
|
+
* All queries execute sequentially. If any query fails, execution stops
|
|
584
|
+
* and an error is returned.
|
|
585
|
+
*
|
|
586
|
+
* @param queries - Array of SQL queries with parameters
|
|
587
|
+
* @returns Result containing array of results or error
|
|
588
|
+
*/
|
|
589
|
+
async executeBatch(queries) {
|
|
590
|
+
const results = [];
|
|
591
|
+
try {
|
|
592
|
+
this.logger.debug("Executing batch queries", { count: queries.length });
|
|
593
|
+
for (const query of queries) {
|
|
594
|
+
const result = await this.execute(query.sql, query.params || []);
|
|
595
|
+
if (result.isErr()) {
|
|
596
|
+
return Result.err({
|
|
597
|
+
type: "EXECUTION_FAILED",
|
|
598
|
+
message: "Batch execution failed",
|
|
599
|
+
cause: result
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
results.push(result.unwrap());
|
|
603
|
+
}
|
|
604
|
+
this.logger.debug("Batch execution completed", {
|
|
605
|
+
queryCount: queries.length,
|
|
606
|
+
totalRows: results.reduce((sum, rows) => sum + rows.length, 0)
|
|
607
|
+
});
|
|
608
|
+
return Result.ok(results);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
this.logger.error("Batch execution failed", { error });
|
|
611
|
+
return Result.err({
|
|
612
|
+
type: "EXECUTION_FAILED",
|
|
613
|
+
message: `Batch execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
614
|
+
cause: error
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Validate SQL query and parameters
|
|
620
|
+
*
|
|
621
|
+
* Checks for:
|
|
622
|
+
* - Empty/whitespace-only queries
|
|
623
|
+
* - Parameter count matching placeholder count
|
|
624
|
+
*
|
|
625
|
+
* @param sql - SQL query string
|
|
626
|
+
* @param params - Parameter values
|
|
627
|
+
* @returns Result indicating validation success or error
|
|
628
|
+
*/
|
|
629
|
+
validateSQL(sql, params) {
|
|
630
|
+
if (!sql || sql.trim().length === 0) {
|
|
631
|
+
return Result.err({
|
|
632
|
+
type: "INVALID_SQL",
|
|
633
|
+
message: "SQL query cannot be empty"
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const placeholderCount = (sql.match(/\?/g) || []).length;
|
|
637
|
+
if (placeholderCount !== params.length) {
|
|
638
|
+
return Result.err({
|
|
639
|
+
type: "PARAMETER_MISMATCH",
|
|
640
|
+
message: `Parameter count mismatch: expected ${placeholderCount} but got ${params.length}`
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return Result.ok(void 0);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
var DatabaseTransaction = class {
|
|
647
|
+
_id;
|
|
648
|
+
db;
|
|
649
|
+
_logger;
|
|
650
|
+
_config;
|
|
651
|
+
_state = "PENDING";
|
|
652
|
+
startTime = null;
|
|
653
|
+
timeoutHandle = null;
|
|
654
|
+
constructor(db, logger, config = {}) {
|
|
655
|
+
this._id = this.generateTransactionId();
|
|
656
|
+
this.db = db;
|
|
657
|
+
this._logger = logger;
|
|
658
|
+
this._config = {
|
|
659
|
+
isolationLevel: config.isolationLevel || "READ COMMITTED",
|
|
660
|
+
timeout: config.timeout || 3e4,
|
|
661
|
+
autoRollback: config.autoRollback !== void 0 ? config.autoRollback : true
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
generateTransactionId() {
|
|
665
|
+
return `tx_${randomBytes(16).toString("hex")}`;
|
|
666
|
+
}
|
|
667
|
+
get id() {
|
|
668
|
+
return this._id;
|
|
669
|
+
}
|
|
670
|
+
get state() {
|
|
671
|
+
return this._state;
|
|
672
|
+
}
|
|
673
|
+
get config() {
|
|
674
|
+
return this._config;
|
|
675
|
+
}
|
|
676
|
+
get isActive() {
|
|
677
|
+
return this._state === "ACTIVE";
|
|
678
|
+
}
|
|
679
|
+
async begin() {
|
|
680
|
+
if (this._state !== "PENDING") {
|
|
681
|
+
return Result.err({
|
|
682
|
+
type: "ALREADY_STARTED",
|
|
683
|
+
message: `Transaction ${this._id} is already ${this._state}`
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
this._logger.debug("Beginning transaction", {
|
|
688
|
+
id: this._id,
|
|
689
|
+
isolationLevel: this._config.isolationLevel
|
|
690
|
+
});
|
|
691
|
+
await this.db.execute("BEGIN", []);
|
|
692
|
+
this._state = "ACTIVE";
|
|
693
|
+
this.startTime = Date.now();
|
|
694
|
+
if (this._config.timeout && this._config.timeout > 0) {
|
|
695
|
+
this.timeoutHandle = setTimeout(() => {
|
|
696
|
+
this.handleTimeout();
|
|
697
|
+
}, this._config.timeout);
|
|
698
|
+
}
|
|
699
|
+
return Result.ok(void 0);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
this._logger.error("Failed to begin transaction", { error, id: this._id });
|
|
702
|
+
return Result.err({
|
|
703
|
+
type: "BEGIN_FAILED",
|
|
704
|
+
message: `Failed to begin transaction: ${error instanceof Error ? error.message : String(error)}`,
|
|
705
|
+
cause: error
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async commit() {
|
|
710
|
+
if (this._state === "PENDING") {
|
|
711
|
+
return Result.err({
|
|
712
|
+
type: "NOT_STARTED",
|
|
713
|
+
message: `Transaction ${this._id} has not been started`
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (this._state === "COMMITTED" || this._state === "ROLLED_BACK") {
|
|
717
|
+
return Result.err({
|
|
718
|
+
type: "ALREADY_COMPLETED",
|
|
719
|
+
message: `Transaction ${this._id} is already ${this._state}`
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
this._logger.debug("Committing transaction", { id: this._id });
|
|
724
|
+
await this.db.execute("COMMIT", []);
|
|
725
|
+
this._state = "COMMITTED";
|
|
726
|
+
this.clearTimeout();
|
|
727
|
+
const duration = this.startTime ? Date.now() - this.startTime : 0;
|
|
728
|
+
this._logger.info("Transaction committed", { id: this._id, duration });
|
|
729
|
+
return Result.ok(void 0);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
this._logger.error("Failed to commit transaction", { error, id: this._id });
|
|
732
|
+
return Result.err({
|
|
733
|
+
type: "COMMIT_FAILED",
|
|
734
|
+
message: `Failed to commit transaction: ${error instanceof Error ? error.message : String(error)}`,
|
|
735
|
+
cause: error
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async rollback() {
|
|
740
|
+
if (this._state === "PENDING") {
|
|
741
|
+
return Result.err({
|
|
742
|
+
type: "NOT_STARTED",
|
|
743
|
+
message: `Transaction ${this._id} has not been started`
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
if (this._state === "COMMITTED" || this._state === "ROLLED_BACK") {
|
|
747
|
+
return Result.err({
|
|
748
|
+
type: "ALREADY_COMPLETED",
|
|
749
|
+
message: `Transaction ${this._id} is already ${this._state}`
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
this._logger.debug("Rolling back transaction", { id: this._id });
|
|
754
|
+
await this.db.execute("ROLLBACK", []);
|
|
755
|
+
this._state = "ROLLED_BACK";
|
|
756
|
+
this.clearTimeout();
|
|
757
|
+
const duration = this.startTime ? Date.now() - this.startTime : 0;
|
|
758
|
+
this._logger.info("Transaction rolled back", { id: this._id, duration });
|
|
759
|
+
return Result.ok(void 0);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
this._logger.error("Failed to rollback transaction", { error, id: this._id });
|
|
762
|
+
return Result.err({
|
|
763
|
+
type: "ROLLBACK_FAILED",
|
|
764
|
+
message: `Failed to rollback transaction: ${error instanceof Error ? error.message : String(error)}`,
|
|
765
|
+
cause: error
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async savepoint(name) {
|
|
770
|
+
if (!this.isActive) {
|
|
771
|
+
return Result.err({
|
|
772
|
+
type: "NOT_STARTED",
|
|
773
|
+
message: `Cannot create savepoint: transaction ${this._id} is not active`
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
this._logger.debug("Creating savepoint", { id: this._id, savepoint: name });
|
|
778
|
+
await this.db.execute(`SAVEPOINT ${name}`, []);
|
|
779
|
+
return Result.ok(name);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
this._logger.error("Failed to create savepoint", { error, id: this._id, savepoint: name });
|
|
782
|
+
return Result.err({
|
|
783
|
+
type: "UNKNOWN",
|
|
784
|
+
message: `Failed to create savepoint: ${error instanceof Error ? error.message : String(error)}`,
|
|
785
|
+
cause: error
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async rollbackToSavepoint(name) {
|
|
790
|
+
if (!this.isActive) {
|
|
791
|
+
return Result.err({
|
|
792
|
+
type: "NOT_STARTED",
|
|
793
|
+
message: `Cannot rollback to savepoint: transaction ${this._id} is not active`
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
this._logger.debug("Rolling back to savepoint", { id: this._id, savepoint: name });
|
|
798
|
+
await this.db.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
|
|
799
|
+
return Result.ok(void 0);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
this._logger.error("Failed to rollback to savepoint", { error, id: this._id, savepoint: name });
|
|
802
|
+
return Result.err({
|
|
803
|
+
type: "UNKNOWN",
|
|
804
|
+
message: `Failed to rollback to savepoint: ${error instanceof Error ? error.message : String(error)}`,
|
|
805
|
+
cause: error
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async releaseSavepoint(name) {
|
|
810
|
+
if (!this.isActive) {
|
|
811
|
+
return Result.err({
|
|
812
|
+
type: "NOT_STARTED",
|
|
813
|
+
message: `Cannot release savepoint: transaction ${this._id} is not active`
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
this._logger.debug("Releasing savepoint", { id: this._id, savepoint: name });
|
|
818
|
+
await this.db.execute(`RELEASE SAVEPOINT ${name}`, []);
|
|
819
|
+
return Result.ok(void 0);
|
|
820
|
+
} catch (error) {
|
|
821
|
+
this._logger.error("Failed to release savepoint", { error, id: this._id, savepoint: name });
|
|
822
|
+
return Result.err({
|
|
823
|
+
type: "UNKNOWN",
|
|
824
|
+
message: `Failed to release savepoint: ${error instanceof Error ? error.message : String(error)}`,
|
|
825
|
+
cause: error
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async execute(sql, params = []) {
|
|
830
|
+
if (!this.isActive) {
|
|
831
|
+
return Result.err({
|
|
832
|
+
type: "NOT_STARTED",
|
|
833
|
+
message: `Cannot execute query: transaction ${this._id} is not active`
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
this._logger.debug("Executing query in transaction", {
|
|
838
|
+
id: this._id,
|
|
839
|
+
sql: sql.substring(0, 100)
|
|
840
|
+
});
|
|
841
|
+
const result = await this.db.execute(sql, params);
|
|
842
|
+
return Result.ok(result);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
this._logger.error("Query execution failed in transaction", {
|
|
845
|
+
error,
|
|
846
|
+
id: this._id,
|
|
847
|
+
sql: sql.substring(0, 100)
|
|
848
|
+
});
|
|
849
|
+
return Result.err({
|
|
850
|
+
type: "UNKNOWN",
|
|
851
|
+
message: `Query execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
852
|
+
cause: error
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
queryBuilder(table) {
|
|
857
|
+
const mockMetaDataService = {
|
|
858
|
+
getAllMappings: async () => [],
|
|
859
|
+
getMapping: async () => null
|
|
860
|
+
};
|
|
861
|
+
return new TypeSafeQueryBuilder(table, mockMetaDataService, this._logger);
|
|
862
|
+
}
|
|
863
|
+
async run(callback) {
|
|
864
|
+
if (this._state === "PENDING") {
|
|
865
|
+
const beginResult = await this.begin();
|
|
866
|
+
if (beginResult.isErr()) {
|
|
867
|
+
return beginResult;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
const result = await callback(this);
|
|
872
|
+
if (result.isErr()) {
|
|
873
|
+
if (this._config.autoRollback) {
|
|
874
|
+
await this.rollback();
|
|
875
|
+
}
|
|
876
|
+
return Result.err({
|
|
877
|
+
type: "UNKNOWN",
|
|
878
|
+
message: "Transaction callback returned error",
|
|
879
|
+
cause: result
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
const commitResult = await this.commit();
|
|
883
|
+
if (commitResult.isErr()) {
|
|
884
|
+
return commitResult;
|
|
885
|
+
}
|
|
886
|
+
return Result.ok(result.unwrap());
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (this._config.autoRollback) {
|
|
889
|
+
await this.rollback();
|
|
890
|
+
}
|
|
891
|
+
return Result.err({
|
|
892
|
+
type: "UNKNOWN",
|
|
893
|
+
message: `Transaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
894
|
+
cause: error
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
handleTimeout() {
|
|
899
|
+
if (this.isActive) {
|
|
900
|
+
this._logger.warn("Transaction timeout", {
|
|
901
|
+
id: this._id,
|
|
902
|
+
timeout: this._config.timeout
|
|
903
|
+
});
|
|
904
|
+
this.rollback().then((result) => {
|
|
905
|
+
if (result.isErr()) {
|
|
906
|
+
this._logger.error("Failed to rollback timed out transaction", {
|
|
907
|
+
id: this._id,
|
|
908
|
+
error: "Transaction rollback failed"
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
clearTimeout() {
|
|
915
|
+
if (this.timeoutHandle) {
|
|
916
|
+
clearTimeout(this.timeoutHandle);
|
|
917
|
+
this.timeoutHandle = null;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
var TenantContextManager = class _TenantContextManager {
|
|
922
|
+
storage;
|
|
923
|
+
_config;
|
|
924
|
+
logger;
|
|
925
|
+
currentTenant = null;
|
|
926
|
+
constructor(config, logger) {
|
|
927
|
+
this._config = config;
|
|
928
|
+
this.logger = logger;
|
|
929
|
+
this.storage = new AsyncLocalStorage();
|
|
930
|
+
}
|
|
931
|
+
get tenant() {
|
|
932
|
+
return this.getTenant();
|
|
933
|
+
}
|
|
934
|
+
get config() {
|
|
935
|
+
return this._config;
|
|
936
|
+
}
|
|
937
|
+
get hasTenant() {
|
|
938
|
+
return this.currentTenant !== null;
|
|
939
|
+
}
|
|
940
|
+
async setTenant(tenantId) {
|
|
941
|
+
this.validateTenantId(tenantId);
|
|
942
|
+
this.logger.debug("Setting tenant", { tenantId });
|
|
943
|
+
const metadata = {
|
|
944
|
+
id: tenantId,
|
|
945
|
+
name: tenantId,
|
|
946
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
947
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
948
|
+
isActive: true
|
|
949
|
+
};
|
|
950
|
+
this.currentTenant = metadata;
|
|
951
|
+
}
|
|
952
|
+
async setTenantWithMetadata(metadata) {
|
|
953
|
+
this.validateTenantId(metadata.id);
|
|
954
|
+
this.logger.debug("Setting tenant with metadata", {
|
|
955
|
+
tenantId: metadata.id,
|
|
956
|
+
name: metadata.name
|
|
957
|
+
});
|
|
958
|
+
this.currentTenant = metadata;
|
|
959
|
+
}
|
|
960
|
+
async clearTenant() {
|
|
961
|
+
this.logger.debug("Clearing tenant context");
|
|
962
|
+
this.currentTenant = null;
|
|
963
|
+
}
|
|
964
|
+
getTenantId() {
|
|
965
|
+
if (this.currentTenant) {
|
|
966
|
+
return Option.some(this.currentTenant.id);
|
|
967
|
+
}
|
|
968
|
+
return Option.none();
|
|
969
|
+
}
|
|
970
|
+
getTenant() {
|
|
971
|
+
if (this.currentTenant) {
|
|
972
|
+
return Option.some(this.currentTenant);
|
|
973
|
+
}
|
|
974
|
+
return Option.none();
|
|
975
|
+
}
|
|
976
|
+
shouldIsolateTable(table) {
|
|
977
|
+
if (!this._config.enforceIsolation) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
if (this._config.excludedTables?.includes(table)) {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
applyTenantIsolation(sql, params = []) {
|
|
986
|
+
const tenantId = this.getTenantId();
|
|
987
|
+
if (tenantId.isNone()) {
|
|
988
|
+
throw new Error("No tenant context set. Cannot apply tenant isolation.");
|
|
989
|
+
}
|
|
990
|
+
const tenant = tenantId.unwrap();
|
|
991
|
+
const tableMatch = sql.match(/(?:FROM|INTO|UPDATE)\s+([a-zA-Z_][a-zA-Z0-9_]*)/i);
|
|
992
|
+
if (tableMatch) {
|
|
993
|
+
const tableName = tableMatch[1];
|
|
994
|
+
if (!this.shouldIsolateTable(tableName)) {
|
|
995
|
+
return { sql, params };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
switch (this._config.isolationStrategy) {
|
|
999
|
+
case "ROW":
|
|
1000
|
+
return this.applyRowLevelIsolation(sql, params, tenant);
|
|
1001
|
+
case "SCHEMA":
|
|
1002
|
+
return this.applySchemaIsolation(sql, params, tenant);
|
|
1003
|
+
case "DATABASE":
|
|
1004
|
+
return { sql, params };
|
|
1005
|
+
case "HYBRID":
|
|
1006
|
+
const schemaResult = this.applySchemaIsolation(sql, params, tenant);
|
|
1007
|
+
return this.applyRowLevelIsolation(schemaResult.sql, schemaResult.params, tenant);
|
|
1008
|
+
default:
|
|
1009
|
+
return { sql, params };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
getTenantDatabase() {
|
|
1013
|
+
const tenantId = this.getTenantId();
|
|
1014
|
+
if (tenantId.isNone()) {
|
|
1015
|
+
return Option.none();
|
|
1016
|
+
}
|
|
1017
|
+
if (this._config.isolationStrategy !== "DATABASE" && this._config.isolationStrategy !== "HYBRID") {
|
|
1018
|
+
return Option.none();
|
|
1019
|
+
}
|
|
1020
|
+
const tenant = tenantId.unwrap();
|
|
1021
|
+
const baseName = this._config.databaseName || "app";
|
|
1022
|
+
const dbName = `${baseName}_${tenant}`;
|
|
1023
|
+
return Option.some(dbName);
|
|
1024
|
+
}
|
|
1025
|
+
getTenantSchema() {
|
|
1026
|
+
const tenantId = this.getTenantId();
|
|
1027
|
+
if (tenantId.isNone()) {
|
|
1028
|
+
return Option.none();
|
|
1029
|
+
}
|
|
1030
|
+
if (this._config.isolationStrategy !== "SCHEMA" && this._config.isolationStrategy !== "HYBRID") {
|
|
1031
|
+
return Option.none();
|
|
1032
|
+
}
|
|
1033
|
+
const tenant = tenantId.unwrap();
|
|
1034
|
+
const baseName = this._config.schemaName || "app";
|
|
1035
|
+
const schemaName = `${baseName}_${tenant}`;
|
|
1036
|
+
return Option.some(schemaName);
|
|
1037
|
+
}
|
|
1038
|
+
async withTenant(tenantId, callback) {
|
|
1039
|
+
const previousTenant = this.currentTenant;
|
|
1040
|
+
try {
|
|
1041
|
+
await this.setTenant(tenantId);
|
|
1042
|
+
const result = await callback();
|
|
1043
|
+
return result;
|
|
1044
|
+
} finally {
|
|
1045
|
+
if (previousTenant) {
|
|
1046
|
+
this.currentTenant = previousTenant;
|
|
1047
|
+
} else {
|
|
1048
|
+
this.currentTenant = null;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
clone() {
|
|
1053
|
+
return new _TenantContextManager(this._config, this.logger);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Validate tenant ID format
|
|
1057
|
+
*/
|
|
1058
|
+
validateTenantId(tenantId) {
|
|
1059
|
+
if (!tenantId || typeof tenantId !== "string") {
|
|
1060
|
+
throw new Error("Tenant ID must be a non-empty string");
|
|
1061
|
+
}
|
|
1062
|
+
if (tenantId.trim().length === 0) {
|
|
1063
|
+
throw new Error("Tenant ID cannot be whitespace only");
|
|
1064
|
+
}
|
|
1065
|
+
if (/[;'"`\\]/.test(tenantId)) {
|
|
1066
|
+
throw new Error("Tenant ID contains invalid characters");
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Apply ROW-level tenant isolation to SQL query
|
|
1071
|
+
*/
|
|
1072
|
+
applyRowLevelIsolation(sql, params, tenantId) {
|
|
1073
|
+
const tenantColumn = this._config.tenantIdColumn || "tenant_id";
|
|
1074
|
+
const newParams = [...params];
|
|
1075
|
+
const sqlUpper = sql.trim().toUpperCase();
|
|
1076
|
+
if (sqlUpper.startsWith("SELECT")) {
|
|
1077
|
+
if (sql.toUpperCase().includes("WHERE")) {
|
|
1078
|
+
sql = sql.replace(/WHERE/i, `WHERE ${tenantColumn} = ? AND`);
|
|
1079
|
+
newParams.unshift(tenantId);
|
|
1080
|
+
} else {
|
|
1081
|
+
const clauseMatch = sql.match(/(ORDER BY|LIMIT|OFFSET|GROUP BY)/i);
|
|
1082
|
+
if (clauseMatch) {
|
|
1083
|
+
const pos = sql.indexOf(clauseMatch[0]);
|
|
1084
|
+
sql = sql.slice(0, pos) + ` WHERE ${tenantColumn} = ? ` + sql.slice(pos);
|
|
1085
|
+
} else {
|
|
1086
|
+
sql = `${sql} WHERE ${tenantColumn} = ?`;
|
|
1087
|
+
}
|
|
1088
|
+
newParams.push(tenantId);
|
|
1089
|
+
}
|
|
1090
|
+
} else if (sqlUpper.startsWith("INSERT")) {
|
|
1091
|
+
const valuesMatch = sql.match(/\(([^)]+)\)\s+VALUES\s+\(([^)]+)\)/i);
|
|
1092
|
+
if (valuesMatch) {
|
|
1093
|
+
const columns = valuesMatch[1];
|
|
1094
|
+
const placeholders = valuesMatch[2];
|
|
1095
|
+
const newColumns = `${columns}, ${tenantColumn}`;
|
|
1096
|
+
const newPlaceholders = `${placeholders}, ?`;
|
|
1097
|
+
sql = sql.replace(
|
|
1098
|
+
/\(([^)]+)\)\s+VALUES\s+\(([^)]+)\)/i,
|
|
1099
|
+
`(${newColumns}) VALUES (${newPlaceholders})`
|
|
1100
|
+
);
|
|
1101
|
+
newParams.push(tenantId);
|
|
1102
|
+
}
|
|
1103
|
+
} else if (sqlUpper.startsWith("UPDATE")) {
|
|
1104
|
+
if (sql.toUpperCase().includes("WHERE")) {
|
|
1105
|
+
sql = sql.replace(/WHERE/i, `WHERE ${tenantColumn} = ? AND`);
|
|
1106
|
+
newParams.unshift(tenantId);
|
|
1107
|
+
} else {
|
|
1108
|
+
sql = `${sql} WHERE ${tenantColumn} = ?`;
|
|
1109
|
+
newParams.push(tenantId);
|
|
1110
|
+
}
|
|
1111
|
+
} else if (sqlUpper.startsWith("DELETE")) {
|
|
1112
|
+
if (sql.toUpperCase().includes("WHERE")) {
|
|
1113
|
+
sql = sql.replace(/WHERE/i, `WHERE ${tenantColumn} = ? AND`);
|
|
1114
|
+
newParams.unshift(tenantId);
|
|
1115
|
+
} else {
|
|
1116
|
+
sql = `${sql} WHERE ${tenantColumn} = ?`;
|
|
1117
|
+
newParams.push(tenantId);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return { sql, params: newParams };
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Apply SCHEMA-level tenant isolation to SQL query
|
|
1124
|
+
*/
|
|
1125
|
+
applySchemaIsolation(sql, params, tenantId) {
|
|
1126
|
+
const schemaName = this.getTenantSchema();
|
|
1127
|
+
if (schemaName.isNone()) {
|
|
1128
|
+
return { sql, params };
|
|
1129
|
+
}
|
|
1130
|
+
const schema = schemaName.unwrap();
|
|
1131
|
+
sql = sql.replace(
|
|
1132
|
+
/(?:FROM|INTO|UPDATE|JOIN)\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
|
|
1133
|
+
(match, tableName) => {
|
|
1134
|
+
if (tableName.includes(".") || this._config.excludedTables?.includes(tableName)) {
|
|
1135
|
+
return match;
|
|
1136
|
+
}
|
|
1137
|
+
return match.replace(tableName, `${schema}.${tableName}`);
|
|
1138
|
+
}
|
|
1139
|
+
);
|
|
1140
|
+
return { sql, params };
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
// src/tenant/query-wrapper.ts
|
|
1145
|
+
var TenantAwareQueryBuilder = class _TenantAwareQueryBuilder {
|
|
1146
|
+
baseBuilder;
|
|
1147
|
+
tenantContext;
|
|
1148
|
+
logger;
|
|
1149
|
+
isAdminMode = false;
|
|
1150
|
+
constructor(baseBuilder, tenantContext, logger) {
|
|
1151
|
+
this.baseBuilder = baseBuilder;
|
|
1152
|
+
this.tenantContext = tenantContext;
|
|
1153
|
+
this.logger = logger;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Switch to admin mode - bypasses tenant filtering
|
|
1157
|
+
*
|
|
1158
|
+
* Use with caution! Only for administrative queries that need
|
|
1159
|
+
* to access data across all tenants.
|
|
1160
|
+
*
|
|
1161
|
+
* @returns QueryBuilder in admin mode
|
|
1162
|
+
*/
|
|
1163
|
+
asAdmin() {
|
|
1164
|
+
const adminBuilder = new _TenantAwareQueryBuilder(
|
|
1165
|
+
this.baseBuilder,
|
|
1166
|
+
this.tenantContext,
|
|
1167
|
+
this.logger
|
|
1168
|
+
);
|
|
1169
|
+
adminBuilder.isAdminMode = true;
|
|
1170
|
+
return adminBuilder;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Switch back to tenant mode from admin mode
|
|
1174
|
+
*
|
|
1175
|
+
* Re-enables automatic tenant filtering
|
|
1176
|
+
*
|
|
1177
|
+
* @returns QueryBuilder in tenant mode
|
|
1178
|
+
*/
|
|
1179
|
+
asTenant() {
|
|
1180
|
+
const tenantBuilder = new _TenantAwareQueryBuilder(
|
|
1181
|
+
this.baseBuilder,
|
|
1182
|
+
this.tenantContext,
|
|
1183
|
+
this.logger
|
|
1184
|
+
);
|
|
1185
|
+
tenantBuilder.isAdminMode = false;
|
|
1186
|
+
return tenantBuilder;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Validate that tenant context is set (unless admin mode or excluded table)
|
|
1190
|
+
*/
|
|
1191
|
+
validateTenantContext() {
|
|
1192
|
+
if (this.isAdminMode) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const tableName = this.getTableName();
|
|
1196
|
+
if (!this.tenantContext.shouldIsolateTable(tableName)) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (!this.tenantContext.hasTenant) {
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
"No tenant context set. Cannot execute query without tenant context. Use asAdmin() for admin queries or set tenant via tenantContext.setTenant()"
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Get table name from base builder
|
|
1207
|
+
*/
|
|
1208
|
+
getTableName() {
|
|
1209
|
+
const sql = this.baseBuilder.toSQL();
|
|
1210
|
+
const match = sql.match(/FROM\s+([a-zA-Z_][a-zA-Z0-9_]*)/i);
|
|
1211
|
+
return match ? match[1] : "";
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Inject tenant filter into query builder
|
|
1215
|
+
*
|
|
1216
|
+
* This method ensures the correct tenant filter is present.
|
|
1217
|
+
* If a manual tenant_id filter exists, it will be overridden.
|
|
1218
|
+
*/
|
|
1219
|
+
injectTenantFilter() {
|
|
1220
|
+
if (this.isAdminMode) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
const tableName = this.getTableName();
|
|
1224
|
+
if (!this.tenantContext.shouldIsolateTable(tableName)) {
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const tenantId = this.tenantContext.getTenantId();
|
|
1228
|
+
if (tenantId.isNone()) {
|
|
1229
|
+
throw new Error("No tenant context set. Cannot inject tenant filter.");
|
|
1230
|
+
}
|
|
1231
|
+
const tenant = tenantId.unwrap();
|
|
1232
|
+
const tenantColumn = this.tenantContext.config.tenantIdColumn || "tenant_id";
|
|
1233
|
+
this.baseBuilder.where(tenantColumn, "=", tenant);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Override tenant_id in data object for INSERT operations
|
|
1237
|
+
*/
|
|
1238
|
+
enforceTenantInData(data) {
|
|
1239
|
+
if (this.isAdminMode) {
|
|
1240
|
+
return data;
|
|
1241
|
+
}
|
|
1242
|
+
const tableName = this.getTableName();
|
|
1243
|
+
if (!this.tenantContext.shouldIsolateTable(tableName)) {
|
|
1244
|
+
return data;
|
|
1245
|
+
}
|
|
1246
|
+
const tenantId = this.tenantContext.getTenantId();
|
|
1247
|
+
if (tenantId.isNone()) {
|
|
1248
|
+
throw new Error("No tenant context set. Cannot enforce tenant in INSERT.");
|
|
1249
|
+
}
|
|
1250
|
+
const tenant = tenantId.unwrap();
|
|
1251
|
+
const tenantColumn = this.tenantContext.config.tenantIdColumn || "tenant_id";
|
|
1252
|
+
return {
|
|
1253
|
+
...data,
|
|
1254
|
+
[tenantColumn]: tenant
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
// QueryBuilder interface implementation
|
|
1258
|
+
table(table) {
|
|
1259
|
+
this.baseBuilder.table(table);
|
|
1260
|
+
return this;
|
|
1261
|
+
}
|
|
1262
|
+
select(...columns) {
|
|
1263
|
+
this.baseBuilder.select(...columns);
|
|
1264
|
+
return this;
|
|
1265
|
+
}
|
|
1266
|
+
where(field, operator, value) {
|
|
1267
|
+
this.baseBuilder.where(field, operator, value);
|
|
1268
|
+
return this;
|
|
1269
|
+
}
|
|
1270
|
+
orWhere(field, operator, value) {
|
|
1271
|
+
this.baseBuilder.orWhere(field, operator, value);
|
|
1272
|
+
return this;
|
|
1273
|
+
}
|
|
1274
|
+
whereIn(field, values) {
|
|
1275
|
+
this.baseBuilder.whereIn(field, values);
|
|
1276
|
+
return this;
|
|
1277
|
+
}
|
|
1278
|
+
join(table, leftField, rightField, type = "INNER") {
|
|
1279
|
+
this.baseBuilder.join(table, leftField, rightField, type);
|
|
1280
|
+
return this;
|
|
1281
|
+
}
|
|
1282
|
+
orderBy(field, direction = "ASC") {
|
|
1283
|
+
this.baseBuilder.orderBy(field, direction);
|
|
1284
|
+
return this;
|
|
1285
|
+
}
|
|
1286
|
+
groupBy(...fields) {
|
|
1287
|
+
this.baseBuilder.groupBy(...fields);
|
|
1288
|
+
return this;
|
|
1289
|
+
}
|
|
1290
|
+
having(condition) {
|
|
1291
|
+
this.baseBuilder.having(condition);
|
|
1292
|
+
return this;
|
|
1293
|
+
}
|
|
1294
|
+
limit(limit) {
|
|
1295
|
+
this.baseBuilder.limit(limit);
|
|
1296
|
+
return this;
|
|
1297
|
+
}
|
|
1298
|
+
offset(offset) {
|
|
1299
|
+
this.baseBuilder.offset(offset);
|
|
1300
|
+
return this;
|
|
1301
|
+
}
|
|
1302
|
+
async insert(data) {
|
|
1303
|
+
this.validateTenantContext();
|
|
1304
|
+
const dataWithTenant = this.enforceTenantInData(data);
|
|
1305
|
+
this.logger.debug("Tenant-aware INSERT", {
|
|
1306
|
+
table: this.getTableName(),
|
|
1307
|
+
isAdmin: this.isAdminMode
|
|
1308
|
+
});
|
|
1309
|
+
return this.baseBuilder.insert(dataWithTenant);
|
|
1310
|
+
}
|
|
1311
|
+
async insertMany(data) {
|
|
1312
|
+
this.validateTenantContext();
|
|
1313
|
+
const dataWithTenant = data.map((item) => this.enforceTenantInData(item));
|
|
1314
|
+
this.logger.debug("Tenant-aware INSERT MANY", {
|
|
1315
|
+
table: this.getTableName(),
|
|
1316
|
+
count: data.length,
|
|
1317
|
+
isAdmin: this.isAdminMode
|
|
1318
|
+
});
|
|
1319
|
+
return this.baseBuilder.insertMany(dataWithTenant);
|
|
1320
|
+
}
|
|
1321
|
+
async update(data) {
|
|
1322
|
+
this.validateTenantContext();
|
|
1323
|
+
this.injectTenantFilter();
|
|
1324
|
+
this.logger.debug("Tenant-aware UPDATE", {
|
|
1325
|
+
table: this.getTableName(),
|
|
1326
|
+
isAdmin: this.isAdminMode
|
|
1327
|
+
});
|
|
1328
|
+
return this.baseBuilder.update(data);
|
|
1329
|
+
}
|
|
1330
|
+
async delete() {
|
|
1331
|
+
this.validateTenantContext();
|
|
1332
|
+
this.injectTenantFilter();
|
|
1333
|
+
this.logger.debug("Tenant-aware DELETE", {
|
|
1334
|
+
table: this.getTableName(),
|
|
1335
|
+
isAdmin: this.isAdminMode
|
|
1336
|
+
});
|
|
1337
|
+
return this.baseBuilder.delete();
|
|
1338
|
+
}
|
|
1339
|
+
async first() {
|
|
1340
|
+
this.validateTenantContext();
|
|
1341
|
+
this.injectTenantFilter();
|
|
1342
|
+
this.logger.debug("Tenant-aware FIRST", {
|
|
1343
|
+
table: this.getTableName(),
|
|
1344
|
+
isAdmin: this.isAdminMode
|
|
1345
|
+
});
|
|
1346
|
+
return this.baseBuilder.first();
|
|
1347
|
+
}
|
|
1348
|
+
async get() {
|
|
1349
|
+
this.validateTenantContext();
|
|
1350
|
+
this.injectTenantFilter();
|
|
1351
|
+
this.logger.debug("Tenant-aware GET", {
|
|
1352
|
+
table: this.getTableName(),
|
|
1353
|
+
isAdmin: this.isAdminMode
|
|
1354
|
+
});
|
|
1355
|
+
return this.baseBuilder.get();
|
|
1356
|
+
}
|
|
1357
|
+
async count(field = "*") {
|
|
1358
|
+
this.validateTenantContext();
|
|
1359
|
+
this.injectTenantFilter();
|
|
1360
|
+
this.logger.debug("Tenant-aware COUNT", {
|
|
1361
|
+
table: this.getTableName(),
|
|
1362
|
+
field,
|
|
1363
|
+
isAdmin: this.isAdminMode
|
|
1364
|
+
});
|
|
1365
|
+
return this.baseBuilder.count(field);
|
|
1366
|
+
}
|
|
1367
|
+
toSQL() {
|
|
1368
|
+
this.validateTenantContext();
|
|
1369
|
+
this.injectTenantFilter();
|
|
1370
|
+
return this.baseBuilder.toSQL();
|
|
1371
|
+
}
|
|
1372
|
+
getBindings() {
|
|
1373
|
+
return this.baseBuilder.getBindings();
|
|
1374
|
+
}
|
|
1375
|
+
clone() {
|
|
1376
|
+
const clonedBase = this.baseBuilder.clone();
|
|
1377
|
+
const clonedTenantAware = new _TenantAwareQueryBuilder(
|
|
1378
|
+
clonedBase,
|
|
1379
|
+
this.tenantContext,
|
|
1380
|
+
this.logger
|
|
1381
|
+
);
|
|
1382
|
+
clonedTenantAware.isAdminMode = this.isAdminMode;
|
|
1383
|
+
return clonedTenantAware;
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
var TenantSchemaManager = class {
|
|
1387
|
+
connection;
|
|
1388
|
+
logger;
|
|
1389
|
+
config;
|
|
1390
|
+
constructor(connection, logger, config) {
|
|
1391
|
+
this.connection = connection;
|
|
1392
|
+
this.logger = logger;
|
|
1393
|
+
this.config = {
|
|
1394
|
+
schemaVersionTable: "schema_versions",
|
|
1395
|
+
...config
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Create a new tenant schema
|
|
1400
|
+
*
|
|
1401
|
+
* @param tenantId - Tenant identifier
|
|
1402
|
+
* @param options - Schema creation options
|
|
1403
|
+
* @returns Result with void on success or SchemaManagerError
|
|
1404
|
+
*/
|
|
1405
|
+
async createTenantSchema(tenantId, options = {}) {
|
|
1406
|
+
const validationResult = this.validateTenantId(tenantId);
|
|
1407
|
+
if (validationResult.isErr()) {
|
|
1408
|
+
return validationResult;
|
|
1409
|
+
}
|
|
1410
|
+
const schemaName = this.getSchemaName(tenantId);
|
|
1411
|
+
try {
|
|
1412
|
+
this.logger.debug("Creating tenant schema", { tenantId, schemaName });
|
|
1413
|
+
if (options.useTransaction) {
|
|
1414
|
+
await this.connection.execute("BEGIN");
|
|
1415
|
+
}
|
|
1416
|
+
const createResult = await this.connection.execute(
|
|
1417
|
+
`CREATE SCHEMA ${schemaName}`,
|
|
1418
|
+
[]
|
|
1419
|
+
);
|
|
1420
|
+
if (createResult.isErr()) {
|
|
1421
|
+
if (options.useTransaction) {
|
|
1422
|
+
await this.connection.execute("ROLLBACK");
|
|
1423
|
+
}
|
|
1424
|
+
const error = createResult.variant.error;
|
|
1425
|
+
if (error.message.includes("already exists")) {
|
|
1426
|
+
return Result.err({
|
|
1427
|
+
type: "SCHEMA_EXISTS",
|
|
1428
|
+
message: `Schema for tenant ${tenantId} already exists`
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
return Result.err({
|
|
1432
|
+
type: "DATABASE_ERROR",
|
|
1433
|
+
message: `Failed to create schema for tenant ${tenantId}`,
|
|
1434
|
+
cause: error
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
if (options.version) {
|
|
1438
|
+
const versionResult = await this.initializeVersionTracking(
|
|
1439
|
+
tenantId,
|
|
1440
|
+
options.version
|
|
1441
|
+
);
|
|
1442
|
+
if (versionResult.isErr()) {
|
|
1443
|
+
if (options.useTransaction) {
|
|
1444
|
+
await this.connection.execute("ROLLBACK");
|
|
1445
|
+
}
|
|
1446
|
+
return versionResult;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
if (options.useTransaction) {
|
|
1450
|
+
await this.connection.execute("COMMIT");
|
|
1451
|
+
}
|
|
1452
|
+
this.logger.info("Tenant schema created", { tenantId, schemaName });
|
|
1453
|
+
return Result.ok(void 0);
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
if (options.useTransaction) {
|
|
1456
|
+
await this.connection.execute("ROLLBACK");
|
|
1457
|
+
}
|
|
1458
|
+
this.logger.error("Schema creation failed", { error, tenantId });
|
|
1459
|
+
return Result.err({
|
|
1460
|
+
type: "DATABASE_ERROR",
|
|
1461
|
+
message: `Schema creation failed for tenant ${tenantId}: ${error}`,
|
|
1462
|
+
cause: error
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Drop a tenant schema
|
|
1468
|
+
*
|
|
1469
|
+
* @param tenantId - Tenant identifier
|
|
1470
|
+
* @param options - Drop schema options
|
|
1471
|
+
* @returns Result with void on success or SchemaManagerError
|
|
1472
|
+
*/
|
|
1473
|
+
async dropTenantSchema(tenantId, options = {}) {
|
|
1474
|
+
const validationResult = this.validateTenantId(tenantId);
|
|
1475
|
+
if (validationResult.isErr()) {
|
|
1476
|
+
return validationResult;
|
|
1477
|
+
}
|
|
1478
|
+
const schemaName = this.getSchemaName(tenantId);
|
|
1479
|
+
try {
|
|
1480
|
+
this.logger.debug("Dropping tenant schema", { tenantId, schemaName });
|
|
1481
|
+
let sql = `DROP SCHEMA ${options.ifExists ? "IF EXISTS " : ""}${schemaName}`;
|
|
1482
|
+
if (options.cascade) {
|
|
1483
|
+
sql += " CASCADE";
|
|
1484
|
+
}
|
|
1485
|
+
const dropResult = await this.connection.execute(sql, []);
|
|
1486
|
+
if (dropResult.isErr()) {
|
|
1487
|
+
const error = dropResult.variant.error;
|
|
1488
|
+
return Result.err({
|
|
1489
|
+
type: "DATABASE_ERROR",
|
|
1490
|
+
message: `Failed to drop schema for tenant ${tenantId}`,
|
|
1491
|
+
cause: error
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
if (options.cleanupVersions) {
|
|
1495
|
+
await this.connection.execute(
|
|
1496
|
+
`DELETE FROM ${this.config.schemaVersionTable} WHERE tenant_id = ?`,
|
|
1497
|
+
[tenantId]
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
this.logger.info("Tenant schema dropped", { tenantId, schemaName });
|
|
1501
|
+
return Result.ok(void 0);
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
this.logger.error("Schema drop failed", { error, tenantId });
|
|
1504
|
+
return Result.err({
|
|
1505
|
+
type: "DATABASE_ERROR",
|
|
1506
|
+
message: `Schema drop failed for tenant ${tenantId}: ${error}`,
|
|
1507
|
+
cause: error
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Check if tenant schema exists
|
|
1513
|
+
*
|
|
1514
|
+
* @param tenantId - Tenant identifier
|
|
1515
|
+
* @returns Result with boolean indicating existence
|
|
1516
|
+
*/
|
|
1517
|
+
async schemaExists(tenantId) {
|
|
1518
|
+
const schemaName = this.getSchemaName(tenantId);
|
|
1519
|
+
try {
|
|
1520
|
+
const result = await this.connection.execute(
|
|
1521
|
+
`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ?`,
|
|
1522
|
+
[schemaName]
|
|
1523
|
+
);
|
|
1524
|
+
if (result.isErr()) {
|
|
1525
|
+
return Result.err({
|
|
1526
|
+
type: "DATABASE_ERROR",
|
|
1527
|
+
message: "Failed to check schema existence",
|
|
1528
|
+
cause: result.variant.error
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
const data = result.unwrap();
|
|
1532
|
+
const rows = data.rows || data || [];
|
|
1533
|
+
return Result.ok(rows.length > 0);
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
return Result.err({
|
|
1536
|
+
type: "DATABASE_ERROR",
|
|
1537
|
+
message: `Failed to check schema existence: ${error}`,
|
|
1538
|
+
cause: error
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Get current schema version for tenant
|
|
1544
|
+
*
|
|
1545
|
+
* @param tenantId - Tenant identifier
|
|
1546
|
+
* @returns Result with version string or null
|
|
1547
|
+
*/
|
|
1548
|
+
async getSchemaVersion(tenantId) {
|
|
1549
|
+
try {
|
|
1550
|
+
const result = await this.connection.execute(
|
|
1551
|
+
`SELECT version FROM ${this.config.schemaVersionTable} WHERE tenant_id = ? ORDER BY applied_at DESC LIMIT 1`,
|
|
1552
|
+
[tenantId]
|
|
1553
|
+
);
|
|
1554
|
+
if (result.isErr()) {
|
|
1555
|
+
return Result.err({
|
|
1556
|
+
type: "DATABASE_ERROR",
|
|
1557
|
+
message: "Failed to get schema version",
|
|
1558
|
+
cause: result.variant.error
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
const data = result.unwrap();
|
|
1562
|
+
const rows = data.rows || data || [];
|
|
1563
|
+
if (rows.length === 0) {
|
|
1564
|
+
return Result.ok(null);
|
|
1565
|
+
}
|
|
1566
|
+
return Result.ok(rows[0].version);
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return Result.err({
|
|
1569
|
+
type: "DATABASE_ERROR",
|
|
1570
|
+
message: `Failed to get schema version: ${error}`,
|
|
1571
|
+
cause: error
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Update schema version for tenant
|
|
1577
|
+
*
|
|
1578
|
+
* @param tenantId - Tenant identifier
|
|
1579
|
+
* @param version - New version string
|
|
1580
|
+
* @returns Result with void on success
|
|
1581
|
+
*/
|
|
1582
|
+
async updateSchemaVersion(tenantId, version2) {
|
|
1583
|
+
if (!this.isValidVersion(version2)) {
|
|
1584
|
+
return Result.err({
|
|
1585
|
+
type: "INVALID_VERSION",
|
|
1586
|
+
message: `Invalid version format: ${version2}. Expected semver format (e.g., 1.0.0)`
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const result = await this.connection.execute(
|
|
1591
|
+
`INSERT INTO ${this.config.schemaVersionTable} (tenant_id, version, applied_at) VALUES (?, ?, ?)`,
|
|
1592
|
+
[tenantId, version2, /* @__PURE__ */ new Date()]
|
|
1593
|
+
);
|
|
1594
|
+
if (result.isErr()) {
|
|
1595
|
+
return Result.err({
|
|
1596
|
+
type: "DATABASE_ERROR",
|
|
1597
|
+
message: "Failed to update schema version",
|
|
1598
|
+
cause: result.variant.error
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
return Result.ok(void 0);
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
return Result.err({
|
|
1604
|
+
type: "DATABASE_ERROR",
|
|
1605
|
+
message: `Failed to update schema version: ${error}`,
|
|
1606
|
+
cause: error
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Get version history for tenant
|
|
1612
|
+
*
|
|
1613
|
+
* @param tenantId - Tenant identifier
|
|
1614
|
+
* @returns Result with array of schema versions
|
|
1615
|
+
*/
|
|
1616
|
+
async getVersionHistory(tenantId) {
|
|
1617
|
+
try {
|
|
1618
|
+
const result = await this.connection.execute(
|
|
1619
|
+
`SELECT version, applied_at, description FROM ${this.config.schemaVersionTable} WHERE tenant_id = ? ORDER BY applied_at ASC`,
|
|
1620
|
+
[tenantId]
|
|
1621
|
+
);
|
|
1622
|
+
if (result.isErr()) {
|
|
1623
|
+
return Result.err({
|
|
1624
|
+
type: "DATABASE_ERROR",
|
|
1625
|
+
message: "Failed to get version history",
|
|
1626
|
+
cause: result.variant.error
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
const data = result.unwrap();
|
|
1630
|
+
const rows = data.rows || data || [];
|
|
1631
|
+
return Result.ok(rows);
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
return Result.err({
|
|
1634
|
+
type: "DATABASE_ERROR",
|
|
1635
|
+
message: `Failed to get version history: ${error}`,
|
|
1636
|
+
cause: error
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Apply a migration to tenant schema
|
|
1642
|
+
*
|
|
1643
|
+
* @param tenantId - Tenant identifier
|
|
1644
|
+
* @param migration - Migration definition
|
|
1645
|
+
* @returns Result with void on success
|
|
1646
|
+
*/
|
|
1647
|
+
async applyMigration(tenantId, migration) {
|
|
1648
|
+
const schemaName = this.getSchemaName(tenantId);
|
|
1649
|
+
try {
|
|
1650
|
+
this.logger.debug("Applying migration", {
|
|
1651
|
+
tenantId,
|
|
1652
|
+
version: migration.version
|
|
1653
|
+
});
|
|
1654
|
+
const currentVersion = await this.getSchemaVersion(tenantId);
|
|
1655
|
+
if (currentVersion.isOk() && currentVersion.unwrap() === migration.version) {
|
|
1656
|
+
this.logger.info("Migration already applied", {
|
|
1657
|
+
tenantId,
|
|
1658
|
+
version: migration.version
|
|
1659
|
+
});
|
|
1660
|
+
return Result.ok(void 0);
|
|
1661
|
+
}
|
|
1662
|
+
await this.connection.execute(`SET search_path TO ${schemaName}`, []);
|
|
1663
|
+
const migrationResult = await this.connection.execute(migration.sql, []);
|
|
1664
|
+
if (migrationResult.isErr()) {
|
|
1665
|
+
return Result.err({
|
|
1666
|
+
type: "MIGRATION_FAILED",
|
|
1667
|
+
message: `Migration ${migration.version} failed for tenant ${tenantId}`,
|
|
1668
|
+
cause: migrationResult.variant.error
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
await this.connection.execute(
|
|
1672
|
+
`INSERT INTO ${this.config.schemaVersionTable} (tenant_id, version, applied_at, description) VALUES (?, ?, ?, ?)`,
|
|
1673
|
+
[tenantId, migration.version, /* @__PURE__ */ new Date(), migration.description || ""]
|
|
1674
|
+
);
|
|
1675
|
+
this.logger.info("Migration applied", {
|
|
1676
|
+
tenantId,
|
|
1677
|
+
version: migration.version
|
|
1678
|
+
});
|
|
1679
|
+
return Result.ok(void 0);
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
this.logger.error("Migration failed", {
|
|
1682
|
+
error,
|
|
1683
|
+
tenantId,
|
|
1684
|
+
version: migration.version
|
|
1685
|
+
});
|
|
1686
|
+
return Result.err({
|
|
1687
|
+
type: "MIGRATION_FAILED",
|
|
1688
|
+
message: `Migration failed: ${error}`,
|
|
1689
|
+
cause: error
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Get the schema name for a tenant
|
|
1695
|
+
*
|
|
1696
|
+
* @param tenantId - Tenant identifier
|
|
1697
|
+
* @returns Schema name
|
|
1698
|
+
*/
|
|
1699
|
+
getSchemaName(tenantId) {
|
|
1700
|
+
if (/[;'"`\\]/.test(tenantId)) {
|
|
1701
|
+
throw new Error(`Invalid tenant ID: contains dangerous characters`);
|
|
1702
|
+
}
|
|
1703
|
+
return `${this.config.baseSchemaName}_tenant_${tenantId.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Validate tenant ID format
|
|
1707
|
+
*/
|
|
1708
|
+
validateTenantId(tenantId) {
|
|
1709
|
+
if (!tenantId || typeof tenantId !== "string") {
|
|
1710
|
+
return Result.err({
|
|
1711
|
+
type: "INVALID_TENANT_ID",
|
|
1712
|
+
message: "Tenant ID must be a non-empty string"
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
if (tenantId.trim().length === 0) {
|
|
1716
|
+
return Result.err({
|
|
1717
|
+
type: "INVALID_TENANT_ID",
|
|
1718
|
+
message: "Tenant ID cannot be whitespace only"
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
if (/[;'"`\\]/.test(tenantId)) {
|
|
1722
|
+
return Result.err({
|
|
1723
|
+
type: "INVALID_TENANT_ID",
|
|
1724
|
+
message: "Tenant ID contains invalid characters"
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
return Result.ok(void 0);
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Validate version format (simple semver check)
|
|
1731
|
+
*/
|
|
1732
|
+
isValidVersion(version2) {
|
|
1733
|
+
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/.test(version2);
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Initialize version tracking for new tenant
|
|
1737
|
+
*/
|
|
1738
|
+
async initializeVersionTracking(tenantId, version2) {
|
|
1739
|
+
if (!this.isValidVersion(version2)) {
|
|
1740
|
+
return Result.err({
|
|
1741
|
+
type: "INVALID_VERSION",
|
|
1742
|
+
message: `Invalid version format: ${version2}`
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
const result = await this.connection.execute(
|
|
1746
|
+
`INSERT INTO ${this.config.schemaVersionTable} (tenant_id, version, applied_at) VALUES (?, ?, ?)`,
|
|
1747
|
+
[tenantId, version2, /* @__PURE__ */ new Date()]
|
|
1748
|
+
);
|
|
1749
|
+
if (result.isErr()) {
|
|
1750
|
+
return Result.err({
|
|
1751
|
+
type: "DATABASE_ERROR",
|
|
1752
|
+
message: "Failed to initialize version tracking",
|
|
1753
|
+
cause: result.variant.error
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
return Result.ok(void 0);
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
var MemoryCache = class {
|
|
1760
|
+
config;
|
|
1761
|
+
logger;
|
|
1762
|
+
store;
|
|
1763
|
+
lruList = [];
|
|
1764
|
+
// Most recent at end
|
|
1765
|
+
stats = {
|
|
1766
|
+
hits: 0,
|
|
1767
|
+
misses: 0,
|
|
1768
|
+
evictions: 0
|
|
1769
|
+
};
|
|
1770
|
+
constructor(config, logger) {
|
|
1771
|
+
this.config = config;
|
|
1772
|
+
this.logger = logger;
|
|
1773
|
+
this.store = /* @__PURE__ */ new Map();
|
|
1774
|
+
}
|
|
1775
|
+
async get(key) {
|
|
1776
|
+
try {
|
|
1777
|
+
const fullKey = this.getFullKey(key);
|
|
1778
|
+
const entry = this.store.get(fullKey);
|
|
1779
|
+
if (!entry) {
|
|
1780
|
+
this.stats.misses++;
|
|
1781
|
+
return Result.ok(Option.none());
|
|
1782
|
+
}
|
|
1783
|
+
if (this.isExpired(entry)) {
|
|
1784
|
+
this.store.delete(fullKey);
|
|
1785
|
+
this.removeLRU(fullKey);
|
|
1786
|
+
this.stats.misses++;
|
|
1787
|
+
return Result.ok(Option.none());
|
|
1788
|
+
}
|
|
1789
|
+
this.touchEntry(fullKey, entry);
|
|
1790
|
+
this.stats.hits++;
|
|
1791
|
+
return Result.ok(Option.some(entry.value));
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
this.logger.error("Cache get failed", { error, key });
|
|
1794
|
+
return Result.err({
|
|
1795
|
+
type: "GET_FAILED",
|
|
1796
|
+
message: `Failed to get cache entry: ${error}`,
|
|
1797
|
+
cause: error
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
async getWithMetadata(key) {
|
|
1802
|
+
try {
|
|
1803
|
+
const fullKey = this.getFullKey(key);
|
|
1804
|
+
const entry = this.store.get(fullKey);
|
|
1805
|
+
if (!entry) {
|
|
1806
|
+
this.stats.misses++;
|
|
1807
|
+
return Result.ok(Option.none());
|
|
1808
|
+
}
|
|
1809
|
+
if (this.isExpired(entry)) {
|
|
1810
|
+
this.store.delete(fullKey);
|
|
1811
|
+
this.removeLRU(fullKey);
|
|
1812
|
+
this.stats.misses++;
|
|
1813
|
+
return Result.ok(Option.none());
|
|
1814
|
+
}
|
|
1815
|
+
this.touchEntry(fullKey, entry);
|
|
1816
|
+
this.stats.hits++;
|
|
1817
|
+
return Result.ok(Option.some(entry));
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
this.logger.error("Cache getWithMetadata failed", { error, key });
|
|
1820
|
+
return Result.err({
|
|
1821
|
+
type: "GET_FAILED",
|
|
1822
|
+
message: `Failed to get cache entry with metadata: ${error}`,
|
|
1823
|
+
cause: error
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
async set(key, value, ttl) {
|
|
1828
|
+
try {
|
|
1829
|
+
const fullKey = this.getFullKey(key);
|
|
1830
|
+
const effectiveTTL = ttl !== void 0 ? ttl : this.config.defaultTTL;
|
|
1831
|
+
if (this.config.maxItems && this.store.size >= this.config.maxItems && !this.store.has(fullKey)) {
|
|
1832
|
+
this.evictLRU();
|
|
1833
|
+
}
|
|
1834
|
+
const now = /* @__PURE__ */ new Date();
|
|
1835
|
+
const expiresAt = effectiveTTL > 0 ? new Date(now.getTime() + effectiveTTL * 1e3) : void 0;
|
|
1836
|
+
const metadata = {
|
|
1837
|
+
key: fullKey,
|
|
1838
|
+
createdAt: now,
|
|
1839
|
+
lastAccessedAt: now,
|
|
1840
|
+
accessCount: 0,
|
|
1841
|
+
size: this.estimateSize(value),
|
|
1842
|
+
ttl: effectiveTTL,
|
|
1843
|
+
expiresAt
|
|
1844
|
+
};
|
|
1845
|
+
const entry = {
|
|
1846
|
+
value,
|
|
1847
|
+
metadata
|
|
1848
|
+
};
|
|
1849
|
+
if (this.store.has(fullKey)) {
|
|
1850
|
+
this.removeLRU(fullKey);
|
|
1851
|
+
}
|
|
1852
|
+
this.store.set(fullKey, entry);
|
|
1853
|
+
this.lruList.push(fullKey);
|
|
1854
|
+
this.logger.debug("Cache set", { key, ttl: effectiveTTL });
|
|
1855
|
+
return Result.ok(void 0);
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
this.logger.error("Cache set failed", { error, key });
|
|
1858
|
+
return Result.err({
|
|
1859
|
+
type: "SET_FAILED",
|
|
1860
|
+
message: `Failed to set cache entry: ${error}`,
|
|
1861
|
+
cause: error
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
async delete(key) {
|
|
1866
|
+
try {
|
|
1867
|
+
const fullKey = this.getFullKey(key);
|
|
1868
|
+
const existed = this.store.delete(fullKey);
|
|
1869
|
+
if (existed) {
|
|
1870
|
+
this.removeLRU(fullKey);
|
|
1871
|
+
this.logger.debug("Cache delete", { key });
|
|
1872
|
+
}
|
|
1873
|
+
return Result.ok(existed);
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
this.logger.error("Cache delete failed", { error, key });
|
|
1876
|
+
return Result.err({
|
|
1877
|
+
type: "DELETE_FAILED",
|
|
1878
|
+
message: `Failed to delete cache entry: ${error}`,
|
|
1879
|
+
cause: error
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
async deleteMany(keys) {
|
|
1884
|
+
try {
|
|
1885
|
+
let deleted = 0;
|
|
1886
|
+
for (const key of keys) {
|
|
1887
|
+
const result = await this.delete(key);
|
|
1888
|
+
if (result.isOk() && result.unwrap()) {
|
|
1889
|
+
deleted++;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return Result.ok(deleted);
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
this.logger.error("Cache deleteMany failed", { error });
|
|
1895
|
+
return Result.err({
|
|
1896
|
+
type: "DELETE_FAILED",
|
|
1897
|
+
message: `Failed to delete multiple cache entries: ${error}`,
|
|
1898
|
+
cause: error
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
async clear() {
|
|
1903
|
+
try {
|
|
1904
|
+
this.store.clear();
|
|
1905
|
+
this.lruList.length = 0;
|
|
1906
|
+
this.logger.debug("Cache cleared");
|
|
1907
|
+
return Result.ok(void 0);
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
this.logger.error("Cache clear failed", { error });
|
|
1910
|
+
return Result.err({
|
|
1911
|
+
type: "CLEAR_FAILED",
|
|
1912
|
+
message: `Failed to clear cache: ${error}`,
|
|
1913
|
+
cause: error
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
async has(key) {
|
|
1918
|
+
try {
|
|
1919
|
+
const fullKey = this.getFullKey(key);
|
|
1920
|
+
const entry = this.store.get(fullKey);
|
|
1921
|
+
if (!entry) {
|
|
1922
|
+
return Result.ok(false);
|
|
1923
|
+
}
|
|
1924
|
+
if (this.isExpired(entry)) {
|
|
1925
|
+
this.store.delete(fullKey);
|
|
1926
|
+
this.removeLRU(fullKey);
|
|
1927
|
+
return Result.ok(false);
|
|
1928
|
+
}
|
|
1929
|
+
return Result.ok(true);
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
this.logger.error("Cache has failed", { error, key });
|
|
1932
|
+
return Result.err({
|
|
1933
|
+
type: "GET_FAILED",
|
|
1934
|
+
message: `Failed to check cache key existence: ${error}`,
|
|
1935
|
+
cause: error
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
async invalidate(pattern) {
|
|
1940
|
+
try {
|
|
1941
|
+
const fullPattern = this.config.keyPrefix ? `${this.config.keyPrefix}${pattern}` : pattern;
|
|
1942
|
+
const regex = this.patternToRegex(fullPattern);
|
|
1943
|
+
let invalidated = 0;
|
|
1944
|
+
for (const key of this.store.keys()) {
|
|
1945
|
+
if (regex.test(key)) {
|
|
1946
|
+
this.store.delete(key);
|
|
1947
|
+
this.removeLRU(key);
|
|
1948
|
+
invalidated++;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
this.logger.debug("Cache invalidate", { pattern, count: invalidated });
|
|
1952
|
+
return Result.ok(invalidated);
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
this.logger.error("Cache invalidate failed", { error, pattern });
|
|
1955
|
+
return Result.err({
|
|
1956
|
+
type: "DELETE_FAILED",
|
|
1957
|
+
message: `Failed to invalidate cache entries: ${error}`,
|
|
1958
|
+
cause: error
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
async invalidateTable(table) {
|
|
1963
|
+
try {
|
|
1964
|
+
const pattern = `*${table}*`;
|
|
1965
|
+
return await this.invalidate(pattern);
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
this.logger.error("Cache invalidateTable failed", { error, table });
|
|
1968
|
+
return Result.err({
|
|
1969
|
+
type: "DELETE_FAILED",
|
|
1970
|
+
message: `Failed to invalidate table cache: ${error}`,
|
|
1971
|
+
cause: error
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
getStats() {
|
|
1976
|
+
try {
|
|
1977
|
+
const totalAccesses = this.stats.hits + this.stats.misses;
|
|
1978
|
+
const hitRate = totalAccesses > 0 ? this.stats.hits / totalAccesses : 0;
|
|
1979
|
+
let totalSize = 0;
|
|
1980
|
+
for (const entry of this.store.values()) {
|
|
1981
|
+
totalSize += entry.metadata.size;
|
|
1982
|
+
}
|
|
1983
|
+
return Result.ok({
|
|
1984
|
+
itemCount: this.store.size,
|
|
1985
|
+
totalSize,
|
|
1986
|
+
hits: this.stats.hits,
|
|
1987
|
+
misses: this.stats.misses,
|
|
1988
|
+
hitRate,
|
|
1989
|
+
evictions: this.stats.evictions
|
|
1990
|
+
});
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
return Result.err({
|
|
1993
|
+
type: "UNKNOWN",
|
|
1994
|
+
message: `Failed to get cache stats: ${error}`,
|
|
1995
|
+
cause: error
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
resetStats() {
|
|
2000
|
+
try {
|
|
2001
|
+
this.stats.hits = 0;
|
|
2002
|
+
this.stats.misses = 0;
|
|
2003
|
+
this.stats.evictions = 0;
|
|
2004
|
+
return Result.ok(void 0);
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
return Result.err({
|
|
2007
|
+
type: "UNKNOWN",
|
|
2008
|
+
message: `Failed to reset cache stats: ${error}`,
|
|
2009
|
+
cause: error
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
generateKey(sql, params) {
|
|
2014
|
+
const data = {
|
|
2015
|
+
sql,
|
|
2016
|
+
params: params || []
|
|
2017
|
+
};
|
|
2018
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
|
2019
|
+
return hash.substring(0, 16);
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Get full cache key with prefix
|
|
2023
|
+
*/
|
|
2024
|
+
getFullKey(key) {
|
|
2025
|
+
return this.config.keyPrefix ? `${this.config.keyPrefix}${key}` : key;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Check if entry is expired
|
|
2029
|
+
*/
|
|
2030
|
+
isExpired(entry) {
|
|
2031
|
+
if (!entry.metadata.expiresAt) {
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
return /* @__PURE__ */ new Date() > entry.metadata.expiresAt;
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Update entry access metadata and LRU position
|
|
2038
|
+
*/
|
|
2039
|
+
touchEntry(key, entry) {
|
|
2040
|
+
entry.metadata.lastAccessedAt = /* @__PURE__ */ new Date();
|
|
2041
|
+
entry.metadata.accessCount++;
|
|
2042
|
+
this.removeLRU(key);
|
|
2043
|
+
this.lruList.push(key);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Remove key from LRU list
|
|
2047
|
+
*/
|
|
2048
|
+
removeLRU(key) {
|
|
2049
|
+
const index = this.lruList.indexOf(key);
|
|
2050
|
+
if (index > -1) {
|
|
2051
|
+
this.lruList.splice(index, 1);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Evict least recently used entry
|
|
2056
|
+
*/
|
|
2057
|
+
evictLRU() {
|
|
2058
|
+
if (this.lruList.length === 0) {
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const lruKey = this.lruList[0];
|
|
2062
|
+
this.store.delete(lruKey);
|
|
2063
|
+
this.lruList.shift();
|
|
2064
|
+
this.stats.evictions++;
|
|
2065
|
+
this.logger.debug("LRU eviction", { key: lruKey });
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Estimate size of value in bytes
|
|
2069
|
+
*/
|
|
2070
|
+
estimateSize(value) {
|
|
2071
|
+
try {
|
|
2072
|
+
return JSON.stringify(value).length;
|
|
2073
|
+
} catch {
|
|
2074
|
+
return 0;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Convert glob pattern to regex
|
|
2079
|
+
*/
|
|
2080
|
+
patternToRegex(pattern) {
|
|
2081
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
2082
|
+
return new RegExp(`^${escaped}$`);
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
var KVCache = class {
|
|
2086
|
+
config;
|
|
2087
|
+
kv;
|
|
2088
|
+
logger;
|
|
2089
|
+
stats = {
|
|
2090
|
+
hits: 0,
|
|
2091
|
+
misses: 0
|
|
2092
|
+
};
|
|
2093
|
+
constructor(kv, config, logger) {
|
|
2094
|
+
this.kv = kv;
|
|
2095
|
+
this.config = config;
|
|
2096
|
+
this.logger = logger;
|
|
2097
|
+
}
|
|
2098
|
+
async get(key) {
|
|
2099
|
+
try {
|
|
2100
|
+
const fullKey = this.getFullKey(key);
|
|
2101
|
+
let value = await this.kv.get(fullKey, { type: "json" });
|
|
2102
|
+
if (value === null) {
|
|
2103
|
+
this.stats.misses++;
|
|
2104
|
+
return Result.ok(Option.none());
|
|
2105
|
+
}
|
|
2106
|
+
if (typeof value === "string") {
|
|
2107
|
+
try {
|
|
2108
|
+
value = JSON.parse(value);
|
|
2109
|
+
} catch {
|
|
2110
|
+
this.stats.hits++;
|
|
2111
|
+
return Result.ok(Option.some(value));
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
this.stats.hits++;
|
|
2115
|
+
if (value && typeof value === "object" && "value" in value) {
|
|
2116
|
+
return Result.ok(Option.some(value.value));
|
|
2117
|
+
}
|
|
2118
|
+
return Result.ok(Option.some(value));
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
this.logger.error("KV cache get failed", { error, key });
|
|
2121
|
+
return Result.err({
|
|
2122
|
+
type: "GET_FAILED",
|
|
2123
|
+
message: `Failed to get cache entry from KV: ${error}`,
|
|
2124
|
+
cause: error
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
async getWithMetadata(key) {
|
|
2129
|
+
try {
|
|
2130
|
+
const fullKey = this.getFullKey(key);
|
|
2131
|
+
let value = await this.kv.get(fullKey, { type: "json" });
|
|
2132
|
+
if (value === null) {
|
|
2133
|
+
this.stats.misses++;
|
|
2134
|
+
return Result.ok(Option.none());
|
|
2135
|
+
}
|
|
2136
|
+
if (typeof value === "string") {
|
|
2137
|
+
try {
|
|
2138
|
+
value = JSON.parse(value);
|
|
2139
|
+
} catch {
|
|
2140
|
+
const now2 = /* @__PURE__ */ new Date();
|
|
2141
|
+
this.stats.hits++;
|
|
2142
|
+
const entry2 = {
|
|
2143
|
+
value,
|
|
2144
|
+
metadata: {
|
|
2145
|
+
key: fullKey,
|
|
2146
|
+
createdAt: now2,
|
|
2147
|
+
lastAccessedAt: now2,
|
|
2148
|
+
accessCount: 1,
|
|
2149
|
+
size: this.estimateSize(value),
|
|
2150
|
+
ttl: this.config.defaultTTL
|
|
2151
|
+
}
|
|
2152
|
+
};
|
|
2153
|
+
return Result.ok(Option.some(entry2));
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
this.stats.hits++;
|
|
2157
|
+
if (value && typeof value === "object" && "value" in value && "metadata" in value) {
|
|
2158
|
+
const entry2 = value;
|
|
2159
|
+
entry2.metadata.createdAt = new Date(entry2.metadata.createdAt);
|
|
2160
|
+
entry2.metadata.lastAccessedAt = new Date(entry2.metadata.lastAccessedAt);
|
|
2161
|
+
if (entry2.metadata.expiresAt) {
|
|
2162
|
+
entry2.metadata.expiresAt = new Date(entry2.metadata.expiresAt);
|
|
2163
|
+
}
|
|
2164
|
+
return Result.ok(Option.some(entry2));
|
|
2165
|
+
}
|
|
2166
|
+
const now = /* @__PURE__ */ new Date();
|
|
2167
|
+
const entry = {
|
|
2168
|
+
value,
|
|
2169
|
+
metadata: {
|
|
2170
|
+
key: fullKey,
|
|
2171
|
+
createdAt: now,
|
|
2172
|
+
lastAccessedAt: now,
|
|
2173
|
+
accessCount: 1,
|
|
2174
|
+
size: this.estimateSize(value),
|
|
2175
|
+
ttl: this.config.defaultTTL
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
return Result.ok(Option.some(entry));
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
this.logger.error("KV cache getWithMetadata failed", { error, key });
|
|
2181
|
+
return Result.err({
|
|
2182
|
+
type: "GET_FAILED",
|
|
2183
|
+
message: `Failed to get cache entry with metadata from KV: ${error}`,
|
|
2184
|
+
cause: error
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
async set(key, value, ttl) {
|
|
2189
|
+
try {
|
|
2190
|
+
const fullKey = this.getFullKey(key);
|
|
2191
|
+
const effectiveTTL = ttl !== void 0 ? ttl : this.config.defaultTTL;
|
|
2192
|
+
const now = /* @__PURE__ */ new Date();
|
|
2193
|
+
const expiresAt = effectiveTTL > 0 ? new Date(now.getTime() + effectiveTTL * 1e3) : void 0;
|
|
2194
|
+
const metadata = {
|
|
2195
|
+
key: fullKey,
|
|
2196
|
+
createdAt: now,
|
|
2197
|
+
lastAccessedAt: now,
|
|
2198
|
+
accessCount: 0,
|
|
2199
|
+
size: this.estimateSize(value),
|
|
2200
|
+
ttl: effectiveTTL,
|
|
2201
|
+
expiresAt
|
|
2202
|
+
};
|
|
2203
|
+
const entry = {
|
|
2204
|
+
value,
|
|
2205
|
+
metadata
|
|
2206
|
+
};
|
|
2207
|
+
const options = effectiveTTL > 0 ? { expirationTtl: effectiveTTL } : {};
|
|
2208
|
+
await this.kv.put(fullKey, JSON.stringify(entry), options);
|
|
2209
|
+
this.logger.debug("KV cache set", { key, ttl: effectiveTTL });
|
|
2210
|
+
return Result.ok(void 0);
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
this.logger.error("KV cache set failed", { error, key });
|
|
2213
|
+
return Result.err({
|
|
2214
|
+
type: "SET_FAILED",
|
|
2215
|
+
message: `Failed to set cache entry in KV: ${error}`,
|
|
2216
|
+
cause: error
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
async delete(key) {
|
|
2221
|
+
try {
|
|
2222
|
+
const fullKey = this.getFullKey(key);
|
|
2223
|
+
const existed = await this.kv.get(fullKey);
|
|
2224
|
+
await this.kv.delete(fullKey);
|
|
2225
|
+
const wasDeleted = existed !== null;
|
|
2226
|
+
if (wasDeleted) {
|
|
2227
|
+
this.logger.debug("KV cache delete", { key });
|
|
2228
|
+
}
|
|
2229
|
+
return Result.ok(wasDeleted);
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
this.logger.error("KV cache delete failed", { error, key });
|
|
2232
|
+
return Result.err({
|
|
2233
|
+
type: "DELETE_FAILED",
|
|
2234
|
+
message: `Failed to delete cache entry from KV: ${error}`,
|
|
2235
|
+
cause: error
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
async deleteMany(keys) {
|
|
2240
|
+
try {
|
|
2241
|
+
let deleted = 0;
|
|
2242
|
+
for (const key of keys) {
|
|
2243
|
+
const result = await this.delete(key);
|
|
2244
|
+
if (result.isOk() && result.unwrap()) {
|
|
2245
|
+
deleted++;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
return Result.ok(deleted);
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
this.logger.error("KV cache deleteMany failed", { error });
|
|
2251
|
+
return Result.err({
|
|
2252
|
+
type: "DELETE_FAILED",
|
|
2253
|
+
message: `Failed to delete multiple cache entries from KV: ${error}`,
|
|
2254
|
+
cause: error
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
async clear() {
|
|
2259
|
+
try {
|
|
2260
|
+
const prefix = this.config.keyPrefix || "";
|
|
2261
|
+
let cursor;
|
|
2262
|
+
let totalDeleted = 0;
|
|
2263
|
+
do {
|
|
2264
|
+
const listResult = await this.kv.list({
|
|
2265
|
+
prefix,
|
|
2266
|
+
limit: 1e3,
|
|
2267
|
+
cursor
|
|
2268
|
+
});
|
|
2269
|
+
const deletePromises = listResult.keys.map(({ name }) => this.kv.delete(name));
|
|
2270
|
+
await Promise.all(deletePromises);
|
|
2271
|
+
totalDeleted += listResult.keys.length;
|
|
2272
|
+
cursor = listResult.list_complete ? void 0 : listResult.cursor;
|
|
2273
|
+
} while (cursor);
|
|
2274
|
+
this.logger.debug("KV cache cleared", { deleted: totalDeleted });
|
|
2275
|
+
return Result.ok(void 0);
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
this.logger.error("KV cache clear failed", { error });
|
|
2278
|
+
return Result.err({
|
|
2279
|
+
type: "CLEAR_FAILED",
|
|
2280
|
+
message: `Failed to clear KV cache: ${error}`,
|
|
2281
|
+
cause: error
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
async has(key) {
|
|
2286
|
+
try {
|
|
2287
|
+
const fullKey = this.getFullKey(key);
|
|
2288
|
+
const value = await this.kv.get(fullKey);
|
|
2289
|
+
return Result.ok(value !== null);
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
this.logger.error("KV cache has failed", { error, key });
|
|
2292
|
+
return Result.err({
|
|
2293
|
+
type: "GET_FAILED",
|
|
2294
|
+
message: `Failed to check cache key existence in KV: ${error}`,
|
|
2295
|
+
cause: error
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async invalidate(pattern) {
|
|
2300
|
+
try {
|
|
2301
|
+
const fullPattern = this.config.keyPrefix ? `${this.config.keyPrefix}${pattern}` : pattern;
|
|
2302
|
+
const prefix = fullPattern.split("*")[0].split("?")[0];
|
|
2303
|
+
const regex = this.patternToRegex(fullPattern);
|
|
2304
|
+
let invalidated = 0;
|
|
2305
|
+
let cursor;
|
|
2306
|
+
do {
|
|
2307
|
+
const listResult = await this.kv.list({
|
|
2308
|
+
prefix,
|
|
2309
|
+
limit: 1e3,
|
|
2310
|
+
cursor
|
|
2311
|
+
});
|
|
2312
|
+
const matchingKeys = listResult.keys.map(({ name }) => name).filter((name) => regex.test(name));
|
|
2313
|
+
const deletePromises = matchingKeys.map((name) => this.kv.delete(name));
|
|
2314
|
+
await Promise.all(deletePromises);
|
|
2315
|
+
invalidated += matchingKeys.length;
|
|
2316
|
+
cursor = listResult.list_complete ? void 0 : listResult.cursor;
|
|
2317
|
+
} while (cursor);
|
|
2318
|
+
this.logger.debug("KV cache invalidate", { pattern, count: invalidated });
|
|
2319
|
+
return Result.ok(invalidated);
|
|
2320
|
+
} catch (error) {
|
|
2321
|
+
this.logger.error("KV cache invalidate failed", { error, pattern });
|
|
2322
|
+
return Result.err({
|
|
2323
|
+
type: "DELETE_FAILED",
|
|
2324
|
+
message: `Failed to invalidate cache entries in KV: ${error}`,
|
|
2325
|
+
cause: error
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
async invalidateTable(table) {
|
|
2330
|
+
try {
|
|
2331
|
+
const pattern = `*${table}*`;
|
|
2332
|
+
return await this.invalidate(pattern);
|
|
2333
|
+
} catch (error) {
|
|
2334
|
+
this.logger.error("KV cache invalidateTable failed", { error, table });
|
|
2335
|
+
return Result.err({
|
|
2336
|
+
type: "DELETE_FAILED",
|
|
2337
|
+
message: `Failed to invalidate table cache in KV: ${error}`,
|
|
2338
|
+
cause: error
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
getStats() {
|
|
2343
|
+
try {
|
|
2344
|
+
const totalAccesses = this.stats.hits + this.stats.misses;
|
|
2345
|
+
const hitRate = totalAccesses > 0 ? this.stats.hits / totalAccesses : 0;
|
|
2346
|
+
return Result.ok({
|
|
2347
|
+
itemCount: 0,
|
|
2348
|
+
// Not available for KV
|
|
2349
|
+
totalSize: 0,
|
|
2350
|
+
// Not available for KV
|
|
2351
|
+
hits: this.stats.hits,
|
|
2352
|
+
misses: this.stats.misses,
|
|
2353
|
+
hitRate,
|
|
2354
|
+
evictions: 0
|
|
2355
|
+
// KV handles eviction internally
|
|
2356
|
+
});
|
|
2357
|
+
} catch (error) {
|
|
2358
|
+
return Result.err({
|
|
2359
|
+
type: "UNKNOWN",
|
|
2360
|
+
message: `Failed to get KV cache stats: ${error}`,
|
|
2361
|
+
cause: error
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
resetStats() {
|
|
2366
|
+
try {
|
|
2367
|
+
this.stats.hits = 0;
|
|
2368
|
+
this.stats.misses = 0;
|
|
2369
|
+
return Result.ok(void 0);
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
return Result.err({
|
|
2372
|
+
type: "UNKNOWN",
|
|
2373
|
+
message: `Failed to reset KV cache stats: ${error}`,
|
|
2374
|
+
cause: error
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
generateKey(sql, params) {
|
|
2379
|
+
const data = {
|
|
2380
|
+
sql,
|
|
2381
|
+
params: params || []
|
|
2382
|
+
};
|
|
2383
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
|
2384
|
+
return hash.substring(0, 16);
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Get full cache key with prefix
|
|
2388
|
+
*/
|
|
2389
|
+
getFullKey(key) {
|
|
2390
|
+
return this.config.keyPrefix ? `${this.config.keyPrefix}${key}` : key;
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Estimate size of value in bytes
|
|
2394
|
+
*/
|
|
2395
|
+
estimateSize(value) {
|
|
2396
|
+
try {
|
|
2397
|
+
return JSON.stringify(value).length;
|
|
2398
|
+
} catch {
|
|
2399
|
+
return 0;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Convert glob pattern to regex
|
|
2404
|
+
*/
|
|
2405
|
+
patternToRegex(pattern) {
|
|
2406
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
2407
|
+
return new RegExp(`^${escaped}$`);
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
var CacheInvalidator = class {
|
|
2411
|
+
cache;
|
|
2412
|
+
eventBus;
|
|
2413
|
+
logger;
|
|
2414
|
+
// Tag index: tag -> Set<key>
|
|
2415
|
+
tagIndex = /* @__PURE__ */ new Map();
|
|
2416
|
+
// Entry tags: key -> Set<tag>
|
|
2417
|
+
entryTags = /* @__PURE__ */ new Map();
|
|
2418
|
+
// Statistics
|
|
2419
|
+
stats = {
|
|
2420
|
+
totalInvalidations: 0,
|
|
2421
|
+
tagInvalidations: 0,
|
|
2422
|
+
patternInvalidations: 0,
|
|
2423
|
+
keyInvalidations: 0,
|
|
2424
|
+
expiredInvalidations: 0
|
|
2425
|
+
};
|
|
2426
|
+
constructor(cache, eventBus, logger) {
|
|
2427
|
+
this.cache = cache;
|
|
2428
|
+
this.eventBus = eventBus;
|
|
2429
|
+
this.logger = logger;
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Tag a cache entry for group invalidation
|
|
2433
|
+
*
|
|
2434
|
+
* @param key - Cache key
|
|
2435
|
+
* @param tags - Array of tags to associate with this entry
|
|
2436
|
+
* @returns Result with void on success
|
|
2437
|
+
*/
|
|
2438
|
+
async tagEntry(key, tags) {
|
|
2439
|
+
try {
|
|
2440
|
+
if (!this.entryTags.has(key)) {
|
|
2441
|
+
this.entryTags.set(key, /* @__PURE__ */ new Set());
|
|
2442
|
+
}
|
|
2443
|
+
const entryTagSet = this.entryTags.get(key);
|
|
2444
|
+
for (const tag of tags) {
|
|
2445
|
+
entryTagSet.add(tag);
|
|
2446
|
+
if (!this.tagIndex.has(tag)) {
|
|
2447
|
+
this.tagIndex.set(tag, /* @__PURE__ */ new Set());
|
|
2448
|
+
}
|
|
2449
|
+
this.tagIndex.get(tag).add(key);
|
|
2450
|
+
}
|
|
2451
|
+
this.logger.debug("Tagged cache entry", { key, tags });
|
|
2452
|
+
return Result.ok(void 0);
|
|
2453
|
+
} catch (error) {
|
|
2454
|
+
this.logger.error("Failed to tag entry", { error, key, tags });
|
|
2455
|
+
return Result.err({
|
|
2456
|
+
type: "UNKNOWN",
|
|
2457
|
+
message: `Failed to tag entry: ${error}`,
|
|
2458
|
+
cause: error
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Remove a tag from a cache entry
|
|
2464
|
+
*
|
|
2465
|
+
* @param key - Cache key
|
|
2466
|
+
* @param tag - Tag to remove
|
|
2467
|
+
* @returns Result with void on success
|
|
2468
|
+
*/
|
|
2469
|
+
async removeTag(key, tag) {
|
|
2470
|
+
try {
|
|
2471
|
+
const entryTagSet = this.entryTags.get(key);
|
|
2472
|
+
if (entryTagSet) {
|
|
2473
|
+
entryTagSet.delete(tag);
|
|
2474
|
+
if (entryTagSet.size === 0) {
|
|
2475
|
+
this.entryTags.delete(key);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
const tagSet = this.tagIndex.get(tag);
|
|
2479
|
+
if (tagSet) {
|
|
2480
|
+
tagSet.delete(key);
|
|
2481
|
+
if (tagSet.size === 0) {
|
|
2482
|
+
this.tagIndex.delete(tag);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
this.logger.debug("Removed tag from entry", { key, tag });
|
|
2486
|
+
return Result.ok(void 0);
|
|
2487
|
+
} catch (error) {
|
|
2488
|
+
this.logger.error("Failed to remove tag", { error, key, tag });
|
|
2489
|
+
return Result.err({
|
|
2490
|
+
type: "UNKNOWN",
|
|
2491
|
+
message: `Failed to remove tag: ${error}`,
|
|
2492
|
+
cause: error
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Invalidate all entries with a specific tag
|
|
2498
|
+
*
|
|
2499
|
+
* @param tag - Tag to invalidate (supports wildcards)
|
|
2500
|
+
* @returns Result with number of invalidated entries
|
|
2501
|
+
*/
|
|
2502
|
+
async invalidateByTag(tag) {
|
|
2503
|
+
try {
|
|
2504
|
+
const keysToInvalidate = /* @__PURE__ */ new Set();
|
|
2505
|
+
if (tag.includes("*") || tag.includes("?")) {
|
|
2506
|
+
const regex = this.patternToRegex(tag);
|
|
2507
|
+
for (const [tagName, keys] of this.tagIndex.entries()) {
|
|
2508
|
+
if (regex.test(tagName)) {
|
|
2509
|
+
for (const key of keys) {
|
|
2510
|
+
keysToInvalidate.add(key);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
} else {
|
|
2515
|
+
const keys = this.tagIndex.get(tag);
|
|
2516
|
+
if (keys) {
|
|
2517
|
+
for (const key of keys) {
|
|
2518
|
+
keysToInvalidate.add(key);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
let invalidated = 0;
|
|
2523
|
+
for (const key of keysToInvalidate) {
|
|
2524
|
+
const result = await this.cache.delete(key);
|
|
2525
|
+
if (result.isOk() && result.unwrap()) {
|
|
2526
|
+
invalidated++;
|
|
2527
|
+
await this.cleanupEntryTags(key);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
this.stats.totalInvalidations += invalidated;
|
|
2531
|
+
this.stats.tagInvalidations += invalidated;
|
|
2532
|
+
this.logger.debug("Invalidated by tag", { tag, count: invalidated });
|
|
2533
|
+
await this.eventBus.emit("cache:invalidated", {
|
|
2534
|
+
type: "tag",
|
|
2535
|
+
tag,
|
|
2536
|
+
count: invalidated
|
|
2537
|
+
});
|
|
2538
|
+
return Result.ok(invalidated);
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
this.logger.error("Failed to invalidate by tag", { error, tag });
|
|
2541
|
+
return Result.err({
|
|
2542
|
+
type: "INVALIDATION_FAILED",
|
|
2543
|
+
message: `Failed to invalidate by tag: ${error}`,
|
|
2544
|
+
cause: error
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Invalidate entries by multiple tags
|
|
2550
|
+
*
|
|
2551
|
+
* @param tags - Array of tags to invalidate
|
|
2552
|
+
* @returns Result with number of invalidated entries
|
|
2553
|
+
*/
|
|
2554
|
+
async invalidateByTags(tags) {
|
|
2555
|
+
try {
|
|
2556
|
+
const keysToInvalidate = /* @__PURE__ */ new Set();
|
|
2557
|
+
for (const tag of tags) {
|
|
2558
|
+
const result = await this.getTaggedKeys(tag);
|
|
2559
|
+
if (result.isOk()) {
|
|
2560
|
+
const keys = result.unwrap();
|
|
2561
|
+
for (const key of keys) {
|
|
2562
|
+
keysToInvalidate.add(key);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
let invalidated = 0;
|
|
2567
|
+
for (const key of keysToInvalidate) {
|
|
2568
|
+
const result = await this.cache.delete(key);
|
|
2569
|
+
if (result.isOk() && result.unwrap()) {
|
|
2570
|
+
invalidated++;
|
|
2571
|
+
await this.cleanupEntryTags(key);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
this.stats.totalInvalidations += invalidated;
|
|
2575
|
+
this.stats.tagInvalidations += invalidated;
|
|
2576
|
+
this.logger.debug("Invalidated by tags", { tags, count: invalidated });
|
|
2577
|
+
return Result.ok(invalidated);
|
|
2578
|
+
} catch (error) {
|
|
2579
|
+
this.logger.error("Failed to invalidate by tags", { error, tags });
|
|
2580
|
+
return Result.err({
|
|
2581
|
+
type: "INVALIDATION_FAILED",
|
|
2582
|
+
message: `Failed to invalidate by tags: ${error}`,
|
|
2583
|
+
cause: error
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Invalidate specific cache keys
|
|
2589
|
+
*
|
|
2590
|
+
* @param keys - Array of keys to invalidate
|
|
2591
|
+
* @returns Result with number of invalidated entries
|
|
2592
|
+
*/
|
|
2593
|
+
async invalidateByKeys(keys) {
|
|
2594
|
+
try {
|
|
2595
|
+
let invalidated = 0;
|
|
2596
|
+
for (const key of keys) {
|
|
2597
|
+
const result = await this.cache.delete(key);
|
|
2598
|
+
if (result.isOk() && result.unwrap()) {
|
|
2599
|
+
invalidated++;
|
|
2600
|
+
await this.cleanupEntryTags(key);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
this.stats.totalInvalidations += invalidated;
|
|
2604
|
+
this.stats.keyInvalidations += invalidated;
|
|
2605
|
+
this.logger.debug("Invalidated by keys", { count: invalidated });
|
|
2606
|
+
return Result.ok(invalidated);
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
this.logger.error("Failed to invalidate by keys", { error, keys });
|
|
2609
|
+
return Result.err({
|
|
2610
|
+
type: "INVALIDATION_FAILED",
|
|
2611
|
+
message: `Failed to invalidate by keys: ${error}`,
|
|
2612
|
+
cause: error
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Invalidate entries matching a pattern
|
|
2618
|
+
*
|
|
2619
|
+
* @param pattern - Pattern to match (supports wildcards)
|
|
2620
|
+
* @returns Result with number of invalidated entries
|
|
2621
|
+
*/
|
|
2622
|
+
async invalidateByPattern(pattern) {
|
|
2623
|
+
try {
|
|
2624
|
+
const result = await this.cache.invalidate(pattern);
|
|
2625
|
+
if (result.isOk()) {
|
|
2626
|
+
const count = result.unwrap();
|
|
2627
|
+
this.stats.totalInvalidations += count;
|
|
2628
|
+
this.stats.patternInvalidations += count;
|
|
2629
|
+
}
|
|
2630
|
+
return result;
|
|
2631
|
+
} catch (error) {
|
|
2632
|
+
this.logger.error("Failed to invalidate by pattern", { error, pattern });
|
|
2633
|
+
return Result.err({
|
|
2634
|
+
type: "INVALIDATION_FAILED",
|
|
2635
|
+
message: `Failed to invalidate by pattern: ${error}`,
|
|
2636
|
+
cause: error
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Scan and invalidate expired entries
|
|
2642
|
+
*
|
|
2643
|
+
* @returns Result with number of invalidated entries
|
|
2644
|
+
*/
|
|
2645
|
+
async invalidateExpired() {
|
|
2646
|
+
try {
|
|
2647
|
+
let invalidated = 0;
|
|
2648
|
+
const allKeys = Array.from(this.entryTags.keys());
|
|
2649
|
+
for (const key of allKeys) {
|
|
2650
|
+
const result = await this.cache.get(key);
|
|
2651
|
+
if (result.isOk() && result.unwrap().isNone()) {
|
|
2652
|
+
await this.cleanupEntryTags(key);
|
|
2653
|
+
invalidated++;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
this.stats.totalInvalidations += invalidated;
|
|
2657
|
+
this.stats.expiredInvalidations += invalidated;
|
|
2658
|
+
this.logger.debug("Invalidated expired entries", { count: invalidated });
|
|
2659
|
+
return Result.ok(invalidated);
|
|
2660
|
+
} catch (error) {
|
|
2661
|
+
this.logger.error("Failed to invalidate expired entries", { error });
|
|
2662
|
+
return Result.err({
|
|
2663
|
+
type: "INVALIDATION_FAILED",
|
|
2664
|
+
message: `Failed to invalidate expired entries: ${error}`,
|
|
2665
|
+
cause: error
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Subscribe to data change events for automatic invalidation
|
|
2671
|
+
*
|
|
2672
|
+
* @returns Unsubscribe function
|
|
2673
|
+
*/
|
|
2674
|
+
subscribeToDataChanges() {
|
|
2675
|
+
const unsubscribers = [];
|
|
2676
|
+
unsubscribers.push(
|
|
2677
|
+
this.eventBus.subscribe("*", async (data) => {
|
|
2678
|
+
})
|
|
2679
|
+
);
|
|
2680
|
+
const cacheInvalidateHandler = async (data) => {
|
|
2681
|
+
if (data && data.tag) {
|
|
2682
|
+
await this.invalidateByTag(data.tag);
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
const events = [
|
|
2686
|
+
"cache:invalidate:users",
|
|
2687
|
+
"cache:invalidate:posts",
|
|
2688
|
+
"cache:invalidate:comments"
|
|
2689
|
+
];
|
|
2690
|
+
for (const event of events) {
|
|
2691
|
+
unsubscribers.push(this.eventBus.subscribe(event, cacheInvalidateHandler));
|
|
2692
|
+
}
|
|
2693
|
+
unsubscribers.push(
|
|
2694
|
+
this.eventBus.subscribe("cache:invalidate:batch", async (data) => {
|
|
2695
|
+
if (data && data.tags) {
|
|
2696
|
+
await this.invalidateByTags(data.tags);
|
|
2697
|
+
}
|
|
2698
|
+
})
|
|
2699
|
+
);
|
|
2700
|
+
const dataUpdateHandler = async (data) => {
|
|
2701
|
+
if (data && data.table) {
|
|
2702
|
+
await this.cache.invalidateTable(data.table);
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
const dataEvents = ["data:updated:users", "data:updated:posts"];
|
|
2706
|
+
for (const event of dataEvents) {
|
|
2707
|
+
unsubscribers.push(this.eventBus.subscribe(event, dataUpdateHandler));
|
|
2708
|
+
}
|
|
2709
|
+
return () => {
|
|
2710
|
+
for (const unsubscribe of unsubscribers) {
|
|
2711
|
+
unsubscribe();
|
|
2712
|
+
}
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
/**
|
|
2716
|
+
* Get all keys tagged with a specific tag
|
|
2717
|
+
*
|
|
2718
|
+
* @param tag - Tag name
|
|
2719
|
+
* @returns Result with array of keys
|
|
2720
|
+
*/
|
|
2721
|
+
async getTaggedKeys(tag) {
|
|
2722
|
+
try {
|
|
2723
|
+
const keys = this.tagIndex.get(tag);
|
|
2724
|
+
if (!keys) {
|
|
2725
|
+
return Result.ok([]);
|
|
2726
|
+
}
|
|
2727
|
+
return Result.ok(Array.from(keys));
|
|
2728
|
+
} catch (error) {
|
|
2729
|
+
return Result.err({
|
|
2730
|
+
type: "UNKNOWN",
|
|
2731
|
+
message: `Failed to get tagged keys: ${error}`,
|
|
2732
|
+
cause: error
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Get all tags for a cache entry
|
|
2738
|
+
*
|
|
2739
|
+
* @param key - Cache key
|
|
2740
|
+
* @returns Result with array of tags
|
|
2741
|
+
*/
|
|
2742
|
+
async getEntryTags(key) {
|
|
2743
|
+
try {
|
|
2744
|
+
const tags = this.entryTags.get(key);
|
|
2745
|
+
if (!tags) {
|
|
2746
|
+
return Result.ok([]);
|
|
2747
|
+
}
|
|
2748
|
+
return Result.ok(Array.from(tags));
|
|
2749
|
+
} catch (error) {
|
|
2750
|
+
return Result.err({
|
|
2751
|
+
type: "UNKNOWN",
|
|
2752
|
+
message: `Failed to get entry tags: ${error}`,
|
|
2753
|
+
cause: error
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Get invalidation statistics
|
|
2759
|
+
*
|
|
2760
|
+
* @returns Result with statistics
|
|
2761
|
+
*/
|
|
2762
|
+
getStats() {
|
|
2763
|
+
try {
|
|
2764
|
+
return Result.ok({ ...this.stats });
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
return Result.err({
|
|
2767
|
+
type: "UNKNOWN",
|
|
2768
|
+
message: `Failed to get stats: ${error}`,
|
|
2769
|
+
cause: error
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* Reset invalidation statistics
|
|
2775
|
+
*
|
|
2776
|
+
* @returns Result with void on success
|
|
2777
|
+
*/
|
|
2778
|
+
resetStats() {
|
|
2779
|
+
try {
|
|
2780
|
+
this.stats = {
|
|
2781
|
+
totalInvalidations: 0,
|
|
2782
|
+
tagInvalidations: 0,
|
|
2783
|
+
patternInvalidations: 0,
|
|
2784
|
+
keyInvalidations: 0,
|
|
2785
|
+
expiredInvalidations: 0
|
|
2786
|
+
};
|
|
2787
|
+
return Result.ok(void 0);
|
|
2788
|
+
} catch (error) {
|
|
2789
|
+
return Result.err({
|
|
2790
|
+
type: "UNKNOWN",
|
|
2791
|
+
message: `Failed to reset stats: ${error}`,
|
|
2792
|
+
cause: error
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Clean up tag mappings for a deleted entry
|
|
2798
|
+
*/
|
|
2799
|
+
async cleanupEntryTags(key) {
|
|
2800
|
+
const tags = this.entryTags.get(key);
|
|
2801
|
+
if (tags) {
|
|
2802
|
+
for (const tag of tags) {
|
|
2803
|
+
const tagSet = this.tagIndex.get(tag);
|
|
2804
|
+
if (tagSet) {
|
|
2805
|
+
tagSet.delete(key);
|
|
2806
|
+
if (tagSet.size === 0) {
|
|
2807
|
+
this.tagIndex.delete(tag);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
this.entryTags.delete(key);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* Convert glob pattern to regex
|
|
2816
|
+
*/
|
|
2817
|
+
patternToRegex(pattern) {
|
|
2818
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
2819
|
+
return new RegExp(`^${escaped}$`);
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
var MigrationLoader = class {
|
|
2823
|
+
config;
|
|
2824
|
+
logger;
|
|
2825
|
+
filePattern;
|
|
2826
|
+
constructor(config, logger) {
|
|
2827
|
+
this.config = config;
|
|
2828
|
+
this.logger = logger;
|
|
2829
|
+
this.filePattern = config.filePattern || /^(\d+|V\d+)[_-].*\.ts$/;
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Load all migrations from the configured directory
|
|
2833
|
+
*
|
|
2834
|
+
* @returns Result with array of migrations ordered by version
|
|
2835
|
+
*/
|
|
2836
|
+
async loadMigrations() {
|
|
2837
|
+
try {
|
|
2838
|
+
if (!fs.existsSync(this.config.migrationsPath)) {
|
|
2839
|
+
return Result.err({
|
|
2840
|
+
type: "INVALID_MIGRATION",
|
|
2841
|
+
message: `Migrations directory does not exist: ${this.config.migrationsPath}`
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
const files = fs.readdirSync(this.config.migrationsPath);
|
|
2845
|
+
const migrationFiles = files.filter((file) => this.filePattern.test(file));
|
|
2846
|
+
this.logger.debug("Found migration files", {
|
|
2847
|
+
total: files.length,
|
|
2848
|
+
migrations: migrationFiles.length
|
|
2849
|
+
});
|
|
2850
|
+
const migrations = [];
|
|
2851
|
+
for (const file of migrationFiles) {
|
|
2852
|
+
const filePath = path.join(this.config.migrationsPath, file);
|
|
2853
|
+
const result = await this.loadMigrationFile(filePath);
|
|
2854
|
+
if (result.isErr()) {
|
|
2855
|
+
return result;
|
|
2856
|
+
}
|
|
2857
|
+
migrations.push(result.unwrap());
|
|
2858
|
+
}
|
|
2859
|
+
const duplicates = this.findDuplicateVersions(migrations);
|
|
2860
|
+
if (duplicates.length > 0) {
|
|
2861
|
+
return Result.err({
|
|
2862
|
+
type: "INVALID_MIGRATION",
|
|
2863
|
+
message: `Duplicate migration versions found: ${duplicates.join(", ")}`
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
migrations.sort((a, b) => a.version - b.version);
|
|
2867
|
+
this.logger.debug("Loaded migrations", { count: migrations.length });
|
|
2868
|
+
return Result.ok(migrations);
|
|
2869
|
+
} catch (error) {
|
|
2870
|
+
this.logger.error("Failed to load migrations", { error });
|
|
2871
|
+
return Result.err({
|
|
2872
|
+
type: "UNKNOWN",
|
|
2873
|
+
message: `Failed to load migrations: ${error}`,
|
|
2874
|
+
cause: error
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Load a single migration file
|
|
2880
|
+
*
|
|
2881
|
+
* @param filePath - Path to migration file
|
|
2882
|
+
* @returns Result with Migration or MigrationError
|
|
2883
|
+
*/
|
|
2884
|
+
async loadMigrationFile(filePath) {
|
|
2885
|
+
try {
|
|
2886
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
2887
|
+
const fileUrl = `file://${filePath.replace(/\\/g, "/")}`;
|
|
2888
|
+
const module = await import(fileUrl);
|
|
2889
|
+
const validation = this.validateMigration(module, filePath);
|
|
2890
|
+
if (validation.isErr()) {
|
|
2891
|
+
return validation;
|
|
2892
|
+
}
|
|
2893
|
+
const { version: version2, name, up, down } = module;
|
|
2894
|
+
const id = this.generateMigrationId(version2, name);
|
|
2895
|
+
const checksum = this.computeChecksum(content);
|
|
2896
|
+
const migration = {
|
|
2897
|
+
id,
|
|
2898
|
+
version: version2,
|
|
2899
|
+
name,
|
|
2900
|
+
up,
|
|
2901
|
+
down,
|
|
2902
|
+
checksum
|
|
2903
|
+
};
|
|
2904
|
+
this.logger.debug("Loaded migration", { id, version: version2, name });
|
|
2905
|
+
return Result.ok(migration);
|
|
2906
|
+
} catch (error) {
|
|
2907
|
+
this.logger.error("Failed to load migration file", { error, filePath });
|
|
2908
|
+
return Result.err({
|
|
2909
|
+
type: "INVALID_MIGRATION",
|
|
2910
|
+
message: `Failed to load migration file ${filePath}: ${error}`,
|
|
2911
|
+
cause: error
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Validate migration module structure
|
|
2917
|
+
*
|
|
2918
|
+
* @param module - Migration module
|
|
2919
|
+
* @param filePath - File path for error messages
|
|
2920
|
+
* @returns Result with void on success or MigrationError
|
|
2921
|
+
*/
|
|
2922
|
+
validateMigration(module, filePath) {
|
|
2923
|
+
if (module.version === void 0) {
|
|
2924
|
+
return Result.err({
|
|
2925
|
+
type: "INVALID_MIGRATION",
|
|
2926
|
+
message: `Migration ${filePath} is missing 'version' export`
|
|
2927
|
+
});
|
|
2928
|
+
}
|
|
2929
|
+
if (typeof module.version !== "number") {
|
|
2930
|
+
return Result.err({
|
|
2931
|
+
type: "INVALID_MIGRATION",
|
|
2932
|
+
message: `Migration ${filePath} has invalid version type (must be number)`
|
|
2933
|
+
});
|
|
2934
|
+
}
|
|
2935
|
+
if (module.version < 0) {
|
|
2936
|
+
return Result.err({
|
|
2937
|
+
type: "INVALID_MIGRATION",
|
|
2938
|
+
message: `Migration ${filePath} has negative version number`
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
if (!module.name) {
|
|
2942
|
+
return Result.err({
|
|
2943
|
+
type: "INVALID_MIGRATION",
|
|
2944
|
+
message: `Migration ${filePath} is missing 'name' export`
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
if (!module.up || typeof module.up !== "function") {
|
|
2948
|
+
return Result.err({
|
|
2949
|
+
type: "INVALID_MIGRATION",
|
|
2950
|
+
message: `Migration ${filePath} is missing 'up' function`
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
if (!module.down || typeof module.down !== "function") {
|
|
2954
|
+
return Result.err({
|
|
2955
|
+
type: "INVALID_MIGRATION",
|
|
2956
|
+
message: `Migration ${filePath} is missing 'down' function`
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
return Result.ok(void 0);
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* Find duplicate version numbers in migrations
|
|
2963
|
+
*
|
|
2964
|
+
* @param migrations - Array of migrations
|
|
2965
|
+
* @returns Array of duplicate version numbers
|
|
2966
|
+
*/
|
|
2967
|
+
findDuplicateVersions(migrations) {
|
|
2968
|
+
const versionCounts = /* @__PURE__ */ new Map();
|
|
2969
|
+
for (const migration of migrations) {
|
|
2970
|
+
const count = versionCounts.get(migration.version) || 0;
|
|
2971
|
+
versionCounts.set(migration.version, count + 1);
|
|
2972
|
+
}
|
|
2973
|
+
const duplicates = [];
|
|
2974
|
+
for (const [version2, count] of versionCounts.entries()) {
|
|
2975
|
+
if (count > 1) {
|
|
2976
|
+
duplicates.push(version2);
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
return duplicates;
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* Generate unique migration ID from version and name
|
|
2983
|
+
*
|
|
2984
|
+
* @param version - Migration version
|
|
2985
|
+
* @param name - Migration name
|
|
2986
|
+
* @returns Migration ID
|
|
2987
|
+
*/
|
|
2988
|
+
generateMigrationId(version2, name) {
|
|
2989
|
+
return `${version2}_${name}`;
|
|
2990
|
+
}
|
|
2991
|
+
/**
|
|
2992
|
+
* Compute SHA-256 checksum of migration content
|
|
2993
|
+
*
|
|
2994
|
+
* @param content - Migration file content
|
|
2995
|
+
* @returns Checksum string
|
|
2996
|
+
*/
|
|
2997
|
+
computeChecksum(content) {
|
|
2998
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Get SQL schema for migration tracking table
|
|
3002
|
+
*
|
|
3003
|
+
* @returns SQL CREATE TABLE statement
|
|
3004
|
+
*/
|
|
3005
|
+
getMigrationTableSchema() {
|
|
3006
|
+
const tableName = this.config.migrationTable;
|
|
3007
|
+
return `
|
|
3008
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
3009
|
+
id TEXT PRIMARY KEY,
|
|
3010
|
+
version INTEGER NOT NULL UNIQUE,
|
|
3011
|
+
name TEXT NOT NULL,
|
|
3012
|
+
checksum TEXT,
|
|
3013
|
+
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
3014
|
+
executed_at TIMESTAMP,
|
|
3015
|
+
execution_time INTEGER,
|
|
3016
|
+
error TEXT
|
|
3017
|
+
);
|
|
3018
|
+
|
|
3019
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_version ON ${tableName}(version);
|
|
3020
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_status ON ${tableName}(status);
|
|
3021
|
+
`.trim();
|
|
3022
|
+
}
|
|
3023
|
+
};
|
|
3024
|
+
var MigrationRunner = class {
|
|
3025
|
+
connection;
|
|
3026
|
+
loader;
|
|
3027
|
+
config;
|
|
3028
|
+
logger;
|
|
3029
|
+
constructor(connection, loader, config, logger) {
|
|
3030
|
+
this.connection = connection;
|
|
3031
|
+
this.loader = loader;
|
|
3032
|
+
this.config = config;
|
|
3033
|
+
this.logger = logger;
|
|
3034
|
+
}
|
|
3035
|
+
/**
|
|
3036
|
+
* Create migration table if it doesn't exist
|
|
3037
|
+
*/
|
|
3038
|
+
async createMigrationTable() {
|
|
3039
|
+
try {
|
|
3040
|
+
const schema = this.loader.getMigrationTableSchema();
|
|
3041
|
+
const result = await this.connection.execute(schema);
|
|
3042
|
+
if (result.isErr()) {
|
|
3043
|
+
return Result.err({
|
|
3044
|
+
type: "UNKNOWN",
|
|
3045
|
+
message: "Failed to create migration table",
|
|
3046
|
+
cause: result.variant.error
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
this.logger.debug("Migration table created");
|
|
3050
|
+
return Result.ok(void 0);
|
|
3051
|
+
} catch (error) {
|
|
3052
|
+
this.logger.error("Failed to create migration table", { error });
|
|
3053
|
+
return Result.err({
|
|
3054
|
+
type: "UNKNOWN",
|
|
3055
|
+
message: `Failed to create migration table: ${error}`,
|
|
3056
|
+
cause: error
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
3061
|
+
* Get all available migrations
|
|
3062
|
+
*/
|
|
3063
|
+
async getMigrations() {
|
|
3064
|
+
return this.loader.loadMigrations();
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Get migration history from database
|
|
3068
|
+
*/
|
|
3069
|
+
async getHistory() {
|
|
3070
|
+
try {
|
|
3071
|
+
const query = `SELECT * FROM ${this.config.migrationTable} ORDER BY version DESC`;
|
|
3072
|
+
const result = await this.connection.query(query);
|
|
3073
|
+
if (result.isErr()) {
|
|
3074
|
+
return Result.err({
|
|
3075
|
+
type: "UNKNOWN",
|
|
3076
|
+
message: "Failed to get migration history",
|
|
3077
|
+
cause: result.variant.error
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
const rows = result.unwrap();
|
|
3081
|
+
const history = rows.map((row) => ({
|
|
3082
|
+
id: row.id,
|
|
3083
|
+
version: row.version,
|
|
3084
|
+
name: row.name,
|
|
3085
|
+
status: row.status,
|
|
3086
|
+
executedAt: row.executed_at ? new Date(row.executed_at) : void 0,
|
|
3087
|
+
executionTime: row.execution_time,
|
|
3088
|
+
error: row.error,
|
|
3089
|
+
checksum: row.checksum
|
|
3090
|
+
}));
|
|
3091
|
+
return Result.ok(history);
|
|
3092
|
+
} catch (error) {
|
|
3093
|
+
this.logger.error("Failed to get migration history", { error });
|
|
3094
|
+
return Result.err({
|
|
3095
|
+
type: "UNKNOWN",
|
|
3096
|
+
message: `Failed to get migration history: ${error}`,
|
|
3097
|
+
cause: error
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Get pending migrations
|
|
3103
|
+
*/
|
|
3104
|
+
async getPending() {
|
|
3105
|
+
try {
|
|
3106
|
+
const migrationsResult = await this.getMigrations();
|
|
3107
|
+
if (migrationsResult.isErr()) {
|
|
3108
|
+
return migrationsResult;
|
|
3109
|
+
}
|
|
3110
|
+
const allMigrations = migrationsResult.unwrap();
|
|
3111
|
+
const historyResult = await this.getHistory();
|
|
3112
|
+
if (historyResult.isErr()) {
|
|
3113
|
+
return Result.err(historyResult.variant.error);
|
|
3114
|
+
}
|
|
3115
|
+
const history = historyResult.unwrap();
|
|
3116
|
+
const appliedVersions = new Set(
|
|
3117
|
+
history.filter((h) => h.status === "COMPLETED").map((h) => h.version)
|
|
3118
|
+
);
|
|
3119
|
+
const pending = allMigrations.filter((m) => !appliedVersions.has(m.version));
|
|
3120
|
+
return Result.ok(pending);
|
|
3121
|
+
} catch (error) {
|
|
3122
|
+
this.logger.error("Failed to get pending migrations", { error });
|
|
3123
|
+
return Result.err({
|
|
3124
|
+
type: "UNKNOWN",
|
|
3125
|
+
message: `Failed to get pending migrations: ${error}`,
|
|
3126
|
+
cause: error
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Run all pending migrations
|
|
3132
|
+
*/
|
|
3133
|
+
async migrate() {
|
|
3134
|
+
try {
|
|
3135
|
+
if (this.config.validateChecksums) {
|
|
3136
|
+
const validationResult = await this.validateChecksums();
|
|
3137
|
+
if (validationResult.isErr()) {
|
|
3138
|
+
return Result.err(validationResult.variant.error);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
const pendingResult = await this.getPending();
|
|
3142
|
+
if (pendingResult.isErr()) {
|
|
3143
|
+
return Result.err(pendingResult.variant.error);
|
|
3144
|
+
}
|
|
3145
|
+
const pending = pendingResult.unwrap();
|
|
3146
|
+
const applied = [];
|
|
3147
|
+
for (const migration of pending) {
|
|
3148
|
+
const result = await this.runMigration(migration, "UP");
|
|
3149
|
+
if (result.isErr()) {
|
|
3150
|
+
return Result.err(result.variant.error);
|
|
3151
|
+
}
|
|
3152
|
+
applied.push(result.unwrap());
|
|
3153
|
+
}
|
|
3154
|
+
this.logger.info("Migrations completed", { count: applied.length });
|
|
3155
|
+
return Result.ok(applied);
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
this.logger.error("Migration failed", { error });
|
|
3158
|
+
return Result.err({
|
|
3159
|
+
type: "UNKNOWN",
|
|
3160
|
+
message: `Migration failed: ${error}`,
|
|
3161
|
+
cause: error
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Run migrations up to a specific version
|
|
3167
|
+
*/
|
|
3168
|
+
async migrateTo(targetVersion) {
|
|
3169
|
+
try {
|
|
3170
|
+
const migrationsResult = await this.getMigrations();
|
|
3171
|
+
if (migrationsResult.isErr()) {
|
|
3172
|
+
return Result.err(migrationsResult.variant.error);
|
|
3173
|
+
}
|
|
3174
|
+
const allMigrations = migrationsResult.unwrap();
|
|
3175
|
+
const targetMigration = allMigrations.find((m) => m.version === targetVersion);
|
|
3176
|
+
if (!targetMigration) {
|
|
3177
|
+
return Result.err({
|
|
3178
|
+
type: "MIGRATION_NOT_FOUND",
|
|
3179
|
+
message: `Migration version ${targetVersion} not found`,
|
|
3180
|
+
migrationId: `${targetVersion}`
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
const pendingResult = await this.getPending();
|
|
3184
|
+
if (pendingResult.isErr()) {
|
|
3185
|
+
return Result.err(pendingResult.variant.error);
|
|
3186
|
+
}
|
|
3187
|
+
const pending = pendingResult.unwrap();
|
|
3188
|
+
const toApply = pending.filter((m) => m.version <= targetVersion);
|
|
3189
|
+
const applied = [];
|
|
3190
|
+
for (const migration of toApply) {
|
|
3191
|
+
const result = await this.runMigration(migration, "UP");
|
|
3192
|
+
if (result.isErr()) {
|
|
3193
|
+
return Result.err(result.variant.error);
|
|
3194
|
+
}
|
|
3195
|
+
applied.push(result.unwrap());
|
|
3196
|
+
}
|
|
3197
|
+
this.logger.info("Migrated to version", { version: targetVersion });
|
|
3198
|
+
return Result.ok(applied);
|
|
3199
|
+
} catch (error) {
|
|
3200
|
+
this.logger.error("Migration to version failed", { error, targetVersion });
|
|
3201
|
+
return Result.err({
|
|
3202
|
+
type: "UNKNOWN",
|
|
3203
|
+
message: `Migration to version ${targetVersion} failed: ${error}`,
|
|
3204
|
+
cause: error
|
|
3205
|
+
});
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Rollback the last migration
|
|
3210
|
+
*/
|
|
3211
|
+
async rollback() {
|
|
3212
|
+
try {
|
|
3213
|
+
const historyResult = await this.getHistory();
|
|
3214
|
+
if (historyResult.isErr()) {
|
|
3215
|
+
return Result.err(historyResult.variant.error);
|
|
3216
|
+
}
|
|
3217
|
+
const history = historyResult.unwrap();
|
|
3218
|
+
const completed = history.filter((h) => h.status === "COMPLETED");
|
|
3219
|
+
if (completed.length === 0) {
|
|
3220
|
+
return Result.err({
|
|
3221
|
+
type: "NOT_APPLIED",
|
|
3222
|
+
message: "No migrations to rollback",
|
|
3223
|
+
migrationId: ""
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
const lastMigration = completed[0];
|
|
3227
|
+
const migrationsResult = await this.getMigrations();
|
|
3228
|
+
if (migrationsResult.isErr()) {
|
|
3229
|
+
return Result.err(migrationsResult.variant.error);
|
|
3230
|
+
}
|
|
3231
|
+
const migrations = migrationsResult.unwrap();
|
|
3232
|
+
const migration = migrations.find((m) => m.id === lastMigration.id);
|
|
3233
|
+
if (!migration) {
|
|
3234
|
+
return Result.err({
|
|
3235
|
+
type: "MIGRATION_NOT_FOUND",
|
|
3236
|
+
message: `Migration ${lastMigration.id} not found`,
|
|
3237
|
+
migrationId: lastMigration.id
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
const result = await this.runMigration(migration, "DOWN");
|
|
3241
|
+
if (result.isErr()) {
|
|
3242
|
+
return Result.err(result.variant.error);
|
|
3243
|
+
}
|
|
3244
|
+
this.logger.info("Rolled back migration", { id: migration.id });
|
|
3245
|
+
return Result.ok(result.unwrap());
|
|
3246
|
+
} catch (error) {
|
|
3247
|
+
this.logger.error("Rollback failed", { error });
|
|
3248
|
+
return Result.err({
|
|
3249
|
+
type: "UNKNOWN",
|
|
3250
|
+
message: `Rollback failed: ${error}`,
|
|
3251
|
+
cause: error
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
/**
|
|
3256
|
+
* Rollback to a specific version
|
|
3257
|
+
*/
|
|
3258
|
+
async rollbackTo(targetVersion) {
|
|
3259
|
+
try {
|
|
3260
|
+
const historyResult = await this.getHistory();
|
|
3261
|
+
if (historyResult.isErr()) {
|
|
3262
|
+
return Result.err(historyResult.variant.error);
|
|
3263
|
+
}
|
|
3264
|
+
const history = historyResult.unwrap();
|
|
3265
|
+
const completed = history.filter((h) => h.status === "COMPLETED" && h.version > targetVersion).sort((a, b) => b.version - a.version);
|
|
3266
|
+
const migrationsResult = await this.getMigrations();
|
|
3267
|
+
if (migrationsResult.isErr()) {
|
|
3268
|
+
return Result.err(migrationsResult.variant.error);
|
|
3269
|
+
}
|
|
3270
|
+
const allMigrations = migrationsResult.unwrap();
|
|
3271
|
+
const rolledBack = [];
|
|
3272
|
+
for (const historyEntry of completed) {
|
|
3273
|
+
const migration = allMigrations.find((m) => m.id === historyEntry.id);
|
|
3274
|
+
if (!migration) {
|
|
3275
|
+
return Result.err({
|
|
3276
|
+
type: "MIGRATION_NOT_FOUND",
|
|
3277
|
+
message: `Migration ${historyEntry.id} not found`,
|
|
3278
|
+
migrationId: historyEntry.id
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
const result = await this.runMigration(migration, "DOWN");
|
|
3282
|
+
if (result.isErr()) {
|
|
3283
|
+
return Result.err(result.variant.error);
|
|
3284
|
+
}
|
|
3285
|
+
rolledBack.push(result.unwrap());
|
|
3286
|
+
}
|
|
3287
|
+
this.logger.info("Rolled back to version", { version: targetVersion });
|
|
3288
|
+
return Result.ok(rolledBack);
|
|
3289
|
+
} catch (error) {
|
|
3290
|
+
this.logger.error("Rollback to version failed", { error, targetVersion });
|
|
3291
|
+
return Result.err({
|
|
3292
|
+
type: "UNKNOWN",
|
|
3293
|
+
message: `Rollback to version ${targetVersion} failed: ${error}`,
|
|
3294
|
+
cause: error
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
/**
|
|
3299
|
+
* Reset database by rolling back all migrations
|
|
3300
|
+
*/
|
|
3301
|
+
async reset() {
|
|
3302
|
+
return this.rollbackTo(0);
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Refresh database by resetting and re-running all migrations
|
|
3306
|
+
*/
|
|
3307
|
+
async refresh() {
|
|
3308
|
+
try {
|
|
3309
|
+
const resetResult = await this.reset();
|
|
3310
|
+
if (resetResult.isErr()) {
|
|
3311
|
+
return Result.err(resetResult.variant.error);
|
|
3312
|
+
}
|
|
3313
|
+
const rolledBack = resetResult.unwrap();
|
|
3314
|
+
const migrateResult = await this.migrate();
|
|
3315
|
+
if (migrateResult.isErr()) {
|
|
3316
|
+
return Result.err(migrateResult.variant.error);
|
|
3317
|
+
}
|
|
3318
|
+
const applied = migrateResult.unwrap();
|
|
3319
|
+
this.logger.info("Database refreshed", {
|
|
3320
|
+
rolledBack: rolledBack.length,
|
|
3321
|
+
applied: applied.length
|
|
3322
|
+
});
|
|
3323
|
+
return Result.ok({ rolledBack, applied });
|
|
3324
|
+
} catch (error) {
|
|
3325
|
+
this.logger.error("Refresh failed", { error });
|
|
3326
|
+
return Result.err({
|
|
3327
|
+
type: "UNKNOWN",
|
|
3328
|
+
message: `Refresh failed: ${error}`,
|
|
3329
|
+
cause: error
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/**
|
|
3334
|
+
* Get current migration version
|
|
3335
|
+
*/
|
|
3336
|
+
async getCurrentVersion() {
|
|
3337
|
+
try {
|
|
3338
|
+
const historyResult = await this.getHistory();
|
|
3339
|
+
if (historyResult.isErr()) {
|
|
3340
|
+
return Result.err(historyResult.variant.error);
|
|
3341
|
+
}
|
|
3342
|
+
const history = historyResult.unwrap();
|
|
3343
|
+
const completed = history.filter((h) => h.status === "COMPLETED");
|
|
3344
|
+
if (completed.length === 0) {
|
|
3345
|
+
return Result.ok(0);
|
|
3346
|
+
}
|
|
3347
|
+
const maxVersion = Math.max(...completed.map((h) => h.version));
|
|
3348
|
+
return Result.ok(maxVersion);
|
|
3349
|
+
} catch (error) {
|
|
3350
|
+
this.logger.error("Failed to get current version", { error });
|
|
3351
|
+
return Result.err({
|
|
3352
|
+
type: "UNKNOWN",
|
|
3353
|
+
message: `Failed to get current version: ${error}`,
|
|
3354
|
+
cause: error
|
|
3355
|
+
});
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Validate migration checksums
|
|
3360
|
+
*/
|
|
3361
|
+
async validateChecksums() {
|
|
3362
|
+
try {
|
|
3363
|
+
const migrationsResult = await this.getMigrations();
|
|
3364
|
+
if (migrationsResult.isErr()) {
|
|
3365
|
+
return Result.err(migrationsResult.variant.error);
|
|
3366
|
+
}
|
|
3367
|
+
const migrations = migrationsResult.unwrap();
|
|
3368
|
+
const historyResult = await this.getHistory();
|
|
3369
|
+
if (historyResult.isErr()) {
|
|
3370
|
+
return Result.err(historyResult.variant.error);
|
|
3371
|
+
}
|
|
3372
|
+
const history = historyResult.unwrap();
|
|
3373
|
+
const completedMigrations = history.filter((h) => h.status === "COMPLETED");
|
|
3374
|
+
for (const historyEntry of completedMigrations) {
|
|
3375
|
+
const migration = migrations.find((m) => m.id === historyEntry.id);
|
|
3376
|
+
if (!migration) {
|
|
3377
|
+
continue;
|
|
3378
|
+
}
|
|
3379
|
+
if (migration.checksum && historyEntry.checksum) {
|
|
3380
|
+
if (migration.checksum !== historyEntry.checksum) {
|
|
3381
|
+
return Result.err({
|
|
3382
|
+
type: "CHECKSUM_MISMATCH",
|
|
3383
|
+
message: `Checksum mismatch for migration ${migration.id}`,
|
|
3384
|
+
migrationId: migration.id
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
return Result.ok(true);
|
|
3390
|
+
} catch (error) {
|
|
3391
|
+
this.logger.error("Checksum validation failed", { error });
|
|
3392
|
+
return Result.err({
|
|
3393
|
+
type: "UNKNOWN",
|
|
3394
|
+
message: `Checksum validation failed: ${error}`,
|
|
3395
|
+
cause: error
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* Load migration from file
|
|
3401
|
+
*/
|
|
3402
|
+
async loadMigration(filePath) {
|
|
3403
|
+
if (this.loader.loadMigrationFile) {
|
|
3404
|
+
return this.loader.loadMigrationFile(filePath);
|
|
3405
|
+
}
|
|
3406
|
+
return Result.err({
|
|
3407
|
+
type: "UNKNOWN",
|
|
3408
|
+
message: "Migration loader does not support loading individual files"
|
|
3409
|
+
});
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* Run a single migration in a transaction
|
|
3413
|
+
*/
|
|
3414
|
+
async runMigration(migration, direction) {
|
|
3415
|
+
const startTime = Date.now();
|
|
3416
|
+
const isUp = direction === "UP";
|
|
3417
|
+
const fn = isUp ? migration.up : migration.down;
|
|
3418
|
+
try {
|
|
3419
|
+
await this.updateMigrationStatus(
|
|
3420
|
+
migration,
|
|
3421
|
+
isUp ? "RUNNING" : "RUNNING",
|
|
3422
|
+
void 0,
|
|
3423
|
+
void 0
|
|
3424
|
+
);
|
|
3425
|
+
const runFn = async (conn) => {
|
|
3426
|
+
const context = {
|
|
3427
|
+
execute: async (sql, params) => {
|
|
3428
|
+
const result2 = await conn.execute(sql, params);
|
|
3429
|
+
if (result2.isErr()) {
|
|
3430
|
+
return Result.err({
|
|
3431
|
+
type: "MIGRATION_FAILED",
|
|
3432
|
+
message: `Migration execution failed: ${result2.variant.error}`,
|
|
3433
|
+
migrationId: migration.id,
|
|
3434
|
+
cause: result2.variant.error
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3437
|
+
return Result.ok(result2.unwrap());
|
|
3438
|
+
},
|
|
3439
|
+
log: (message) => {
|
|
3440
|
+
this.logger.info(message, { migrationId: migration.id });
|
|
3441
|
+
},
|
|
3442
|
+
metadata: {
|
|
3443
|
+
id: migration.id,
|
|
3444
|
+
version: migration.version,
|
|
3445
|
+
name: migration.name,
|
|
3446
|
+
status: "RUNNING"
|
|
3447
|
+
}
|
|
3448
|
+
};
|
|
3449
|
+
return await fn(context);
|
|
3450
|
+
};
|
|
3451
|
+
let result;
|
|
3452
|
+
if (this.config.transactional) {
|
|
3453
|
+
result = await this.connection.transaction(runFn);
|
|
3454
|
+
} else {
|
|
3455
|
+
result = await runFn(this.connection);
|
|
3456
|
+
}
|
|
3457
|
+
const executionTime = Date.now() - startTime;
|
|
3458
|
+
if (result.isErr()) {
|
|
3459
|
+
await this.updateMigrationStatus(
|
|
3460
|
+
migration,
|
|
3461
|
+
"FAILED",
|
|
3462
|
+
executionTime,
|
|
3463
|
+
result.variant.error.message
|
|
3464
|
+
);
|
|
3465
|
+
return Result.err(result.variant.error);
|
|
3466
|
+
}
|
|
3467
|
+
const finalStatus = isUp ? "COMPLETED" : "ROLLED_BACK";
|
|
3468
|
+
await this.updateMigrationStatus(migration, finalStatus, executionTime, void 0);
|
|
3469
|
+
const metadata = {
|
|
3470
|
+
id: migration.id,
|
|
3471
|
+
version: migration.version,
|
|
3472
|
+
name: migration.name,
|
|
3473
|
+
status: finalStatus,
|
|
3474
|
+
executedAt: /* @__PURE__ */ new Date(),
|
|
3475
|
+
executionTime,
|
|
3476
|
+
checksum: migration.checksum
|
|
3477
|
+
};
|
|
3478
|
+
this.logger.info("Migration executed", {
|
|
3479
|
+
id: migration.id,
|
|
3480
|
+
direction,
|
|
3481
|
+
executionTime
|
|
3482
|
+
});
|
|
3483
|
+
return Result.ok(metadata);
|
|
3484
|
+
} catch (error) {
|
|
3485
|
+
const executionTime = Date.now() - startTime;
|
|
3486
|
+
await this.updateMigrationStatus(
|
|
3487
|
+
migration,
|
|
3488
|
+
"FAILED",
|
|
3489
|
+
executionTime,
|
|
3490
|
+
String(error)
|
|
3491
|
+
);
|
|
3492
|
+
this.logger.error("Migration execution failed", { error, migration: migration.id });
|
|
3493
|
+
return Result.err({
|
|
3494
|
+
type: isUp ? "MIGRATION_FAILED" : "ROLLBACK_FAILED",
|
|
3495
|
+
message: `Migration ${migration.id} failed: ${error}`,
|
|
3496
|
+
migrationId: migration.id,
|
|
3497
|
+
cause: error
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* Update migration status in database
|
|
3503
|
+
*/
|
|
3504
|
+
async updateMigrationStatus(migration, status, executionTime, error) {
|
|
3505
|
+
try {
|
|
3506
|
+
const tableName = this.config.migrationTable;
|
|
3507
|
+
if (status === "RUNNING") {
|
|
3508
|
+
const insertSQL = `
|
|
3509
|
+
INSERT OR REPLACE INTO ${tableName} (id, version, name, checksum, status, executed_at, execution_time, error)
|
|
3510
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3511
|
+
`;
|
|
3512
|
+
await this.connection.execute(insertSQL, [
|
|
3513
|
+
{
|
|
3514
|
+
id: migration.id,
|
|
3515
|
+
version: migration.version,
|
|
3516
|
+
name: migration.name,
|
|
3517
|
+
checksum: migration.checksum || null,
|
|
3518
|
+
status,
|
|
3519
|
+
executed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3520
|
+
execution_time: null,
|
|
3521
|
+
error: null
|
|
3522
|
+
}
|
|
3523
|
+
]);
|
|
3524
|
+
} else {
|
|
3525
|
+
const updateSQL = `
|
|
3526
|
+
UPDATE ${tableName}
|
|
3527
|
+
SET status = ?, execution_time = ?, error = ?, executed_at = ?
|
|
3528
|
+
WHERE id = ?
|
|
3529
|
+
`;
|
|
3530
|
+
await this.connection.execute(updateSQL, [
|
|
3531
|
+
{
|
|
3532
|
+
status,
|
|
3533
|
+
execution_time: executionTime || null,
|
|
3534
|
+
error: error || null,
|
|
3535
|
+
executed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3536
|
+
id: migration.id
|
|
3537
|
+
}
|
|
3538
|
+
]);
|
|
3539
|
+
}
|
|
3540
|
+
} catch (err) {
|
|
3541
|
+
this.logger.error("Failed to update migration status", {
|
|
3542
|
+
error: err,
|
|
3543
|
+
migration: migration.id
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
};
|
|
3548
|
+
var DataSeeder = class {
|
|
3549
|
+
connection;
|
|
3550
|
+
config;
|
|
3551
|
+
logger;
|
|
3552
|
+
seedsCache = null;
|
|
3553
|
+
constructor(connection, config, logger) {
|
|
3554
|
+
this.connection = connection;
|
|
3555
|
+
this.config = config;
|
|
3556
|
+
this.logger = logger;
|
|
3557
|
+
}
|
|
3558
|
+
/**
|
|
3559
|
+
* Create seed table if it doesn't exist
|
|
3560
|
+
*/
|
|
3561
|
+
async createSeedTable() {
|
|
3562
|
+
try {
|
|
3563
|
+
const schema = this.getSeedTableSchema();
|
|
3564
|
+
const result = await this.connection.execute(schema);
|
|
3565
|
+
if (result.isErr()) {
|
|
3566
|
+
return Result.err({
|
|
3567
|
+
type: "UNKNOWN",
|
|
3568
|
+
message: "Failed to create seed table",
|
|
3569
|
+
cause: result.variant.error
|
|
3570
|
+
});
|
|
3571
|
+
}
|
|
3572
|
+
this.logger.debug("Seed table created");
|
|
3573
|
+
return Result.ok(void 0);
|
|
3574
|
+
} catch (error) {
|
|
3575
|
+
this.logger.error("Failed to create seed table", { error });
|
|
3576
|
+
return Result.err({
|
|
3577
|
+
type: "UNKNOWN",
|
|
3578
|
+
message: `Failed to create seed table: ${error}`,
|
|
3579
|
+
cause: error
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Get all available seeds
|
|
3585
|
+
*/
|
|
3586
|
+
async getSeeds() {
|
|
3587
|
+
return this.loadSeedsFromDirectory();
|
|
3588
|
+
}
|
|
3589
|
+
/**
|
|
3590
|
+
* Get seed history from database
|
|
3591
|
+
*/
|
|
3592
|
+
async getHistory() {
|
|
3593
|
+
try {
|
|
3594
|
+
const query = `SELECT * FROM ${this.config.seedTable} ORDER BY executed_at DESC`;
|
|
3595
|
+
const result = await this.connection.query(query);
|
|
3596
|
+
if (result.isErr()) {
|
|
3597
|
+
return Result.err({
|
|
3598
|
+
type: "UNKNOWN",
|
|
3599
|
+
message: "Failed to get seed history",
|
|
3600
|
+
cause: result.variant.error
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
const rows = result.unwrap();
|
|
3604
|
+
const history = rows.map((row) => ({
|
|
3605
|
+
id: row.id,
|
|
3606
|
+
name: row.name,
|
|
3607
|
+
environment: JSON.parse(row.environment || "[]"),
|
|
3608
|
+
status: row.status,
|
|
3609
|
+
executedAt: row.executed_at ? new Date(row.executed_at) : void 0,
|
|
3610
|
+
executionTime: row.execution_time,
|
|
3611
|
+
error: row.error,
|
|
3612
|
+
checksum: row.checksum
|
|
3613
|
+
}));
|
|
3614
|
+
return Result.ok(history);
|
|
3615
|
+
} catch (error) {
|
|
3616
|
+
this.logger.error("Failed to get seed history", { error });
|
|
3617
|
+
return Result.err({
|
|
3618
|
+
type: "UNKNOWN",
|
|
3619
|
+
message: `Failed to get seed history: ${error}`,
|
|
3620
|
+
cause: error
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
/**
|
|
3625
|
+
* Get pending seeds for current environment
|
|
3626
|
+
*/
|
|
3627
|
+
async getPending() {
|
|
3628
|
+
try {
|
|
3629
|
+
const seedsResult = await this.getSeeds();
|
|
3630
|
+
if (seedsResult.isErr()) {
|
|
3631
|
+
return seedsResult;
|
|
3632
|
+
}
|
|
3633
|
+
const allSeeds = seedsResult.unwrap();
|
|
3634
|
+
const environmentSeeds = allSeeds.filter(
|
|
3635
|
+
(seed) => this.matchesEnvironment(seed.environment)
|
|
3636
|
+
);
|
|
3637
|
+
const historyResult = await this.getHistory();
|
|
3638
|
+
if (historyResult.isErr()) {
|
|
3639
|
+
return Result.err(historyResult.variant.error);
|
|
3640
|
+
}
|
|
3641
|
+
const history = historyResult.unwrap();
|
|
3642
|
+
const executedIds = new Set(
|
|
3643
|
+
history.filter((h) => h.status === "COMPLETED").map((h) => h.id)
|
|
3644
|
+
);
|
|
3645
|
+
const pending = environmentSeeds.filter((s) => !executedIds.has(s.id));
|
|
3646
|
+
return Result.ok(pending);
|
|
3647
|
+
} catch (error) {
|
|
3648
|
+
this.logger.error("Failed to get pending seeds", { error });
|
|
3649
|
+
return Result.err({
|
|
3650
|
+
type: "UNKNOWN",
|
|
3651
|
+
message: `Failed to get pending seeds: ${error}`,
|
|
3652
|
+
cause: error
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* Run all pending seeds for current environment
|
|
3658
|
+
*/
|
|
3659
|
+
async seed() {
|
|
3660
|
+
try {
|
|
3661
|
+
if (this.config.validateChecksums) {
|
|
3662
|
+
const validationResult = await this.validateChecksums();
|
|
3663
|
+
if (validationResult.isErr()) {
|
|
3664
|
+
return Result.err(validationResult.variant.error);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
const pendingResult = await this.getPending();
|
|
3668
|
+
if (pendingResult.isErr()) {
|
|
3669
|
+
return Result.err(pendingResult.variant.error);
|
|
3670
|
+
}
|
|
3671
|
+
const pending = pendingResult.unwrap();
|
|
3672
|
+
const executed = [];
|
|
3673
|
+
for (const seed of pending) {
|
|
3674
|
+
const result = await this.executeSeed(seed);
|
|
3675
|
+
if (result.isErr()) {
|
|
3676
|
+
return Result.err(result.variant.error);
|
|
3677
|
+
}
|
|
3678
|
+
executed.push(result.unwrap());
|
|
3679
|
+
}
|
|
3680
|
+
this.logger.info("Seeds completed", { count: executed.length });
|
|
3681
|
+
return Result.ok(executed);
|
|
3682
|
+
} catch (error) {
|
|
3683
|
+
this.logger.error("Seeding failed", { error });
|
|
3684
|
+
return Result.err({
|
|
3685
|
+
type: "UNKNOWN",
|
|
3686
|
+
message: `Seeding failed: ${error}`,
|
|
3687
|
+
cause: error
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
/**
|
|
3692
|
+
* Run a specific seed by ID
|
|
3693
|
+
*/
|
|
3694
|
+
async runSeed(seedId) {
|
|
3695
|
+
try {
|
|
3696
|
+
const seedsResult = await this.getSeeds();
|
|
3697
|
+
if (seedsResult.isErr()) {
|
|
3698
|
+
return Result.err(seedsResult.variant.error);
|
|
3699
|
+
}
|
|
3700
|
+
const allSeeds = seedsResult.unwrap();
|
|
3701
|
+
const seed = allSeeds.find((s) => s.id === seedId);
|
|
3702
|
+
if (!seed) {
|
|
3703
|
+
return Result.err({
|
|
3704
|
+
type: "SEED_NOT_FOUND",
|
|
3705
|
+
message: `Seed ${seedId} not found`,
|
|
3706
|
+
seedId
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
return this.executeSeed(seed);
|
|
3710
|
+
} catch (error) {
|
|
3711
|
+
this.logger.error("Run seed failed", { error, seedId });
|
|
3712
|
+
return Result.err({
|
|
3713
|
+
type: "UNKNOWN",
|
|
3714
|
+
message: `Run seed ${seedId} failed: ${error}`,
|
|
3715
|
+
cause: error
|
|
3716
|
+
});
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
/**
|
|
3720
|
+
* Reset all seeds (clear history)
|
|
3721
|
+
*/
|
|
3722
|
+
async reset() {
|
|
3723
|
+
try {
|
|
3724
|
+
const sql = `DELETE FROM ${this.config.seedTable}`;
|
|
3725
|
+
const result = await this.connection.execute(sql);
|
|
3726
|
+
if (result.isErr()) {
|
|
3727
|
+
return Result.err({
|
|
3728
|
+
type: "UNKNOWN",
|
|
3729
|
+
message: "Failed to reset seeds",
|
|
3730
|
+
cause: result.variant.error
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3733
|
+
this.logger.info("All seeds reset");
|
|
3734
|
+
return Result.ok(void 0);
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
this.logger.error("Failed to reset seeds", { error });
|
|
3737
|
+
return Result.err({
|
|
3738
|
+
type: "UNKNOWN",
|
|
3739
|
+
message: `Failed to reset seeds: ${error}`,
|
|
3740
|
+
cause: error
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
/**
|
|
3745
|
+
* Validate seed checksums
|
|
3746
|
+
*/
|
|
3747
|
+
async validateChecksums() {
|
|
3748
|
+
try {
|
|
3749
|
+
const seedsResult = await this.getSeeds();
|
|
3750
|
+
if (seedsResult.isErr()) {
|
|
3751
|
+
return Result.err(seedsResult.variant.error);
|
|
3752
|
+
}
|
|
3753
|
+
const seeds = seedsResult.unwrap();
|
|
3754
|
+
const historyResult = await this.getHistory();
|
|
3755
|
+
if (historyResult.isErr()) {
|
|
3756
|
+
return Result.err(historyResult.variant.error);
|
|
3757
|
+
}
|
|
3758
|
+
const history = historyResult.unwrap();
|
|
3759
|
+
const completedSeeds = history.filter((h) => h.status === "COMPLETED");
|
|
3760
|
+
for (const historyEntry of completedSeeds) {
|
|
3761
|
+
const seed = seeds.find((s) => s.id === historyEntry.id);
|
|
3762
|
+
if (!seed) {
|
|
3763
|
+
continue;
|
|
3764
|
+
}
|
|
3765
|
+
if (seed.checksum && historyEntry.checksum) {
|
|
3766
|
+
if (seed.checksum !== historyEntry.checksum) {
|
|
3767
|
+
return Result.err({
|
|
3768
|
+
type: "CHECKSUM_MISMATCH",
|
|
3769
|
+
message: `Checksum mismatch for seed ${seed.id}`,
|
|
3770
|
+
seedId: seed.id
|
|
3771
|
+
});
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
return Result.ok(true);
|
|
3776
|
+
} catch (error) {
|
|
3777
|
+
this.logger.error("Checksum validation failed", { error });
|
|
3778
|
+
return Result.err({
|
|
3779
|
+
type: "UNKNOWN",
|
|
3780
|
+
message: `Checksum validation failed: ${error}`,
|
|
3781
|
+
cause: error
|
|
3782
|
+
});
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
/**
|
|
3786
|
+
* Load seed from file
|
|
3787
|
+
*/
|
|
3788
|
+
async loadSeed(filePath) {
|
|
3789
|
+
return this.loadSeedFile(filePath);
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* Load seeds from directory
|
|
3793
|
+
*/
|
|
3794
|
+
async loadSeedsFromDirectory() {
|
|
3795
|
+
if (this.seedsCache) {
|
|
3796
|
+
return Result.ok(this.seedsCache);
|
|
3797
|
+
}
|
|
3798
|
+
try {
|
|
3799
|
+
if (!fs.existsSync(this.config.seedsPath)) {
|
|
3800
|
+
return Result.err({
|
|
3801
|
+
type: "INVALID_SEED",
|
|
3802
|
+
message: `Seeds directory does not exist: ${this.config.seedsPath}`
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
const files = fs.readdirSync(this.config.seedsPath);
|
|
3806
|
+
const filePattern = this.config.filePattern || /^[0-9]+[_-].*\.ts$/;
|
|
3807
|
+
const seedFiles = files.filter((file) => filePattern.test(file));
|
|
3808
|
+
this.logger.debug("Found seed files", {
|
|
3809
|
+
total: files.length,
|
|
3810
|
+
seeds: seedFiles.length
|
|
3811
|
+
});
|
|
3812
|
+
const seeds = [];
|
|
3813
|
+
for (const file of seedFiles) {
|
|
3814
|
+
const filePath = path.join(this.config.seedsPath, file);
|
|
3815
|
+
const result = await this.loadSeedFile(filePath);
|
|
3816
|
+
if (result.isErr()) {
|
|
3817
|
+
return result;
|
|
3818
|
+
}
|
|
3819
|
+
seeds.push(result.unwrap());
|
|
3820
|
+
}
|
|
3821
|
+
seeds.sort((a, b) => a.id.localeCompare(b.id));
|
|
3822
|
+
this.seedsCache = seeds;
|
|
3823
|
+
this.logger.debug("Loaded seeds", { count: seeds.length });
|
|
3824
|
+
return Result.ok(seeds);
|
|
3825
|
+
} catch (error) {
|
|
3826
|
+
this.logger.error("Failed to load seeds", { error });
|
|
3827
|
+
return Result.err({
|
|
3828
|
+
type: "UNKNOWN",
|
|
3829
|
+
message: `Failed to load seeds: ${error}`,
|
|
3830
|
+
cause: error
|
|
3831
|
+
});
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
/**
|
|
3835
|
+
* Load a single seed file
|
|
3836
|
+
*/
|
|
3837
|
+
async loadSeedFile(filePath) {
|
|
3838
|
+
try {
|
|
3839
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
3840
|
+
const fileUrl = `file://${filePath.replace(/\\/g, "/")}`;
|
|
3841
|
+
const module = await import(fileUrl);
|
|
3842
|
+
const validation = this.validateSeed(module, filePath);
|
|
3843
|
+
if (validation.isErr()) {
|
|
3844
|
+
return validation;
|
|
3845
|
+
}
|
|
3846
|
+
const { id, name, environment, run } = module;
|
|
3847
|
+
const checksum = this.computeChecksum(content);
|
|
3848
|
+
const seed = {
|
|
3849
|
+
id,
|
|
3850
|
+
name,
|
|
3851
|
+
environment: Array.isArray(environment) ? environment : [environment],
|
|
3852
|
+
run,
|
|
3853
|
+
checksum
|
|
3854
|
+
};
|
|
3855
|
+
this.logger.debug("Loaded seed", { id, name, environment });
|
|
3856
|
+
return Result.ok(seed);
|
|
3857
|
+
} catch (error) {
|
|
3858
|
+
this.logger.error("Failed to load seed file", { error, filePath });
|
|
3859
|
+
return Result.err({
|
|
3860
|
+
type: "INVALID_SEED",
|
|
3861
|
+
message: `Failed to load seed file ${filePath}: ${error}`,
|
|
3862
|
+
cause: error
|
|
3863
|
+
});
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Validate seed module structure
|
|
3868
|
+
*/
|
|
3869
|
+
validateSeed(module, filePath) {
|
|
3870
|
+
if (!module.id) {
|
|
3871
|
+
return Result.err({
|
|
3872
|
+
type: "INVALID_SEED",
|
|
3873
|
+
message: `Seed ${filePath} is missing 'id' export`
|
|
3874
|
+
});
|
|
3875
|
+
}
|
|
3876
|
+
if (!module.name) {
|
|
3877
|
+
return Result.err({
|
|
3878
|
+
type: "INVALID_SEED",
|
|
3879
|
+
message: `Seed ${filePath} is missing 'name' export`
|
|
3880
|
+
});
|
|
3881
|
+
}
|
|
3882
|
+
if (!module.environment) {
|
|
3883
|
+
return Result.err({
|
|
3884
|
+
type: "INVALID_SEED",
|
|
3885
|
+
message: `Seed ${filePath} is missing 'environment' export`
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
if (!module.run || typeof module.run !== "function") {
|
|
3889
|
+
return Result.err({
|
|
3890
|
+
type: "INVALID_SEED",
|
|
3891
|
+
message: `Seed ${filePath} is missing 'run' function`
|
|
3892
|
+
});
|
|
3893
|
+
}
|
|
3894
|
+
return Result.ok(void 0);
|
|
3895
|
+
}
|
|
3896
|
+
/**
|
|
3897
|
+
* Check if seed matches current environment
|
|
3898
|
+
*/
|
|
3899
|
+
matchesEnvironment(environments) {
|
|
3900
|
+
if (environments.includes("all")) {
|
|
3901
|
+
return true;
|
|
3902
|
+
}
|
|
3903
|
+
return environments.includes(this.config.environment);
|
|
3904
|
+
}
|
|
3905
|
+
/**
|
|
3906
|
+
* Execute a single seed
|
|
3907
|
+
*/
|
|
3908
|
+
async executeSeed(seed) {
|
|
3909
|
+
const startTime = Date.now();
|
|
3910
|
+
try {
|
|
3911
|
+
await this.updateSeedStatus(seed, "RUNNING", void 0, void 0);
|
|
3912
|
+
const runFn = async (conn) => {
|
|
3913
|
+
const context = {
|
|
3914
|
+
execute: async (sql, params) => {
|
|
3915
|
+
const result2 = await conn.execute(sql, params);
|
|
3916
|
+
if (result2.isErr()) {
|
|
3917
|
+
return Result.err({
|
|
3918
|
+
type: "SEED_FAILED",
|
|
3919
|
+
message: `Seed execution failed: ${result2.variant.error}`,
|
|
3920
|
+
seedId: seed.id,
|
|
3921
|
+
cause: result2.variant.error
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3924
|
+
return Result.ok(result2.unwrap());
|
|
3925
|
+
},
|
|
3926
|
+
log: (message) => {
|
|
3927
|
+
this.logger.info(message, { seedId: seed.id });
|
|
3928
|
+
},
|
|
3929
|
+
metadata: {
|
|
3930
|
+
id: seed.id,
|
|
3931
|
+
name: seed.name,
|
|
3932
|
+
environment: seed.environment,
|
|
3933
|
+
status: "RUNNING"
|
|
3934
|
+
},
|
|
3935
|
+
environment: this.config.environment
|
|
3936
|
+
};
|
|
3937
|
+
return await seed.run(context);
|
|
3938
|
+
};
|
|
3939
|
+
let result;
|
|
3940
|
+
if (this.config.transactional) {
|
|
3941
|
+
result = await this.connection.transaction(runFn);
|
|
3942
|
+
} else {
|
|
3943
|
+
result = await runFn(this.connection);
|
|
3944
|
+
}
|
|
3945
|
+
const executionTime = Date.now() - startTime;
|
|
3946
|
+
if (result.isErr()) {
|
|
3947
|
+
await this.updateSeedStatus(seed, "FAILED", executionTime, result.variant.error.message);
|
|
3948
|
+
return Result.err(result.variant.error);
|
|
3949
|
+
}
|
|
3950
|
+
await this.updateSeedStatus(seed, "COMPLETED", executionTime, void 0);
|
|
3951
|
+
const metadata = {
|
|
3952
|
+
id: seed.id,
|
|
3953
|
+
name: seed.name,
|
|
3954
|
+
environment: seed.environment,
|
|
3955
|
+
status: "COMPLETED",
|
|
3956
|
+
executedAt: /* @__PURE__ */ new Date(),
|
|
3957
|
+
executionTime,
|
|
3958
|
+
checksum: seed.checksum
|
|
3959
|
+
};
|
|
3960
|
+
this.logger.info("Seed executed", {
|
|
3961
|
+
id: seed.id,
|
|
3962
|
+
executionTime
|
|
3963
|
+
});
|
|
3964
|
+
return Result.ok(metadata);
|
|
3965
|
+
} catch (error) {
|
|
3966
|
+
const executionTime = Date.now() - startTime;
|
|
3967
|
+
await this.updateSeedStatus(seed, "FAILED", executionTime, String(error));
|
|
3968
|
+
this.logger.error("Seed execution failed", { error, seed: seed.id });
|
|
3969
|
+
return Result.err({
|
|
3970
|
+
type: "SEED_FAILED",
|
|
3971
|
+
message: `Seed ${seed.id} failed: ${error}`,
|
|
3972
|
+
seedId: seed.id,
|
|
3973
|
+
cause: error
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
/**
|
|
3978
|
+
* Update seed status in database
|
|
3979
|
+
*/
|
|
3980
|
+
async updateSeedStatus(seed, status, executionTime, error) {
|
|
3981
|
+
try {
|
|
3982
|
+
const tableName = this.config.seedTable;
|
|
3983
|
+
if (status === "RUNNING") {
|
|
3984
|
+
const insertSQL = `
|
|
3985
|
+
INSERT OR REPLACE INTO ${tableName} (id, name, environment, checksum, status, executed_at, execution_time, error)
|
|
3986
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3987
|
+
`;
|
|
3988
|
+
await this.connection.execute(insertSQL, [
|
|
3989
|
+
seed.id,
|
|
3990
|
+
seed.name,
|
|
3991
|
+
JSON.stringify(seed.environment),
|
|
3992
|
+
seed.checksum || null,
|
|
3993
|
+
status,
|
|
3994
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
3995
|
+
null,
|
|
3996
|
+
null
|
|
3997
|
+
]);
|
|
3998
|
+
} else {
|
|
3999
|
+
const updateSQL = `
|
|
4000
|
+
UPDATE ${tableName}
|
|
4001
|
+
SET status = ?, execution_time = ?, error = ?, executed_at = ?
|
|
4002
|
+
WHERE id = ?
|
|
4003
|
+
`;
|
|
4004
|
+
await this.connection.execute(updateSQL, [
|
|
4005
|
+
status,
|
|
4006
|
+
executionTime || null,
|
|
4007
|
+
error || null,
|
|
4008
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
4009
|
+
seed.id
|
|
4010
|
+
]);
|
|
4011
|
+
}
|
|
4012
|
+
} catch (err) {
|
|
4013
|
+
this.logger.error("Failed to update seed status", {
|
|
4014
|
+
error: err,
|
|
4015
|
+
seed: seed.id
|
|
4016
|
+
});
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
/**
|
|
4020
|
+
* Compute SHA-256 checksum of seed content
|
|
4021
|
+
*/
|
|
4022
|
+
computeChecksum(content) {
|
|
4023
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Get SQL schema for seed tracking table
|
|
4027
|
+
*/
|
|
4028
|
+
getSeedTableSchema() {
|
|
4029
|
+
const tableName = this.config.seedTable;
|
|
4030
|
+
return `
|
|
4031
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
4032
|
+
id TEXT PRIMARY KEY,
|
|
4033
|
+
name TEXT NOT NULL,
|
|
4034
|
+
environment TEXT NOT NULL,
|
|
4035
|
+
checksum TEXT,
|
|
4036
|
+
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
4037
|
+
executed_at TIMESTAMP,
|
|
4038
|
+
execution_time INTEGER,
|
|
4039
|
+
error TEXT
|
|
4040
|
+
);
|
|
4041
|
+
|
|
4042
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_status ON ${tableName}(status);
|
|
4043
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_environment ON ${tableName}(environment);
|
|
4044
|
+
`.trim();
|
|
4045
|
+
}
|
|
4046
|
+
};
|
|
4047
|
+
var D1DatabaseAdapter = class {
|
|
4048
|
+
config;
|
|
4049
|
+
logger;
|
|
4050
|
+
db;
|
|
4051
|
+
constructor(config, logger) {
|
|
4052
|
+
this.config = config;
|
|
4053
|
+
this.logger = logger;
|
|
4054
|
+
this.db = config.database;
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Execute a SQL query and return all results
|
|
4058
|
+
*/
|
|
4059
|
+
async query(sql, params) {
|
|
4060
|
+
try {
|
|
4061
|
+
if (this.config.enableQueryLogging) {
|
|
4062
|
+
this.logger.debug("Executing D1 query", { sql, params });
|
|
4063
|
+
}
|
|
4064
|
+
const stmt = this.prepareStatement(sql, params);
|
|
4065
|
+
const result = await stmt.all();
|
|
4066
|
+
if (!result.success) {
|
|
4067
|
+
return Result.err({
|
|
4068
|
+
type: "QUERY_FAILED",
|
|
4069
|
+
message: result.error || "Query failed",
|
|
4070
|
+
sql
|
|
4071
|
+
});
|
|
4072
|
+
}
|
|
4073
|
+
return Result.ok(result.results || []);
|
|
4074
|
+
} catch (error) {
|
|
4075
|
+
this.logger.error("D1 query failed", { error, sql });
|
|
4076
|
+
return Result.err({
|
|
4077
|
+
type: "QUERY_FAILED",
|
|
4078
|
+
message: `Query failed: ${error}`,
|
|
4079
|
+
sql,
|
|
4080
|
+
cause: error
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
/**
|
|
4085
|
+
* Execute a SQL query and return first result
|
|
4086
|
+
*/
|
|
4087
|
+
async queryFirst(sql, params) {
|
|
4088
|
+
try {
|
|
4089
|
+
if (this.config.enableQueryLogging) {
|
|
4090
|
+
this.logger.debug("Executing D1 queryFirst", { sql, params });
|
|
4091
|
+
}
|
|
4092
|
+
const stmt = this.prepareStatement(sql, params);
|
|
4093
|
+
const result = await stmt.first();
|
|
4094
|
+
return Result.ok(result);
|
|
4095
|
+
} catch (error) {
|
|
4096
|
+
this.logger.error("D1 queryFirst failed", { error, sql });
|
|
4097
|
+
return Result.err({
|
|
4098
|
+
type: "QUERY_FAILED",
|
|
4099
|
+
message: `QueryFirst failed: ${error}`,
|
|
4100
|
+
sql,
|
|
4101
|
+
cause: error
|
|
4102
|
+
});
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
/**
|
|
4106
|
+
* Execute a SQL statement (INSERT, UPDATE, DELETE)
|
|
4107
|
+
*/
|
|
4108
|
+
async execute(sql, params) {
|
|
4109
|
+
try {
|
|
4110
|
+
if (this.config.enableQueryLogging) {
|
|
4111
|
+
this.logger.debug("Executing D1 statement", { sql, params });
|
|
4112
|
+
}
|
|
4113
|
+
const stmt = this.prepareStatement(sql, params);
|
|
4114
|
+
const result = await stmt.run();
|
|
4115
|
+
if (!result.success) {
|
|
4116
|
+
return Result.err({
|
|
4117
|
+
type: "QUERY_FAILED",
|
|
4118
|
+
message: result.error || "Execution failed",
|
|
4119
|
+
sql
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
return Result.ok(result);
|
|
4123
|
+
} catch (error) {
|
|
4124
|
+
this.logger.error("D1 execute failed", { error, sql });
|
|
4125
|
+
return Result.err({
|
|
4126
|
+
type: "QUERY_FAILED",
|
|
4127
|
+
message: `Execute failed: ${error}`,
|
|
4128
|
+
sql,
|
|
4129
|
+
cause: error
|
|
4130
|
+
});
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
/**
|
|
4134
|
+
* Execute multiple SQL statements in a batch
|
|
4135
|
+
*/
|
|
4136
|
+
async batch(statements) {
|
|
4137
|
+
try {
|
|
4138
|
+
const maxBatchSize = this.config.maxBatchSize || 100;
|
|
4139
|
+
if (statements.length > maxBatchSize) {
|
|
4140
|
+
return Result.err({
|
|
4141
|
+
type: "BATCH_FAILED",
|
|
4142
|
+
message: `Batch size ${statements.length} exceeds maximum ${maxBatchSize}`
|
|
4143
|
+
});
|
|
4144
|
+
}
|
|
4145
|
+
if (statements.length === 0) {
|
|
4146
|
+
return Result.ok([]);
|
|
4147
|
+
}
|
|
4148
|
+
if (this.config.enableQueryLogging) {
|
|
4149
|
+
this.logger.debug("Executing D1 batch", {
|
|
4150
|
+
count: statements.length
|
|
4151
|
+
});
|
|
4152
|
+
}
|
|
4153
|
+
const preparedStatements = statements.map(
|
|
4154
|
+
({ sql, params }) => this.prepareStatement(sql, params)
|
|
4155
|
+
);
|
|
4156
|
+
const results = await this.db.batch(preparedStatements);
|
|
4157
|
+
return Result.ok(results);
|
|
4158
|
+
} catch (error) {
|
|
4159
|
+
this.logger.error("D1 batch failed", { error, count: statements.length });
|
|
4160
|
+
return Result.err({
|
|
4161
|
+
type: "BATCH_FAILED",
|
|
4162
|
+
message: `Batch execution failed: ${error}`,
|
|
4163
|
+
cause: error
|
|
4164
|
+
});
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Execute raw SQL (for migrations and schema changes)
|
|
4169
|
+
*/
|
|
4170
|
+
async exec(sql) {
|
|
4171
|
+
try {
|
|
4172
|
+
if (this.config.enableQueryLogging) {
|
|
4173
|
+
this.logger.debug("Executing D1 raw SQL", { sql });
|
|
4174
|
+
}
|
|
4175
|
+
const result = await this.db.exec(sql);
|
|
4176
|
+
return Result.ok(result);
|
|
4177
|
+
} catch (error) {
|
|
4178
|
+
this.logger.error("D1 exec failed", { error, sql });
|
|
4179
|
+
return Result.err({
|
|
4180
|
+
type: "EXEC_FAILED",
|
|
4181
|
+
message: `Exec failed: ${error}`,
|
|
4182
|
+
cause: error
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
/**
|
|
4187
|
+
* Begin a transaction
|
|
4188
|
+
*
|
|
4189
|
+
* Note: D1 doesn't support explicit transactions yet, so this wraps
|
|
4190
|
+
* the callback execution and provides the same interface for compatibility.
|
|
4191
|
+
* Consider using batch() for atomic multi-statement operations.
|
|
4192
|
+
*/
|
|
4193
|
+
async transaction(callback) {
|
|
4194
|
+
try {
|
|
4195
|
+
this.logger.debug("Starting D1 transaction (logical)");
|
|
4196
|
+
const result = await callback(this);
|
|
4197
|
+
return result;
|
|
4198
|
+
} catch (error) {
|
|
4199
|
+
this.logger.error("D1 transaction failed", { error });
|
|
4200
|
+
return Result.err({
|
|
4201
|
+
type: "UNKNOWN",
|
|
4202
|
+
message: `Transaction failed: ${error}`,
|
|
4203
|
+
cause: error
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
/**
|
|
4208
|
+
* Prepare a SQL statement with parameters
|
|
4209
|
+
*/
|
|
4210
|
+
prepareStatement(sql, params) {
|
|
4211
|
+
const stmt = this.db.prepare(sql);
|
|
4212
|
+
if (params && params.length > 0) {
|
|
4213
|
+
return stmt.bind(...params);
|
|
4214
|
+
}
|
|
4215
|
+
return stmt;
|
|
4216
|
+
}
|
|
4217
|
+
};
|
|
4218
|
+
var DurableObjectStorageAdapter = class _DurableObjectStorageAdapter {
|
|
4219
|
+
config;
|
|
4220
|
+
storage;
|
|
4221
|
+
logger;
|
|
4222
|
+
constructor(storage, config, logger) {
|
|
4223
|
+
this.storage = storage;
|
|
4224
|
+
this.config = config;
|
|
4225
|
+
this.logger = logger;
|
|
4226
|
+
}
|
|
4227
|
+
/**
|
|
4228
|
+
* Get a single value by key
|
|
4229
|
+
*/
|
|
4230
|
+
async get(key) {
|
|
4231
|
+
try {
|
|
4232
|
+
if (this.config.enableLogging) {
|
|
4233
|
+
this.logger.debug("DO Storage operation", { operation: "get", key });
|
|
4234
|
+
}
|
|
4235
|
+
const value = await this.storage.get(key);
|
|
4236
|
+
return Result.ok(value === void 0 ? null : value);
|
|
4237
|
+
} catch (error) {
|
|
4238
|
+
this.logger.error("DO Storage get failed", { error, key });
|
|
4239
|
+
return Result.err({
|
|
4240
|
+
type: "STORAGE_FAILED",
|
|
4241
|
+
message: `Failed to get value for key "${key}": ${error}`,
|
|
4242
|
+
key,
|
|
4243
|
+
cause: error
|
|
4244
|
+
});
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
/**
|
|
4248
|
+
* Get multiple values by keys
|
|
4249
|
+
*/
|
|
4250
|
+
async getMultiple(keys) {
|
|
4251
|
+
try {
|
|
4252
|
+
if (this.config.enableLogging) {
|
|
4253
|
+
this.logger.debug("DO Storage operation", {
|
|
4254
|
+
operation: "getMultiple",
|
|
4255
|
+
keyCount: keys.length
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
const result = await this.storage.get(keys);
|
|
4259
|
+
const typedResult = result;
|
|
4260
|
+
return Result.ok(typedResult);
|
|
4261
|
+
} catch (error) {
|
|
4262
|
+
this.logger.error("DO Storage getMultiple failed", { error, keyCount: keys.length });
|
|
4263
|
+
return Result.err({
|
|
4264
|
+
type: "STORAGE_FAILED",
|
|
4265
|
+
message: `Failed to get multiple values: ${error}`,
|
|
4266
|
+
cause: error
|
|
4267
|
+
});
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
/**
|
|
4271
|
+
* Put a single key-value pair
|
|
4272
|
+
*/
|
|
4273
|
+
async put(key, value) {
|
|
4274
|
+
try {
|
|
4275
|
+
if (this.config.enableLogging) {
|
|
4276
|
+
this.logger.debug("DO Storage operation", { operation: "put", key });
|
|
4277
|
+
}
|
|
4278
|
+
await this.storage.put(key, value);
|
|
4279
|
+
return Result.ok(void 0);
|
|
4280
|
+
} catch (error) {
|
|
4281
|
+
if (error instanceof Error && (error.message.includes("DataCloneError") || error.message.includes("could not be cloned"))) {
|
|
4282
|
+
this.logger.error("DO Storage serialization failed", { error, key });
|
|
4283
|
+
return Result.err({
|
|
4284
|
+
type: "SERIALIZATION_ERROR",
|
|
4285
|
+
message: `Failed to serialize value for key "${key}": ${error.message}`,
|
|
4286
|
+
cause: error
|
|
4287
|
+
});
|
|
4288
|
+
}
|
|
4289
|
+
if (error instanceof Error && (error.message.includes("QuotaExceededError") || error.message.includes("storage quota exceeded"))) {
|
|
4290
|
+
this.logger.error("DO Storage quota exceeded", { error, key });
|
|
4291
|
+
return Result.err({
|
|
4292
|
+
type: "QUOTA_EXCEEDED",
|
|
4293
|
+
message: `Storage quota exceeded when setting key "${key}"`
|
|
4294
|
+
});
|
|
4295
|
+
}
|
|
4296
|
+
this.logger.error("DO Storage put failed", { error, key });
|
|
4297
|
+
return Result.err({
|
|
4298
|
+
type: "STORAGE_FAILED",
|
|
4299
|
+
message: `Failed to put value for key "${key}": ${error}`,
|
|
4300
|
+
key,
|
|
4301
|
+
cause: error
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
/**
|
|
4306
|
+
* Put multiple key-value pairs
|
|
4307
|
+
*/
|
|
4308
|
+
async putMultiple(entries) {
|
|
4309
|
+
try {
|
|
4310
|
+
if (this.config.enableLogging) {
|
|
4311
|
+
this.logger.debug("DO Storage operation", {
|
|
4312
|
+
operation: "putMultiple",
|
|
4313
|
+
entryCount: entries.length
|
|
4314
|
+
});
|
|
4315
|
+
}
|
|
4316
|
+
const entriesObj = {};
|
|
4317
|
+
for (const [key, value] of entries) {
|
|
4318
|
+
entriesObj[key] = value;
|
|
4319
|
+
}
|
|
4320
|
+
await this.storage.put(entriesObj);
|
|
4321
|
+
return Result.ok(void 0);
|
|
4322
|
+
} catch (error) {
|
|
4323
|
+
if (error instanceof Error && (error.message.includes("DataCloneError") || error.message.includes("could not be cloned"))) {
|
|
4324
|
+
this.logger.error("DO Storage serialization failed", { error });
|
|
4325
|
+
return Result.err({
|
|
4326
|
+
type: "SERIALIZATION_ERROR",
|
|
4327
|
+
message: `Failed to serialize values: ${error.message}`,
|
|
4328
|
+
cause: error
|
|
4329
|
+
});
|
|
4330
|
+
}
|
|
4331
|
+
if (error instanceof Error && (error.message.includes("QuotaExceededError") || error.message.includes("storage quota exceeded"))) {
|
|
4332
|
+
this.logger.error("DO Storage quota exceeded", { error });
|
|
4333
|
+
return Result.err({
|
|
4334
|
+
type: "QUOTA_EXCEEDED",
|
|
4335
|
+
message: "Storage quota exceeded when setting multiple values"
|
|
4336
|
+
});
|
|
4337
|
+
}
|
|
4338
|
+
this.logger.error("DO Storage putMultiple failed", { error, entryCount: entries.length });
|
|
4339
|
+
return Result.err({
|
|
4340
|
+
type: "STORAGE_FAILED",
|
|
4341
|
+
message: `Failed to put multiple values: ${error}`,
|
|
4342
|
+
cause: error
|
|
4343
|
+
});
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
/**
|
|
4347
|
+
* Delete a single key
|
|
4348
|
+
*/
|
|
4349
|
+
async delete(key) {
|
|
4350
|
+
try {
|
|
4351
|
+
if (this.config.enableLogging) {
|
|
4352
|
+
this.logger.debug("DO Storage operation", { operation: "delete", key });
|
|
4353
|
+
}
|
|
4354
|
+
const result = await this.storage.delete(key);
|
|
4355
|
+
return Result.ok(result);
|
|
4356
|
+
} catch (error) {
|
|
4357
|
+
this.logger.error("DO Storage delete failed", { error, key });
|
|
4358
|
+
return Result.err({
|
|
4359
|
+
type: "STORAGE_FAILED",
|
|
4360
|
+
message: `Failed to delete key "${key}": ${error}`,
|
|
4361
|
+
key,
|
|
4362
|
+
cause: error
|
|
4363
|
+
});
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
/**
|
|
4367
|
+
* Delete multiple keys
|
|
4368
|
+
*/
|
|
4369
|
+
async deleteMultiple(keys) {
|
|
4370
|
+
try {
|
|
4371
|
+
if (this.config.enableLogging) {
|
|
4372
|
+
this.logger.debug("DO Storage operation", {
|
|
4373
|
+
operation: "deleteMultiple",
|
|
4374
|
+
keyCount: keys.length
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4377
|
+
const result = await this.storage.delete(keys);
|
|
4378
|
+
return Result.ok(result);
|
|
4379
|
+
} catch (error) {
|
|
4380
|
+
this.logger.error("DO Storage deleteMultiple failed", { error, keyCount: keys.length });
|
|
4381
|
+
return Result.err({
|
|
4382
|
+
type: "STORAGE_FAILED",
|
|
4383
|
+
message: `Failed to delete multiple keys: ${error}`,
|
|
4384
|
+
cause: error
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
/**
|
|
4389
|
+
* Delete all keys in storage
|
|
4390
|
+
*/
|
|
4391
|
+
async deleteAll() {
|
|
4392
|
+
try {
|
|
4393
|
+
if (this.config.enableLogging) {
|
|
4394
|
+
this.logger.debug("DO Storage operation", { operation: "deleteAll" });
|
|
4395
|
+
}
|
|
4396
|
+
await this.storage.deleteAll();
|
|
4397
|
+
return Result.ok(void 0);
|
|
4398
|
+
} catch (error) {
|
|
4399
|
+
this.logger.error("DO Storage deleteAll failed", { error });
|
|
4400
|
+
return Result.err({
|
|
4401
|
+
type: "STORAGE_FAILED",
|
|
4402
|
+
message: `Failed to delete all keys: ${error}`,
|
|
4403
|
+
cause: error
|
|
4404
|
+
});
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
/**
|
|
4408
|
+
* List keys in storage
|
|
4409
|
+
*/
|
|
4410
|
+
async list(options) {
|
|
4411
|
+
try {
|
|
4412
|
+
if (this.config.enableLogging) {
|
|
4413
|
+
this.logger.debug("DO Storage operation", { operation: "list", options });
|
|
4414
|
+
}
|
|
4415
|
+
const result = await this.storage.list(options);
|
|
4416
|
+
return Result.ok(result);
|
|
4417
|
+
} catch (error) {
|
|
4418
|
+
this.logger.error("DO Storage list failed", { error, options });
|
|
4419
|
+
return Result.err({
|
|
4420
|
+
type: "STORAGE_FAILED",
|
|
4421
|
+
message: `Failed to list keys: ${error}`,
|
|
4422
|
+
cause: error
|
|
4423
|
+
});
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
/**
|
|
4427
|
+
* Execute operations in a transaction
|
|
4428
|
+
*/
|
|
4429
|
+
async transaction(callback) {
|
|
4430
|
+
try {
|
|
4431
|
+
if (this.config.enableLogging) {
|
|
4432
|
+
this.logger.debug("DO Storage operation", { operation: "transaction" });
|
|
4433
|
+
}
|
|
4434
|
+
const result = await this.storage.transaction(async (txnStorage) => {
|
|
4435
|
+
const txnAdapter = new _DurableObjectStorageAdapter(
|
|
4436
|
+
txnStorage,
|
|
4437
|
+
this.config,
|
|
4438
|
+
this.logger
|
|
4439
|
+
);
|
|
4440
|
+
return await callback(txnAdapter);
|
|
4441
|
+
});
|
|
4442
|
+
return result;
|
|
4443
|
+
} catch (error) {
|
|
4444
|
+
this.logger.error("DO Storage transaction failed", { error });
|
|
4445
|
+
return Result.err({
|
|
4446
|
+
type: "TRANSACTION_FAILED",
|
|
4447
|
+
message: `Transaction failed: ${error}`,
|
|
4448
|
+
cause: error
|
|
4449
|
+
});
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
};
|
|
4453
|
+
var R2BackupAdapter = class {
|
|
4454
|
+
config;
|
|
4455
|
+
bucket;
|
|
4456
|
+
logger;
|
|
4457
|
+
constructor(config, logger) {
|
|
4458
|
+
this.config = config;
|
|
4459
|
+
this.bucket = config.bucket;
|
|
4460
|
+
this.logger = logger;
|
|
4461
|
+
}
|
|
4462
|
+
/**
|
|
4463
|
+
* Create a backup with compression
|
|
4464
|
+
*/
|
|
4465
|
+
async backup(data, options) {
|
|
4466
|
+
try {
|
|
4467
|
+
if (this.config.enableLogging) {
|
|
4468
|
+
this.logger.debug("R2 Backup operation", { operation: "backup" });
|
|
4469
|
+
}
|
|
4470
|
+
const buffer = data instanceof ArrayBuffer ? Buffer.from(data) : data;
|
|
4471
|
+
if (!buffer || buffer.length === 0) {
|
|
4472
|
+
return Result.err({
|
|
4473
|
+
type: "COMPRESSION_FAILED",
|
|
4474
|
+
message: "Cannot backup empty or invalid data"
|
|
4475
|
+
});
|
|
4476
|
+
}
|
|
4477
|
+
const compressionLevel = options?.compressionLevel ?? this.config.defaultCompressionLevel ?? 6;
|
|
4478
|
+
let compressedData;
|
|
4479
|
+
try {
|
|
4480
|
+
compressedData = gzipSync(buffer, { level: compressionLevel });
|
|
4481
|
+
} catch (error) {
|
|
4482
|
+
this.logger.error("Compression failed", { error });
|
|
4483
|
+
return Result.err({
|
|
4484
|
+
type: "COMPRESSION_FAILED",
|
|
4485
|
+
message: `Failed to compress data: ${error}`,
|
|
4486
|
+
cause: error
|
|
4487
|
+
});
|
|
4488
|
+
}
|
|
4489
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
4490
|
+
const key = `${this.config.name}-backup-${timestamp}.gz`;
|
|
4491
|
+
const customMetadata = {
|
|
4492
|
+
originalSize: buffer.length.toString(),
|
|
4493
|
+
compressionRatio: (compressedData.length / buffer.length).toString(),
|
|
4494
|
+
compressionLevel: compressionLevel.toString(),
|
|
4495
|
+
backupDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4496
|
+
...options?.metadata || {}
|
|
4497
|
+
};
|
|
4498
|
+
try {
|
|
4499
|
+
const r2Object = await this.bucket.put(key, compressedData, {
|
|
4500
|
+
httpMetadata: {
|
|
4501
|
+
contentType: "application/gzip",
|
|
4502
|
+
contentEncoding: "gzip",
|
|
4503
|
+
contentDisposition: `attachment; filename="${key}"`
|
|
4504
|
+
},
|
|
4505
|
+
customMetadata,
|
|
4506
|
+
storageClass: options?.storageClass || "Standard"
|
|
4507
|
+
});
|
|
4508
|
+
const backupInfo = {
|
|
4509
|
+
key: r2Object.key,
|
|
4510
|
+
size: buffer.length,
|
|
4511
|
+
compressedSize: r2Object.size,
|
|
4512
|
+
compressionRatio: r2Object.size / buffer.length,
|
|
4513
|
+
uploadedAt: r2Object.uploaded,
|
|
4514
|
+
etag: r2Object.etag,
|
|
4515
|
+
metadata: {
|
|
4516
|
+
databaseVersion: customMetadata.databaseVersion,
|
|
4517
|
+
backupType: customMetadata.backupType,
|
|
4518
|
+
originalChecksum: customMetadata.originalChecksum
|
|
4519
|
+
}
|
|
4520
|
+
};
|
|
4521
|
+
if (this.config.enableLogging) {
|
|
4522
|
+
this.logger.debug("Backup completed", {
|
|
4523
|
+
key: backupInfo.key,
|
|
4524
|
+
originalSize: backupInfo.size,
|
|
4525
|
+
compressedSize: backupInfo.compressedSize,
|
|
4526
|
+
compressionRatio: backupInfo.compressionRatio
|
|
4527
|
+
});
|
|
4528
|
+
}
|
|
4529
|
+
return Result.ok(backupInfo);
|
|
4530
|
+
} catch (error) {
|
|
4531
|
+
this.logger.error("R2 upload failed", { error, key });
|
|
4532
|
+
return Result.err({
|
|
4533
|
+
type: "UPLOAD_FAILED",
|
|
4534
|
+
message: `Failed to upload backup: ${error}`,
|
|
4535
|
+
key,
|
|
4536
|
+
cause: error
|
|
4537
|
+
});
|
|
4538
|
+
}
|
|
4539
|
+
} catch (error) {
|
|
4540
|
+
this.logger.error("Backup operation failed", { error });
|
|
4541
|
+
return Result.err({
|
|
4542
|
+
type: "UNKNOWN",
|
|
4543
|
+
message: `Backup failed: ${error}`,
|
|
4544
|
+
cause: error
|
|
4545
|
+
});
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Restore from a backup with decompression
|
|
4550
|
+
*/
|
|
4551
|
+
async restore(key, options) {
|
|
4552
|
+
try {
|
|
4553
|
+
if (this.config.enableLogging) {
|
|
4554
|
+
this.logger.debug("R2 Backup operation", { operation: "restore", key });
|
|
4555
|
+
}
|
|
4556
|
+
let r2Object;
|
|
4557
|
+
try {
|
|
4558
|
+
r2Object = await this.bucket.get(key);
|
|
4559
|
+
} catch (error) {
|
|
4560
|
+
this.logger.error("R2 download failed", { error, key });
|
|
4561
|
+
return Result.err({
|
|
4562
|
+
type: "DOWNLOAD_FAILED",
|
|
4563
|
+
message: `Failed to download backup: ${error}`,
|
|
4564
|
+
key,
|
|
4565
|
+
cause: error
|
|
4566
|
+
});
|
|
4567
|
+
}
|
|
4568
|
+
if (!r2Object) {
|
|
4569
|
+
return Result.err({
|
|
4570
|
+
type: "BACKUP_NOT_FOUND",
|
|
4571
|
+
message: `Backup not found: ${key}`,
|
|
4572
|
+
key
|
|
4573
|
+
});
|
|
4574
|
+
}
|
|
4575
|
+
const compressedData = await r2Object.arrayBuffer();
|
|
4576
|
+
try {
|
|
4577
|
+
const decompressed = gunzipSync(Buffer.from(compressedData));
|
|
4578
|
+
if (this.config.enableLogging) {
|
|
4579
|
+
this.logger.debug("Restore completed", {
|
|
4580
|
+
key,
|
|
4581
|
+
compressedSize: compressedData.byteLength,
|
|
4582
|
+
originalSize: decompressed.length
|
|
4583
|
+
});
|
|
4584
|
+
}
|
|
4585
|
+
return Result.ok(decompressed.buffer);
|
|
4586
|
+
} catch (error) {
|
|
4587
|
+
this.logger.error("Decompression failed", { error, key });
|
|
4588
|
+
return Result.err({
|
|
4589
|
+
type: "DECOMPRESSION_FAILED",
|
|
4590
|
+
message: `Failed to decompress backup: ${error}`,
|
|
4591
|
+
cause: error
|
|
4592
|
+
});
|
|
4593
|
+
}
|
|
4594
|
+
} catch (error) {
|
|
4595
|
+
this.logger.error("Restore operation failed", { error, key });
|
|
4596
|
+
return Result.err({
|
|
4597
|
+
type: "RESTORE_FAILED",
|
|
4598
|
+
message: `Restore failed: ${error}`,
|
|
4599
|
+
cause: error
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* List all backups
|
|
4605
|
+
*/
|
|
4606
|
+
async listBackups(options) {
|
|
4607
|
+
try {
|
|
4608
|
+
if (this.config.enableLogging) {
|
|
4609
|
+
this.logger.debug("R2 Backup operation", { operation: "listBackups" });
|
|
4610
|
+
}
|
|
4611
|
+
const r2Objects = await this.bucket.list({
|
|
4612
|
+
prefix: `${this.config.name}-backup`,
|
|
4613
|
+
limit: options?.limit,
|
|
4614
|
+
cursor: options?.cursor,
|
|
4615
|
+
include: ["customMetadata"]
|
|
4616
|
+
});
|
|
4617
|
+
const backups = r2Objects.objects.map((obj) => {
|
|
4618
|
+
const originalSize = parseInt(obj.customMetadata?.originalSize || "0", 10);
|
|
4619
|
+
const compressionRatio = parseFloat(
|
|
4620
|
+
obj.customMetadata?.compressionRatio || "1.0"
|
|
4621
|
+
);
|
|
4622
|
+
return {
|
|
4623
|
+
key: obj.key,
|
|
4624
|
+
size: originalSize,
|
|
4625
|
+
compressedSize: obj.size,
|
|
4626
|
+
compressionRatio,
|
|
4627
|
+
uploadedAt: obj.uploaded,
|
|
4628
|
+
etag: obj.etag,
|
|
4629
|
+
metadata: {
|
|
4630
|
+
databaseVersion: obj.customMetadata?.databaseVersion,
|
|
4631
|
+
backupType: obj.customMetadata?.backupType,
|
|
4632
|
+
originalChecksum: obj.customMetadata?.originalChecksum
|
|
4633
|
+
}
|
|
4634
|
+
};
|
|
4635
|
+
});
|
|
4636
|
+
return Result.ok(backups);
|
|
4637
|
+
} catch (error) {
|
|
4638
|
+
this.logger.error("List backups failed", { error });
|
|
4639
|
+
return Result.err({
|
|
4640
|
+
type: "UNKNOWN",
|
|
4641
|
+
message: `Failed to list backups: ${error}`,
|
|
4642
|
+
cause: error
|
|
4643
|
+
});
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
/**
|
|
4647
|
+
* Delete a single backup
|
|
4648
|
+
*/
|
|
4649
|
+
async deleteBackup(key) {
|
|
4650
|
+
try {
|
|
4651
|
+
if (this.config.enableLogging) {
|
|
4652
|
+
this.logger.debug("R2 Backup operation", { operation: "deleteBackup", key });
|
|
4653
|
+
}
|
|
4654
|
+
await this.bucket.delete(key);
|
|
4655
|
+
return Result.ok(void 0);
|
|
4656
|
+
} catch (error) {
|
|
4657
|
+
this.logger.error("Delete backup failed", { error, key });
|
|
4658
|
+
return Result.err({
|
|
4659
|
+
type: "UNKNOWN",
|
|
4660
|
+
message: `Failed to delete backup: ${error}`,
|
|
4661
|
+
cause: error
|
|
4662
|
+
});
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
/**
|
|
4666
|
+
* Delete multiple backups (batched for efficiency)
|
|
4667
|
+
*/
|
|
4668
|
+
async deleteMultipleBackups(keys) {
|
|
4669
|
+
try {
|
|
4670
|
+
if (this.config.enableLogging) {
|
|
4671
|
+
this.logger.debug("R2 Backup operation", {
|
|
4672
|
+
operation: "deleteMultipleBackups",
|
|
4673
|
+
count: keys.length
|
|
4674
|
+
});
|
|
4675
|
+
}
|
|
4676
|
+
const batchSize = 1e3;
|
|
4677
|
+
const batches = [];
|
|
4678
|
+
for (let i = 0; i < keys.length; i += batchSize) {
|
|
4679
|
+
batches.push(keys.slice(i, i + batchSize));
|
|
4680
|
+
}
|
|
4681
|
+
for (const batch of batches) {
|
|
4682
|
+
await this.bucket.delete(batch);
|
|
4683
|
+
}
|
|
4684
|
+
return Result.ok(void 0);
|
|
4685
|
+
} catch (error) {
|
|
4686
|
+
this.logger.error("Delete multiple backups failed", { error, count: keys.length });
|
|
4687
|
+
return Result.err({
|
|
4688
|
+
type: "UNKNOWN",
|
|
4689
|
+
message: `Failed to delete backups: ${error}`,
|
|
4690
|
+
cause: error
|
|
4691
|
+
});
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
};
|
|
4695
|
+
|
|
4696
|
+
// src/index.ts
|
|
4697
|
+
var version = "1.0.0";
|
|
4698
|
+
|
|
4699
|
+
export { CacheInvalidator, D1DatabaseAdapter, DataSeeder, DatabaseConnectionManager, DatabaseTransaction, DurableObjectStorageAdapter, KVCache, MemoryCache, MigrationLoader, MigrationRunner, R2BackupAdapter, RawSQLExecutor, TenantAwareQueryBuilder, TenantContextManager, TenantSchemaManager, TypeSafeQueryBuilder, version };
|
|
4700
|
+
//# sourceMappingURL=index.js.map
|
|
4701
|
+
//# sourceMappingURL=index.js.map
|