@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 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
- * Initially Postgres-only: other engines fail rather than receiving a false empty snapshot.
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 db = new Database(filename, { create: true });
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/database/postgresReadOnlyCollector.ts
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 createPostgresReadOnlyCollector(name, timeoutMs, collect) {
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
- if (currentSqlDialect().driver !== "pgsql")
2212
- throw new Error(`${name} currently require Postgres.`);
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)", [`${remaining()}ms`]);
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 createPostgresReadOnlyCollector("Failed-job metrics", options.timeoutMs ?? 1000, async (remaining) => {
2276
- await repositoryConnection.unsafe('LOCK TABLE "failed_job" IN ACCESS SHARE MODE');
2277
- const schema = await repositoryConnection.unsafe(schemaQuery);
2278
- if (schema.length !== 1 || schema[0]?.supported !== true)
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
- close
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 db = new Database(filename, { create: true });
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/database/postgresReadOnlyCollector.ts
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 createPostgresReadOnlyCollector(name, timeoutMs, collect) {
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
- if (currentSqlDialect().driver !== "pgsql")
9316
- throw new Error(`${name} currently require Postgres.`);
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)", [`${remaining()}ms`]);
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 createPostgresReadOnlyCollector("Outbox metrics", timeoutMs, (remaining) => runWithMigrationBypass(async () => {
9420
- remaining();
9421
- const rows = await repositoryConnection.unsafe(snapshotQuery, [sampleLimit + 1]);
9422
- remaining();
9423
- if (rows.length !== specifications.length)
9424
- throw new Error("Invalid outbox metrics response.");
9425
- const states = specifications.map((spec) => {
9426
- const row = rows.find((value) => value.state === spec.state);
9427
- const count = Number(row?.count);
9428
- const lateness = row?.lateness_ms == null ? null : Number(row.lateness_ms);
9429
- if (!Number.isSafeInteger(count) || count < 0 || count > sampleLimit + 1 || spec.age && (lateness === null || !Number.isFinite(lateness) || lateness < 0))
9430
- throw new Error("Invalid outbox metrics values.");
9431
- return {
9432
- state: spec.state,
9433
- count: Math.min(count, sampleLimit),
9434
- capped: count > sampleLimit,
9435
- actionableLatenessSeconds: lateness === null ? null : lateness / 1000
9436
- };
9437
- });
9438
- return { sampleLimit, states };
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 = resolve(rootDirectory);
10047
- const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
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 createPostgresReadOnlyCollector("Failed-job metrics", options.timeoutMs ?? 1000, async (remaining) => {
13036
- await repositoryConnection.unsafe('LOCK TABLE "failed_job" IN ACCESS SHARE MODE');
13037
- const schema = await repositoryConnection.unsafe(schemaQuery);
13038
- if (schema.length !== 1 || schema[0]?.supported !== true)
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.10",
3
+ "version": "2.2.11",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 };