@getstrata/core 2.2.10 → 2.2.11
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/CHANGELOG.md +4 -0
- package/dist/core/database/baseRepository.d.ts +5 -0
- package/dist/core/database/isolatedReadOnlyObservation.d.ts +15 -0
- package/dist/core/database/mysqlConnection.d.ts +3 -0
- package/dist/core/database/readOnlyCollector.d.ts +7 -0
- package/dist/core/database/sqliteConnection.d.ts +3 -0
- package/dist/core/events/outbox/metrics.d.ts +1 -1
- package/dist/entries/database/sqliteConnection.js +137 -1
- package/dist/entries/queue/queueMetrics.js +61 -27
- package/dist/index.js +281 -38
- package/package.json +1 -1
- package/dist/core/database/postgresReadOnlyCollector.d.ts +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @getstrata/core changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.11
|
|
4
|
+
|
|
5
|
+
Add independently interruptible read-only SQLite and MySQL observations; extend bounded failed-job and outbox metrics to file-backed SQLite and URL-backed MySQL with schema/index guards, real-engine regressions and three-process consistency tests. Preserve Postgres RLS and business transaction behavior.
|
|
6
|
+
|
|
3
7
|
## 2.2.10
|
|
4
8
|
|
|
5
9
|
- Add bounded OpenTelemetry SDK health observations and selected native transaction-acquisition histograms through explicit metrics options.
|
|
@@ -17,6 +17,11 @@ interface DatabaseConnection {
|
|
|
17
17
|
}): Promise<DatabaseConnection & {
|
|
18
18
|
release(): void | Promise<void>;
|
|
19
19
|
}>;
|
|
20
|
+
/** Optional independent, interruptible read-only observation; no business transaction or pool slot. */
|
|
21
|
+
observeReadOnly?<T>(operation: (connection: DatabaseConnection) => Promise<T>, options: {
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
}): Promise<T>;
|
|
20
25
|
/** SQLite closes synchronously; pooled drivers return a promise. */
|
|
21
26
|
close?(): void | Promise<void>;
|
|
22
27
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DatabaseConnection } from "./baseRepository";
|
|
2
|
+
interface ReadOnlyObservationOptions {
|
|
3
|
+
signal: AbortSignal;
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
}
|
|
6
|
+
declare function observeIsolatedReadOnly<T>(profile: {
|
|
7
|
+
driver: "sqlite";
|
|
8
|
+
filename: string;
|
|
9
|
+
} | {
|
|
10
|
+
driver: "mysql";
|
|
11
|
+
url: string;
|
|
12
|
+
module: string;
|
|
13
|
+
}, operation: (connection: DatabaseConnection) => Promise<T>, options: ReadOnlyObservationOptions): Promise<T>;
|
|
14
|
+
export type { ReadOnlyObservationOptions };
|
|
15
|
+
export { observeIsolatedReadOnly };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { DatabaseConnection } from "./baseRepository.ts";
|
|
1
2
|
import { type ActiveDatabaseHandle } from "./connectionContext.ts";
|
|
3
|
+
import { type ReadOnlyObservationOptions } from "./isolatedReadOnlyObservation.ts";
|
|
2
4
|
type MysqlExecutable = {
|
|
3
5
|
execute: (sql: string, params?: unknown[]) => Promise<[unknown, unknown]>;
|
|
4
6
|
query?: (sql: string) => Promise<[unknown, unknown]>;
|
|
@@ -19,6 +21,7 @@ type MysqlPoolConnection = MysqlExecutable & {
|
|
|
19
21
|
type MysqlConnection = ActiveDatabaseHandle & {
|
|
20
22
|
begin<T>(callback: (transaction: ActiveDatabaseHandle) => Promise<T>): Promise<T>;
|
|
21
23
|
close(): Promise<void>;
|
|
24
|
+
observeReadOnly<T>(operation: (connection: DatabaseConnection) => Promise<T>, options: ReadOnlyObservationOptions): Promise<T>;
|
|
22
25
|
};
|
|
23
26
|
declare function resetMysqlLoaderForTests(importer?: () => Promise<unknown>): void;
|
|
24
27
|
declare function createMysqlConnectionFromPool(pool: MysqlExecutable): MysqlConnection;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface ReadOnlyCollector<T> {
|
|
2
|
+
collect(): Promise<T>;
|
|
3
|
+
close(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
/** Internal shared observation lifecycle. No bypass, payload or tenant enumeration. */
|
|
6
|
+
declare function createReadOnlyCollector<T>(name: string, timeoutMs: number, collect: (remaining: () => number) => Promise<T>): ReadOnlyCollector<T>;
|
|
7
|
+
export { createReadOnlyCollector };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { DatabaseConnection } from "./baseRepository.ts";
|
|
1
2
|
import { type ActiveDatabaseHandle } from "./connectionContext.ts";
|
|
3
|
+
import { type ReadOnlyObservationOptions } from "./isolatedReadOnlyObservation.ts";
|
|
2
4
|
type SqliteConnection = ActiveDatabaseHandle & {
|
|
3
5
|
begin<T>(callback: (transaction: ActiveDatabaseHandle) => Promise<T>): Promise<T>;
|
|
6
|
+
observeReadOnly<T>(operation: (connection: DatabaseConnection) => Promise<T>, options: ReadOnlyObservationOptions): Promise<T>;
|
|
4
7
|
close(): void;
|
|
5
8
|
};
|
|
6
9
|
declare function createSqliteConnection(filename: string): SqliteConnection;
|
|
@@ -19,7 +19,7 @@ interface OutboxMetricsCollector {
|
|
|
19
19
|
}
|
|
20
20
|
declare const snapshotQuery: string;
|
|
21
21
|
/** Explicit platform observation, using the same transaction-local RLS bypass as coordination.
|
|
22
|
-
*
|
|
22
|
+
* Postgres uses scoped bypass; official SQLite/MySQL adapters use isolated read-only snapshots.
|
|
23
23
|
*/
|
|
24
24
|
declare function createOutboxMetricsCollector(options?: OutboxMetricsOptions): OutboxMetricsCollector;
|
|
25
25
|
declare function renderOutboxMetrics(snapshot: OutboxMetricsSnapshot): string;
|
|
@@ -45,6 +45,126 @@ var init_connectionContext = __esm(() => {
|
|
|
45
45
|
// ../../src/core/database/sqliteConnection.ts
|
|
46
46
|
init_connectionContext();
|
|
47
47
|
import { Database } from "bun:sqlite";
|
|
48
|
+
import { resolve } from "path";
|
|
49
|
+
|
|
50
|
+
// ../../src/core/database/isolatedReadOnlyObservation.ts
|
|
51
|
+
var childSource = `
|
|
52
|
+
import { Database } from 'bun:sqlite';
|
|
53
|
+
let db;
|
|
54
|
+
let mysql;
|
|
55
|
+
process.on('message', async message => {
|
|
56
|
+
try {
|
|
57
|
+
if (message.init) {
|
|
58
|
+
if (message.driver === 'mysql') {
|
|
59
|
+
const module = await import(message.module);
|
|
60
|
+
mysql = await module.createConnection({ uri: message.url, timezone: 'Z', connectTimeout: message.timeoutMs });
|
|
61
|
+
await mysql.query("SET time_zone = '+00:00'");
|
|
62
|
+
await mysql.query('SET SESSION max_execution_time = ' + message.timeoutMs);
|
|
63
|
+
await mysql.query('SET SESSION lock_wait_timeout = 1');
|
|
64
|
+
await mysql.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
|
65
|
+
await mysql.query('START TRANSACTION READ ONLY');
|
|
66
|
+
} else {
|
|
67
|
+
db = new Database(message.filename, { readonly: true, create: false });
|
|
68
|
+
db.exec('PRAGMA busy_timeout = 0');
|
|
69
|
+
db.exec('PRAGMA query_only = ON');
|
|
70
|
+
db.exec('BEGIN');
|
|
71
|
+
}
|
|
72
|
+
process.send({ ok: true, rows: [] });
|
|
73
|
+
} else {
|
|
74
|
+
const rows = mysql ? (message.params.length ? await mysql.execute(message.query, message.params) : await mysql.query(message.query))[0] : db.query(message.query).all(...message.params);
|
|
75
|
+
process.send({ ok: true, rows });
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// No SQL, file paths, payloads or driver exception text crosses the error channel.
|
|
79
|
+
process.send({ ok: false });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
`;
|
|
83
|
+
async function observeIsolatedReadOnly(profile, operation, options) {
|
|
84
|
+
options.signal.throwIfAborted();
|
|
85
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 5000)
|
|
86
|
+
throw new TypeError("SQL observation timeout must be between 1 and 5000 milliseconds.");
|
|
87
|
+
const executable = Bun.which("bun", { PATH: process.env.PATH ?? "" });
|
|
88
|
+
if (!executable)
|
|
89
|
+
throw new Error("SQL observations require the Bun CLI on PATH.");
|
|
90
|
+
let pending;
|
|
91
|
+
let stopped = false;
|
|
92
|
+
const { promise: aborted, reject: rejectAbort } = Promise.withResolvers();
|
|
93
|
+
const fail = (error) => {
|
|
94
|
+
pending?.reject(error);
|
|
95
|
+
pending = undefined;
|
|
96
|
+
};
|
|
97
|
+
const child = Bun.spawn({
|
|
98
|
+
cmd: [
|
|
99
|
+
executable,
|
|
100
|
+
"--no-env-file",
|
|
101
|
+
`--config=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
|
|
102
|
+
"-e",
|
|
103
|
+
childSource
|
|
104
|
+
],
|
|
105
|
+
env: {},
|
|
106
|
+
stdout: "ignore",
|
|
107
|
+
stderr: "ignore",
|
|
108
|
+
serialization: "advanced",
|
|
109
|
+
ipc(message) {
|
|
110
|
+
const response = message;
|
|
111
|
+
const request = pending;
|
|
112
|
+
pending = undefined;
|
|
113
|
+
if (response?.ok === true && Array.isArray(response.rows))
|
|
114
|
+
request?.resolve(response.rows);
|
|
115
|
+
else
|
|
116
|
+
request?.reject(new Error("Read-only SQL observation failed."));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const exited = child.exited.then(() => {
|
|
120
|
+
fail(new Error("SQL observation process exited."));
|
|
121
|
+
});
|
|
122
|
+
const abort = (reason) => {
|
|
123
|
+
stopped = true;
|
|
124
|
+
child.kill("SIGKILL");
|
|
125
|
+
fail(reason);
|
|
126
|
+
rejectAbort(reason);
|
|
127
|
+
};
|
|
128
|
+
const onAbort = () => abort(options.signal.reason);
|
|
129
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
const timer = setTimeout(() => abort(new Error("SQL observation timed out.")), options.timeoutMs);
|
|
131
|
+
const request = (message) => {
|
|
132
|
+
if (stopped)
|
|
133
|
+
return Promise.reject(new Error("SQL observation is closed."));
|
|
134
|
+
if (pending)
|
|
135
|
+
return Promise.reject(new Error("Concurrent SQL observation queries are not supported."));
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
pending = { resolve, reject };
|
|
138
|
+
child.send(message);
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
if (options.signal.aborted)
|
|
143
|
+
onAbort();
|
|
144
|
+
const work = (async () => {
|
|
145
|
+
await request({ init: true, ...profile, timeoutMs: options.timeoutMs });
|
|
146
|
+
options.signal.throwIfAborted();
|
|
147
|
+
return operation({
|
|
148
|
+
async unsafe(query, params = []) {
|
|
149
|
+
return await request({
|
|
150
|
+
query,
|
|
151
|
+
params: params.map((value) => profile.driver === "sqlite" && value instanceof Date ? value.toISOString() : value)
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
})();
|
|
156
|
+
return await Promise.race([work, aborted]);
|
|
157
|
+
} finally {
|
|
158
|
+
stopped = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
161
|
+
child.kill("SIGKILL");
|
|
162
|
+
fail(new Error("SQL observation is closed."));
|
|
163
|
+
await exited;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ../../src/core/database/sqliteConnection.ts
|
|
48
168
|
function isRowReturning(sql) {
|
|
49
169
|
const upper = sql.replace(/\s+/g, " ").trim().toUpperCase();
|
|
50
170
|
if (upper.includes(" RETURNING ")) {
|
|
@@ -56,7 +176,8 @@ function createSqliteConnection(filename) {
|
|
|
56
176
|
if (!filename.trim()) {
|
|
57
177
|
throw new Error("SQLite path is not configured. Pass a filename or :memory:.");
|
|
58
178
|
}
|
|
59
|
-
const
|
|
179
|
+
const absoluteFilename = filename === ":memory:" ? filename : resolve(filename);
|
|
180
|
+
const db = new Database(absoluteFilename, { create: true });
|
|
60
181
|
db.exec("PRAGMA foreign_keys = ON");
|
|
61
182
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
62
183
|
if (filename !== ":memory:") {
|
|
@@ -119,6 +240,21 @@ function createSqliteConnection(filename) {
|
|
|
119
240
|
}
|
|
120
241
|
});
|
|
121
242
|
},
|
|
243
|
+
async observeReadOnly(operation, options) {
|
|
244
|
+
if (closed)
|
|
245
|
+
throw new Error("SQLite connection is closed.");
|
|
246
|
+
if (absoluteFilename === ":memory:")
|
|
247
|
+
throw new Error("SQL observations require file-backed SQLite.");
|
|
248
|
+
pending++;
|
|
249
|
+
try {
|
|
250
|
+
return await observeIsolatedReadOnly({ driver: "sqlite", filename: absoluteFilename }, async (transaction) => {
|
|
251
|
+
transactions.add(transaction);
|
|
252
|
+
return await runWithDatabaseConnection(transaction, () => operation(transaction));
|
|
253
|
+
}, options);
|
|
254
|
+
} finally {
|
|
255
|
+
pending--;
|
|
256
|
+
}
|
|
257
|
+
},
|
|
122
258
|
close() {
|
|
123
259
|
if (pending > 0)
|
|
124
260
|
throw new Error("Drain SQLite operations before closing the connection.");
|
|
@@ -26,22 +26,6 @@ function createAsyncContextStore(key) {
|
|
|
26
26
|
}
|
|
27
27
|
var init_asyncContextStore = () => {};
|
|
28
28
|
|
|
29
|
-
// ../../src/core/database/connectionContext.ts
|
|
30
|
-
function runWithDatabaseConnection(connection, callback) {
|
|
31
|
-
return activeConnection.run(connection, callback);
|
|
32
|
-
}
|
|
33
|
-
function getActiveDatabaseConnection(fallback) {
|
|
34
|
-
return activeConnection.getStore() ?? fallback;
|
|
35
|
-
}
|
|
36
|
-
function hasActiveDatabaseConnection() {
|
|
37
|
-
return activeConnection.getStore() !== undefined;
|
|
38
|
-
}
|
|
39
|
-
var activeConnection;
|
|
40
|
-
var init_connectionContext = __esm(() => {
|
|
41
|
-
init_asyncContextStore();
|
|
42
|
-
activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
29
|
// ../../src/core/database/schema/driver.ts
|
|
46
30
|
function normalizeConnectionName(connection) {
|
|
47
31
|
const normalized = connection.trim().toLowerCase();
|
|
@@ -243,6 +227,22 @@ var init_dialect = __esm(() => {
|
|
|
243
227
|
dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
|
|
244
228
|
});
|
|
245
229
|
|
|
230
|
+
// ../../src/core/database/connectionContext.ts
|
|
231
|
+
function runWithDatabaseConnection(connection, callback) {
|
|
232
|
+
return activeConnection.run(connection, callback);
|
|
233
|
+
}
|
|
234
|
+
function getActiveDatabaseConnection(fallback) {
|
|
235
|
+
return activeConnection.getStore() ?? fallback;
|
|
236
|
+
}
|
|
237
|
+
function hasActiveDatabaseConnection() {
|
|
238
|
+
return activeConnection.getStore() !== undefined;
|
|
239
|
+
}
|
|
240
|
+
var activeConnection;
|
|
241
|
+
var init_connectionContext = __esm(() => {
|
|
242
|
+
init_asyncContextStore();
|
|
243
|
+
activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
246
|
// ../../src/core/database/boundConnection.ts
|
|
247
247
|
function boundConnectionState() {
|
|
248
248
|
const globalRecord = globalThis;
|
|
@@ -2192,12 +2192,15 @@ function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService(),
|
|
|
2192
2192
|
});
|
|
2193
2193
|
}
|
|
2194
2194
|
|
|
2195
|
-
// ../../src/core/
|
|
2195
|
+
// ../../src/core/queue/failedJobMetrics.ts
|
|
2196
|
+
init_dialect();
|
|
2197
|
+
|
|
2198
|
+
// ../../src/core/database/readOnlyCollector.ts
|
|
2196
2199
|
init_connectionContext();
|
|
2197
2200
|
init_dialect();
|
|
2198
2201
|
init_repositoryConnection();
|
|
2199
2202
|
init_transaction();
|
|
2200
|
-
function
|
|
2203
|
+
function createReadOnlyCollector(name, timeoutMs, collect) {
|
|
2201
2204
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000)
|
|
2202
2205
|
throw new TypeError(`${name} timeout must be between 1 and 5000 milliseconds.`);
|
|
2203
2206
|
let active;
|
|
@@ -2208,8 +2211,10 @@ function createPostgresReadOnlyCollector(name, timeoutMs, collect) {
|
|
|
2208
2211
|
throw new Error(`${name} collector is closed.`);
|
|
2209
2212
|
if (hasActiveTransaction() || hasActiveDatabaseConnection())
|
|
2210
2213
|
throw new Error(`${name} require an independent transaction.`);
|
|
2211
|
-
|
|
2212
|
-
|
|
2214
|
+
const driver = currentSqlDialect().driver;
|
|
2215
|
+
const pool = resolveRepositoryConnection();
|
|
2216
|
+
if (driver !== "pgsql" && driver !== "sqlite" && driver !== "mysql")
|
|
2217
|
+
throw new Error(`${name} require Postgres or an interruptible SQLite/MySQL adapter.`);
|
|
2213
2218
|
if (active)
|
|
2214
2219
|
throw new Error(`${name} collection is still settling.`);
|
|
2215
2220
|
const deadline = performance.now() + timeoutMs;
|
|
@@ -2222,14 +2227,25 @@ function createPostgresReadOnlyCollector(name, timeoutMs, collect) {
|
|
|
2222
2227
|
throw new Error(`${name} collection timed out.`);
|
|
2223
2228
|
return value;
|
|
2224
2229
|
};
|
|
2225
|
-
const work = runInTransaction(async () => {
|
|
2226
|
-
await repositoryConnection.unsafe("SELECT set_config('statement_timeout', $1, true)", [
|
|
2230
|
+
const work = driver === "pgsql" ? runInTransaction(async () => {
|
|
2231
|
+
await repositoryConnection.unsafe("SELECT set_config('statement_timeout', $1, true)", [
|
|
2232
|
+
`${remaining()}ms`
|
|
2233
|
+
]);
|
|
2227
2234
|
await repositoryConnection.unsafe("SET TRANSACTION READ ONLY");
|
|
2228
2235
|
remaining();
|
|
2229
2236
|
const result = await collect(remaining);
|
|
2230
2237
|
remaining();
|
|
2231
2238
|
return result;
|
|
2232
|
-
}, { acquisitionSignal: acquisition.signal })
|
|
2239
|
+
}, { acquisitionSignal: acquisition.signal }) : (async () => {
|
|
2240
|
+
if (!pool.observeReadOnly)
|
|
2241
|
+
throw new Error(`${name} require Postgres or an interruptible SQLite/MySQL adapter.`);
|
|
2242
|
+
return pool.observeReadOnly(async (connection) => await runWithDatabaseConnection(connection, async () => {
|
|
2243
|
+
remaining();
|
|
2244
|
+
const result = await collect(remaining);
|
|
2245
|
+
remaining();
|
|
2246
|
+
return result;
|
|
2247
|
+
}), { signal: acquisition.signal, timeoutMs: remaining() });
|
|
2248
|
+
})();
|
|
2233
2249
|
active = work;
|
|
2234
2250
|
work.finally(() => {
|
|
2235
2251
|
if (active === work)
|
|
@@ -2267,15 +2283,33 @@ var schemaQuery = `SELECT c.relkind = 'r' AND c.relpersistence = 'p' AND NOT c.r
|
|
|
2267
2283
|
AND i.indnkeyatts = 1 AND a.attname = 'id' AND NOT a.attisdropped
|
|
2268
2284
|
AND a.atttypid IN ('int2'::regtype, 'int4'::regtype, 'int8'::regtype)
|
|
2269
2285
|
) AS supported FROM pg_class c WHERE c.oid = 'failed_job'::regclass`;
|
|
2286
|
+
var sqliteSchemaQuery = `SELECT 1 AS supported FROM main.sqlite_schema
|
|
2287
|
+
WHERE name='failed_job' AND type='table' AND rootpage > 0
|
|
2288
|
+
AND (SELECT COUNT(*) FROM pragma_table_xinfo('failed_job', 'main') WHERE pk > 0) = 1
|
|
2289
|
+
AND EXISTS (SELECT 1 FROM pragma_table_xinfo('failed_job', 'main')
|
|
2290
|
+
WHERE name='id' AND pk=1 AND upper(type)='INTEGER' AND hidden=0) LIMIT 1`;
|
|
2291
|
+
var mysqlSchemaQuery = `SELECT 1 AS supported FROM information_schema.TABLES t
|
|
2292
|
+
WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME='failed_job' AND t.TABLE_TYPE='BASE TABLE' AND t.ENGINE='InnoDB'
|
|
2293
|
+
AND (SELECT COUNT(*) FROM information_schema.STATISTICS i WHERE i.TABLE_SCHEMA=t.TABLE_SCHEMA
|
|
2294
|
+
AND i.TABLE_NAME=t.TABLE_NAME AND i.INDEX_NAME='PRIMARY')=1
|
|
2295
|
+
AND EXISTS(SELECT 1 FROM information_schema.STATISTICS i JOIN information_schema.COLUMNS c
|
|
2296
|
+
ON c.TABLE_SCHEMA=i.TABLE_SCHEMA AND c.TABLE_NAME=i.TABLE_NAME AND c.COLUMN_NAME=i.COLUMN_NAME
|
|
2297
|
+
WHERE i.TABLE_SCHEMA=t.TABLE_SCHEMA AND i.TABLE_NAME=t.TABLE_NAME AND i.INDEX_NAME='PRIMARY'
|
|
2298
|
+
AND i.SEQ_IN_INDEX=1 AND i.COLUMN_NAME='id' AND c.DATA_TYPE IN ('tinyint','smallint','mediumint','int','bigint')) LIMIT 1`;
|
|
2270
2299
|
function createFailedJobMetricsCollector(options = {}) {
|
|
2271
2300
|
const sampleLimit = options.sampleLimit ?? 500;
|
|
2272
2301
|
if (!Number.isSafeInteger(sampleLimit) || sampleLimit < 1 || sampleLimit > 1000)
|
|
2273
2302
|
throw new TypeError("Failed-job metrics sample limit must be between 1 and 1000.");
|
|
2274
2303
|
const repository = new failedJobRepository_default;
|
|
2275
|
-
return
|
|
2276
|
-
|
|
2277
|
-
const
|
|
2278
|
-
if (
|
|
2304
|
+
return createReadOnlyCollector("Failed-job metrics", options.timeoutMs ?? 1000, async (remaining) => {
|
|
2305
|
+
const driver = currentSqlDialect().driver;
|
|
2306
|
+
const postgres = driver === "pgsql";
|
|
2307
|
+
if (postgres)
|
|
2308
|
+
await repositoryConnection.unsafe('LOCK TABLE "failed_job" IN ACCESS SHARE MODE');
|
|
2309
|
+
if (driver === "mysql")
|
|
2310
|
+
await repositoryConnection.unsafe("SELECT id FROM failed_job LIMIT 0");
|
|
2311
|
+
const schema = await repositoryConnection.unsafe(postgres ? schemaQuery : driver === "mysql" ? mysqlSchemaQuery : sqliteSchemaQuery);
|
|
2312
|
+
if (schema.length !== 1 || schema[0]?.supported !== (postgres ? true : 1))
|
|
2279
2313
|
throw new Error("Failed-job metrics require the standard global table with an ID primary key and no RLS.");
|
|
2280
2314
|
remaining();
|
|
2281
2315
|
const rows = await repository.query().orderBy({ column: "id", direction: "ASC" }).project(["id"], sampleLimit + 1);
|
package/dist/index.js
CHANGED
|
@@ -8309,6 +8309,125 @@ function registerModelRepository(model, repository) {
|
|
|
8309
8309
|
}
|
|
8310
8310
|
// ../../src/core/database/mysqlConnection.ts
|
|
8311
8311
|
init_connectionContext();
|
|
8312
|
+
|
|
8313
|
+
// ../../src/core/database/isolatedReadOnlyObservation.ts
|
|
8314
|
+
var childSource = `
|
|
8315
|
+
import { Database } from 'bun:sqlite';
|
|
8316
|
+
let db;
|
|
8317
|
+
let mysql;
|
|
8318
|
+
process.on('message', async message => {
|
|
8319
|
+
try {
|
|
8320
|
+
if (message.init) {
|
|
8321
|
+
if (message.driver === 'mysql') {
|
|
8322
|
+
const module = await import(message.module);
|
|
8323
|
+
mysql = await module.createConnection({ uri: message.url, timezone: 'Z', connectTimeout: message.timeoutMs });
|
|
8324
|
+
await mysql.query("SET time_zone = '+00:00'");
|
|
8325
|
+
await mysql.query('SET SESSION max_execution_time = ' + message.timeoutMs);
|
|
8326
|
+
await mysql.query('SET SESSION lock_wait_timeout = 1');
|
|
8327
|
+
await mysql.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
|
8328
|
+
await mysql.query('START TRANSACTION READ ONLY');
|
|
8329
|
+
} else {
|
|
8330
|
+
db = new Database(message.filename, { readonly: true, create: false });
|
|
8331
|
+
db.exec('PRAGMA busy_timeout = 0');
|
|
8332
|
+
db.exec('PRAGMA query_only = ON');
|
|
8333
|
+
db.exec('BEGIN');
|
|
8334
|
+
}
|
|
8335
|
+
process.send({ ok: true, rows: [] });
|
|
8336
|
+
} else {
|
|
8337
|
+
const rows = mysql ? (message.params.length ? await mysql.execute(message.query, message.params) : await mysql.query(message.query))[0] : db.query(message.query).all(...message.params);
|
|
8338
|
+
process.send({ ok: true, rows });
|
|
8339
|
+
}
|
|
8340
|
+
} catch {
|
|
8341
|
+
// No SQL, file paths, payloads or driver exception text crosses the error channel.
|
|
8342
|
+
process.send({ ok: false });
|
|
8343
|
+
}
|
|
8344
|
+
});
|
|
8345
|
+
`;
|
|
8346
|
+
async function observeIsolatedReadOnly(profile, operation, options) {
|
|
8347
|
+
options.signal.throwIfAborted();
|
|
8348
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 5000)
|
|
8349
|
+
throw new TypeError("SQL observation timeout must be between 1 and 5000 milliseconds.");
|
|
8350
|
+
const executable = Bun.which("bun", { PATH: process.env.PATH ?? "" });
|
|
8351
|
+
if (!executable)
|
|
8352
|
+
throw new Error("SQL observations require the Bun CLI on PATH.");
|
|
8353
|
+
let pending;
|
|
8354
|
+
let stopped = false;
|
|
8355
|
+
const { promise: aborted, reject: rejectAbort } = Promise.withResolvers();
|
|
8356
|
+
const fail = (error) => {
|
|
8357
|
+
pending?.reject(error);
|
|
8358
|
+
pending = undefined;
|
|
8359
|
+
};
|
|
8360
|
+
const child = Bun.spawn({
|
|
8361
|
+
cmd: [
|
|
8362
|
+
executable,
|
|
8363
|
+
"--no-env-file",
|
|
8364
|
+
`--config=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
|
|
8365
|
+
"-e",
|
|
8366
|
+
childSource
|
|
8367
|
+
],
|
|
8368
|
+
env: {},
|
|
8369
|
+
stdout: "ignore",
|
|
8370
|
+
stderr: "ignore",
|
|
8371
|
+
serialization: "advanced",
|
|
8372
|
+
ipc(message) {
|
|
8373
|
+
const response = message;
|
|
8374
|
+
const request = pending;
|
|
8375
|
+
pending = undefined;
|
|
8376
|
+
if (response?.ok === true && Array.isArray(response.rows))
|
|
8377
|
+
request?.resolve(response.rows);
|
|
8378
|
+
else
|
|
8379
|
+
request?.reject(new Error("Read-only SQL observation failed."));
|
|
8380
|
+
}
|
|
8381
|
+
});
|
|
8382
|
+
const exited = child.exited.then(() => {
|
|
8383
|
+
fail(new Error("SQL observation process exited."));
|
|
8384
|
+
});
|
|
8385
|
+
const abort = (reason) => {
|
|
8386
|
+
stopped = true;
|
|
8387
|
+
child.kill("SIGKILL");
|
|
8388
|
+
fail(reason);
|
|
8389
|
+
rejectAbort(reason);
|
|
8390
|
+
};
|
|
8391
|
+
const onAbort = () => abort(options.signal.reason);
|
|
8392
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
8393
|
+
const timer = setTimeout(() => abort(new Error("SQL observation timed out.")), options.timeoutMs);
|
|
8394
|
+
const request = (message) => {
|
|
8395
|
+
if (stopped)
|
|
8396
|
+
return Promise.reject(new Error("SQL observation is closed."));
|
|
8397
|
+
if (pending)
|
|
8398
|
+
return Promise.reject(new Error("Concurrent SQL observation queries are not supported."));
|
|
8399
|
+
return new Promise((resolve, reject) => {
|
|
8400
|
+
pending = { resolve, reject };
|
|
8401
|
+
child.send(message);
|
|
8402
|
+
});
|
|
8403
|
+
};
|
|
8404
|
+
try {
|
|
8405
|
+
if (options.signal.aborted)
|
|
8406
|
+
onAbort();
|
|
8407
|
+
const work = (async () => {
|
|
8408
|
+
await request({ init: true, ...profile, timeoutMs: options.timeoutMs });
|
|
8409
|
+
options.signal.throwIfAborted();
|
|
8410
|
+
return operation({
|
|
8411
|
+
async unsafe(query, params = []) {
|
|
8412
|
+
return await request({
|
|
8413
|
+
query,
|
|
8414
|
+
params: params.map((value) => profile.driver === "sqlite" && value instanceof Date ? value.toISOString() : value)
|
|
8415
|
+
});
|
|
8416
|
+
}
|
|
8417
|
+
});
|
|
8418
|
+
})();
|
|
8419
|
+
return await Promise.race([work, aborted]);
|
|
8420
|
+
} finally {
|
|
8421
|
+
stopped = true;
|
|
8422
|
+
clearTimeout(timer);
|
|
8423
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
8424
|
+
child.kill("SIGKILL");
|
|
8425
|
+
fail(new Error("SQL observation is closed."));
|
|
8426
|
+
await exited;
|
|
8427
|
+
}
|
|
8428
|
+
}
|
|
8429
|
+
|
|
8430
|
+
// ../../src/core/database/mysqlConnection.ts
|
|
8312
8431
|
var MYSQL_SESSION_UTC = "SET time_zone = '+00:00'";
|
|
8313
8432
|
var mysqlModule;
|
|
8314
8433
|
var mysqlPending;
|
|
@@ -8368,8 +8487,10 @@ async function executeMysql(target, query, params) {
|
|
|
8368
8487
|
const [result] = params.length === 0 && target.query ? await target.query(query) : await target.execute(query, [...params]);
|
|
8369
8488
|
return rowsFromResult(result);
|
|
8370
8489
|
}
|
|
8371
|
-
function createMysqlAdapter(getPool, close) {
|
|
8490
|
+
function createMysqlAdapter(getPool, close, observer) {
|
|
8372
8491
|
const transactions = new WeakSet;
|
|
8492
|
+
const observations = new Set;
|
|
8493
|
+
let observationsClosed = false;
|
|
8373
8494
|
const connection = {
|
|
8374
8495
|
async unsafe(query, params = []) {
|
|
8375
8496
|
const active = getActiveDatabaseConnection(connection);
|
|
@@ -8421,7 +8542,27 @@ function createMysqlAdapter(getPool, close) {
|
|
|
8421
8542
|
reserved.release();
|
|
8422
8543
|
}
|
|
8423
8544
|
},
|
|
8424
|
-
|
|
8545
|
+
async observeReadOnly(operation, options) {
|
|
8546
|
+
if (observationsClosed)
|
|
8547
|
+
throw new Error("MySQL observations are closed.");
|
|
8548
|
+
if (!observer)
|
|
8549
|
+
throw new Error("MySQL observations require the URL-backed official adapter.");
|
|
8550
|
+
const work = observer(async (transaction) => {
|
|
8551
|
+
transactions.add(transaction);
|
|
8552
|
+
return await runWithDatabaseConnection(transaction, () => operation(transaction));
|
|
8553
|
+
}, options);
|
|
8554
|
+
observations.add(work);
|
|
8555
|
+
try {
|
|
8556
|
+
return await work;
|
|
8557
|
+
} finally {
|
|
8558
|
+
observations.delete(work);
|
|
8559
|
+
}
|
|
8560
|
+
},
|
|
8561
|
+
async close() {
|
|
8562
|
+
observationsClosed = true;
|
|
8563
|
+
await Promise.allSettled([...observations]);
|
|
8564
|
+
await close();
|
|
8565
|
+
}
|
|
8425
8566
|
};
|
|
8426
8567
|
return connection;
|
|
8427
8568
|
}
|
|
@@ -8470,6 +8611,10 @@ function createMysqlConnection(url) {
|
|
|
8470
8611
|
return;
|
|
8471
8612
|
});
|
|
8472
8613
|
await pool?.end();
|
|
8614
|
+
}, async (operation, options) => {
|
|
8615
|
+
await loadMysql();
|
|
8616
|
+
const module = import.meta.resolve("mysql2/promise");
|
|
8617
|
+
return await observeIsolatedReadOnly({ driver: "mysql", url, module }, operation, options);
|
|
8473
8618
|
});
|
|
8474
8619
|
}
|
|
8475
8620
|
// ../../src/core/database/namedConnections.ts
|
|
@@ -9196,6 +9341,7 @@ async function runSeedersFromDirectory(directory, db, options) {
|
|
|
9196
9341
|
// ../../src/core/database/sqliteConnection.ts
|
|
9197
9342
|
init_connectionContext();
|
|
9198
9343
|
import { Database } from "bun:sqlite";
|
|
9344
|
+
import { resolve } from "path";
|
|
9199
9345
|
function isRowReturning(sql) {
|
|
9200
9346
|
const upper = sql.replace(/\s+/g, " ").trim().toUpperCase();
|
|
9201
9347
|
if (upper.includes(" RETURNING ")) {
|
|
@@ -9207,7 +9353,8 @@ function createSqliteConnection(filename) {
|
|
|
9207
9353
|
if (!filename.trim()) {
|
|
9208
9354
|
throw new Error("SQLite path is not configured. Pass a filename or :memory:.");
|
|
9209
9355
|
}
|
|
9210
|
-
const
|
|
9356
|
+
const absoluteFilename = filename === ":memory:" ? filename : resolve(filename);
|
|
9357
|
+
const db = new Database(absoluteFilename, { create: true });
|
|
9211
9358
|
db.exec("PRAGMA foreign_keys = ON");
|
|
9212
9359
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
9213
9360
|
if (filename !== ":memory:") {
|
|
@@ -9270,6 +9417,21 @@ function createSqliteConnection(filename) {
|
|
|
9270
9417
|
}
|
|
9271
9418
|
});
|
|
9272
9419
|
},
|
|
9420
|
+
async observeReadOnly(operation, options) {
|
|
9421
|
+
if (closed)
|
|
9422
|
+
throw new Error("SQLite connection is closed.");
|
|
9423
|
+
if (absoluteFilename === ":memory:")
|
|
9424
|
+
throw new Error("SQL observations require file-backed SQLite.");
|
|
9425
|
+
pending++;
|
|
9426
|
+
try {
|
|
9427
|
+
return await observeIsolatedReadOnly({ driver: "sqlite", filename: absoluteFilename }, async (transaction) => {
|
|
9428
|
+
transactions.add(transaction);
|
|
9429
|
+
return await runWithDatabaseConnection(transaction, () => operation(transaction));
|
|
9430
|
+
}, options);
|
|
9431
|
+
} finally {
|
|
9432
|
+
pending--;
|
|
9433
|
+
}
|
|
9434
|
+
},
|
|
9273
9435
|
close() {
|
|
9274
9436
|
if (pending > 0)
|
|
9275
9437
|
throw new Error("Drain SQLite operations before closing the connection.");
|
|
@@ -9296,12 +9458,15 @@ init_databaseTenantContext();
|
|
|
9296
9458
|
init_tenantContext();
|
|
9297
9459
|
init_tenantDatabaseScope();
|
|
9298
9460
|
|
|
9299
|
-
// ../../src/core/
|
|
9461
|
+
// ../../src/core/events/outbox/metrics.ts
|
|
9462
|
+
init_dialect();
|
|
9463
|
+
|
|
9464
|
+
// ../../src/core/database/readOnlyCollector.ts
|
|
9300
9465
|
init_connectionContext();
|
|
9301
9466
|
init_dialect();
|
|
9302
9467
|
init_repositoryConnection();
|
|
9303
9468
|
init_transaction();
|
|
9304
|
-
function
|
|
9469
|
+
function createReadOnlyCollector(name, timeoutMs, collect) {
|
|
9305
9470
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000)
|
|
9306
9471
|
throw new TypeError(`${name} timeout must be between 1 and 5000 milliseconds.`);
|
|
9307
9472
|
let active;
|
|
@@ -9312,8 +9477,10 @@ function createPostgresReadOnlyCollector(name, timeoutMs, collect) {
|
|
|
9312
9477
|
throw new Error(`${name} collector is closed.`);
|
|
9313
9478
|
if (hasActiveTransaction() || hasActiveDatabaseConnection())
|
|
9314
9479
|
throw new Error(`${name} require an independent transaction.`);
|
|
9315
|
-
|
|
9316
|
-
|
|
9480
|
+
const driver = currentSqlDialect().driver;
|
|
9481
|
+
const pool = resolveRepositoryConnection();
|
|
9482
|
+
if (driver !== "pgsql" && driver !== "sqlite" && driver !== "mysql")
|
|
9483
|
+
throw new Error(`${name} require Postgres or an interruptible SQLite/MySQL adapter.`);
|
|
9317
9484
|
if (active)
|
|
9318
9485
|
throw new Error(`${name} collection is still settling.`);
|
|
9319
9486
|
const deadline = performance.now() + timeoutMs;
|
|
@@ -9326,14 +9493,25 @@ function createPostgresReadOnlyCollector(name, timeoutMs, collect) {
|
|
|
9326
9493
|
throw new Error(`${name} collection timed out.`);
|
|
9327
9494
|
return value;
|
|
9328
9495
|
};
|
|
9329
|
-
const work = runInTransaction(async () => {
|
|
9330
|
-
await repositoryConnection.unsafe("SELECT set_config('statement_timeout', $1, true)", [
|
|
9496
|
+
const work = driver === "pgsql" ? runInTransaction(async () => {
|
|
9497
|
+
await repositoryConnection.unsafe("SELECT set_config('statement_timeout', $1, true)", [
|
|
9498
|
+
`${remaining()}ms`
|
|
9499
|
+
]);
|
|
9331
9500
|
await repositoryConnection.unsafe("SET TRANSACTION READ ONLY");
|
|
9332
9501
|
remaining();
|
|
9333
9502
|
const result = await collect(remaining);
|
|
9334
9503
|
remaining();
|
|
9335
9504
|
return result;
|
|
9336
|
-
}, { acquisitionSignal: acquisition.signal })
|
|
9505
|
+
}, { acquisitionSignal: acquisition.signal }) : (async () => {
|
|
9506
|
+
if (!pool.observeReadOnly)
|
|
9507
|
+
throw new Error(`${name} require Postgres or an interruptible SQLite/MySQL adapter.`);
|
|
9508
|
+
return pool.observeReadOnly(async (connection) => await runWithDatabaseConnection(connection, async () => {
|
|
9509
|
+
remaining();
|
|
9510
|
+
const result = await collect(remaining);
|
|
9511
|
+
remaining();
|
|
9512
|
+
return result;
|
|
9513
|
+
}), { signal: acquisition.signal, timeoutMs: remaining() });
|
|
9514
|
+
})();
|
|
9337
9515
|
active = work;
|
|
9338
9516
|
work.finally(() => {
|
|
9339
9517
|
if (active === work)
|
|
@@ -9411,32 +9589,78 @@ ${specifications.map((spec, index) => `SELECT '${spec.state}' AS state, COUNT(*)
|
|
|
9411
9589
|
FROM sample_${index}`).join(`
|
|
9412
9590
|
UNION ALL
|
|
9413
9591
|
`)}`;
|
|
9592
|
+
async function readIndexedSnapshot(sampleLimit, remaining) {
|
|
9593
|
+
const mysql = currentSqlDialect().driver === "mysql";
|
|
9594
|
+
if (mysql)
|
|
9595
|
+
await repositoryConnection.unsafe("SELECT event_id FROM strata_outbox_delivery LIMIT 0");
|
|
9596
|
+
const schema = await repositoryConnection.unsafe(mysql ? "SELECT 1 AS valid FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='strata_outbox_delivery' AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' LIMIT 1" : "SELECT 1 AS valid FROM main.sqlite_schema WHERE name='strata_outbox_delivery' AND type='table' AND rootpage>0 LIMIT 1");
|
|
9597
|
+
if (schema.length !== 1 || schema[0]?.valid !== 1)
|
|
9598
|
+
throw new Error("Outbox metrics require the standard delivery table.");
|
|
9599
|
+
for (const [index, column] of [
|
|
9600
|
+
["strata_outbox_due", "available_at"],
|
|
9601
|
+
["strata_outbox_expired", "lease_until"]
|
|
9602
|
+
]) {
|
|
9603
|
+
const rows = await repositoryConnection.unsafe(mysql ? "SELECT IF(SUB_PART IS NULL AND COLLATION='A' AND IS_VISIBLE='YES', COLUMN_NAME, NULL) AS name, SEQ_IN_INDEX-1 AS seqno FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='strata_outbox_delivery' AND INDEX_NAME=? ORDER BY SEQ_IN_INDEX LIMIT 5" : "SELECT name, seqno FROM pragma_index_info(?, 'main') ORDER BY seqno LIMIT 5", [index]);
|
|
9604
|
+
const expected = ["status", column, "event_id", "listener_name"];
|
|
9605
|
+
if (rows.length !== expected.length || rows.some((row, i) => row.seqno !== i || row.name !== expected[i]))
|
|
9606
|
+
throw new Error("Outbox metrics require the standard delivery indexes.");
|
|
9607
|
+
remaining();
|
|
9608
|
+
}
|
|
9609
|
+
const [clock] = await repositoryConnection.unsafe(mysql ? "SELECT CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3))*1000 AS UNSIGNED) AS now_ms" : "SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now_ms");
|
|
9610
|
+
if (!Number.isSafeInteger(clock?.now_ms) || !clock || clock.now_ms < 0)
|
|
9611
|
+
throw new Error("Invalid outbox clock.");
|
|
9612
|
+
const rows = [];
|
|
9613
|
+
for (const spec of specifications) {
|
|
9614
|
+
remaining();
|
|
9615
|
+
const index = spec.column === "lease_until" ? "strata_outbox_expired" : "strata_outbox_due";
|
|
9616
|
+
const predicate = spec.predicate ? ` AND ${spec.column} ${spec.predicate}${spec.predicate === "IS NULL" ? "" : " ?"}` : "";
|
|
9617
|
+
const values = await repositoryConnection.unsafe(`SELECT ${spec.column} AS timestamp_ms
|
|
9618
|
+
FROM strata_outbox_delivery ${mysql ? `FORCE INDEX (${index})` : `INDEXED BY ${index}`} WHERE status = ?${predicate}
|
|
9619
|
+
ORDER BY ${spec.column}, event_id, listener_name LIMIT ?`, [
|
|
9620
|
+
spec.status,
|
|
9621
|
+
...spec.predicate && spec.predicate !== "IS NULL" ? [clock.now_ms] : [],
|
|
9622
|
+
sampleLimit + 1
|
|
9623
|
+
]);
|
|
9624
|
+
const timestamp = values[0]?.timestamp_ms;
|
|
9625
|
+
if (spec.age && values.length && (!Number.isSafeInteger(timestamp) || timestamp == null || timestamp < 0))
|
|
9626
|
+
throw new Error("Invalid outbox timestamp.");
|
|
9627
|
+
rows.push({
|
|
9628
|
+
state: spec.state,
|
|
9629
|
+
count: values.length,
|
|
9630
|
+
lateness_ms: spec.age ? Math.max(0, clock.now_ms - (timestamp ?? clock.now_ms)) : null
|
|
9631
|
+
});
|
|
9632
|
+
}
|
|
9633
|
+
return rows;
|
|
9634
|
+
}
|
|
9414
9635
|
function createOutboxMetricsCollector(options = {}) {
|
|
9415
9636
|
const timeoutMs = options.timeoutMs ?? 1000;
|
|
9416
9637
|
const sampleLimit = options.sampleLimit ?? 500;
|
|
9417
9638
|
if (!Number.isSafeInteger(sampleLimit) || sampleLimit < 1 || sampleLimit > 1000)
|
|
9418
9639
|
throw new TypeError("Outbox metrics sample limit must be between 1 and 1000.");
|
|
9419
|
-
return
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
const
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9640
|
+
return createReadOnlyCollector("Outbox metrics", timeoutMs, async (remaining) => {
|
|
9641
|
+
const read = async () => {
|
|
9642
|
+
remaining();
|
|
9643
|
+
const rows = currentSqlDialect().driver !== "pgsql" ? await readIndexedSnapshot(sampleLimit, remaining) : await repositoryConnection.unsafe(snapshotQuery, [sampleLimit + 1]);
|
|
9644
|
+
remaining();
|
|
9645
|
+
if (rows.length !== specifications.length)
|
|
9646
|
+
throw new Error("Invalid outbox metrics response.");
|
|
9647
|
+
const states = specifications.map((spec) => {
|
|
9648
|
+
const row = rows.find((value) => value.state === spec.state);
|
|
9649
|
+
const count = Number(row?.count);
|
|
9650
|
+
const lateness = row?.lateness_ms == null ? null : Number(row.lateness_ms);
|
|
9651
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > sampleLimit + 1 || spec.age && (lateness === null || !Number.isFinite(lateness) || lateness < 0))
|
|
9652
|
+
throw new Error("Invalid outbox metrics values.");
|
|
9653
|
+
return {
|
|
9654
|
+
state: spec.state,
|
|
9655
|
+
count: Math.min(count, sampleLimit),
|
|
9656
|
+
capped: count > sampleLimit,
|
|
9657
|
+
actionableLatenessSeconds: lateness === null ? null : lateness / 1000
|
|
9658
|
+
};
|
|
9659
|
+
});
|
|
9660
|
+
return { sampleLimit, states };
|
|
9661
|
+
};
|
|
9662
|
+
return currentSqlDialect().driver === "pgsql" ? runWithMigrationBypass(read) : read();
|
|
9663
|
+
});
|
|
9440
9664
|
}
|
|
9441
9665
|
function renderOutboxMetrics(snapshot) {
|
|
9442
9666
|
if (!Number.isSafeInteger(snapshot.sampleLimit) || snapshot.sampleLimit < 1 || snapshot.sampleLimit > 1000 || snapshot.states.length !== specifications.length)
|
|
@@ -10034,7 +10258,7 @@ import { dirname } from "path";
|
|
|
10034
10258
|
var {S3Client } = globalThis.Bun;
|
|
10035
10259
|
|
|
10036
10260
|
// ../../src/core/security/safePath.ts
|
|
10037
|
-
import { isAbsolute, resolve, sep } from "path";
|
|
10261
|
+
import { isAbsolute, resolve as resolve2, sep } from "path";
|
|
10038
10262
|
function assertPathUnderRoot(rootDirectory, userPath) {
|
|
10039
10263
|
const trimmed = userPath.trim();
|
|
10040
10264
|
if (trimmed.length === 0) {
|
|
@@ -10043,8 +10267,8 @@ function assertPathUnderRoot(rootDirectory, userPath) {
|
|
|
10043
10267
|
if (trimmed.includes("\x00")) {
|
|
10044
10268
|
throw new Error("Path must not contain a null byte.");
|
|
10045
10269
|
}
|
|
10046
|
-
const root =
|
|
10047
|
-
const candidate = isAbsolute(trimmed) ?
|
|
10270
|
+
const root = resolve2(rootDirectory);
|
|
10271
|
+
const candidate = isAbsolute(trimmed) ? resolve2(trimmed) : resolve2(root, trimmed.replace(/^\/+/u, ""));
|
|
10048
10272
|
if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
|
|
10049
10273
|
return candidate;
|
|
10050
10274
|
}
|
|
@@ -13002,6 +13226,7 @@ class Notification {
|
|
|
13002
13226
|
}
|
|
13003
13227
|
}
|
|
13004
13228
|
// ../../src/core/queue/failedJobMetrics.ts
|
|
13229
|
+
init_dialect();
|
|
13005
13230
|
init_repositoryConnection();
|
|
13006
13231
|
|
|
13007
13232
|
// ../../src/core/queue/failedJobTable.ts
|
|
@@ -13027,15 +13252,33 @@ var schemaQuery = `SELECT c.relkind = 'r' AND c.relpersistence = 'p' AND NOT c.r
|
|
|
13027
13252
|
AND i.indnkeyatts = 1 AND a.attname = 'id' AND NOT a.attisdropped
|
|
13028
13253
|
AND a.atttypid IN ('int2'::regtype, 'int4'::regtype, 'int8'::regtype)
|
|
13029
13254
|
) AS supported FROM pg_class c WHERE c.oid = 'failed_job'::regclass`;
|
|
13255
|
+
var sqliteSchemaQuery = `SELECT 1 AS supported FROM main.sqlite_schema
|
|
13256
|
+
WHERE name='failed_job' AND type='table' AND rootpage > 0
|
|
13257
|
+
AND (SELECT COUNT(*) FROM pragma_table_xinfo('failed_job', 'main') WHERE pk > 0) = 1
|
|
13258
|
+
AND EXISTS (SELECT 1 FROM pragma_table_xinfo('failed_job', 'main')
|
|
13259
|
+
WHERE name='id' AND pk=1 AND upper(type)='INTEGER' AND hidden=0) LIMIT 1`;
|
|
13260
|
+
var mysqlSchemaQuery = `SELECT 1 AS supported FROM information_schema.TABLES t
|
|
13261
|
+
WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME='failed_job' AND t.TABLE_TYPE='BASE TABLE' AND t.ENGINE='InnoDB'
|
|
13262
|
+
AND (SELECT COUNT(*) FROM information_schema.STATISTICS i WHERE i.TABLE_SCHEMA=t.TABLE_SCHEMA
|
|
13263
|
+
AND i.TABLE_NAME=t.TABLE_NAME AND i.INDEX_NAME='PRIMARY')=1
|
|
13264
|
+
AND EXISTS(SELECT 1 FROM information_schema.STATISTICS i JOIN information_schema.COLUMNS c
|
|
13265
|
+
ON c.TABLE_SCHEMA=i.TABLE_SCHEMA AND c.TABLE_NAME=i.TABLE_NAME AND c.COLUMN_NAME=i.COLUMN_NAME
|
|
13266
|
+
WHERE i.TABLE_SCHEMA=t.TABLE_SCHEMA AND i.TABLE_NAME=t.TABLE_NAME AND i.INDEX_NAME='PRIMARY'
|
|
13267
|
+
AND i.SEQ_IN_INDEX=1 AND i.COLUMN_NAME='id' AND c.DATA_TYPE IN ('tinyint','smallint','mediumint','int','bigint')) LIMIT 1`;
|
|
13030
13268
|
function createFailedJobMetricsCollector(options = {}) {
|
|
13031
13269
|
const sampleLimit = options.sampleLimit ?? 500;
|
|
13032
13270
|
if (!Number.isSafeInteger(sampleLimit) || sampleLimit < 1 || sampleLimit > 1000)
|
|
13033
13271
|
throw new TypeError("Failed-job metrics sample limit must be between 1 and 1000.");
|
|
13034
13272
|
const repository = new failedJobRepository_default;
|
|
13035
|
-
return
|
|
13036
|
-
|
|
13037
|
-
const
|
|
13038
|
-
if (
|
|
13273
|
+
return createReadOnlyCollector("Failed-job metrics", options.timeoutMs ?? 1000, async (remaining) => {
|
|
13274
|
+
const driver = currentSqlDialect().driver;
|
|
13275
|
+
const postgres = driver === "pgsql";
|
|
13276
|
+
if (postgres)
|
|
13277
|
+
await repositoryConnection.unsafe('LOCK TABLE "failed_job" IN ACCESS SHARE MODE');
|
|
13278
|
+
if (driver === "mysql")
|
|
13279
|
+
await repositoryConnection.unsafe("SELECT id FROM failed_job LIMIT 0");
|
|
13280
|
+
const schema = await repositoryConnection.unsafe(postgres ? schemaQuery : driver === "mysql" ? mysqlSchemaQuery : sqliteSchemaQuery);
|
|
13281
|
+
if (schema.length !== 1 || schema[0]?.supported !== (postgres ? true : 1))
|
|
13039
13282
|
throw new Error("Failed-job metrics require the standard global table with an ID primary key and no RLS.");
|
|
13040
13283
|
remaining();
|
|
13041
13284
|
const rows = await repository.query().orderBy({ column: "id", direction: "ASC" }).project(["id"], sampleLimit + 1);
|
package/package.json
CHANGED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
interface ReadOnlyCollector<T> {
|
|
2
|
-
collect(): Promise<T>;
|
|
3
|
-
close(): Promise<void>;
|
|
4
|
-
}
|
|
5
|
-
/** Internal shared observation lifecycle. No bypass, payload or tenant enumeration. */
|
|
6
|
-
declare function createPostgresReadOnlyCollector<T>(name: string, timeoutMs: number, collect: (remaining: () => number) => Promise<T>): ReadOnlyCollector<T>;
|
|
7
|
-
export { createPostgresReadOnlyCollector };
|