@getstrata/core 2.2.9 → 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,17 @@
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
+
7
+ ## 2.2.10
8
+
9
+ - Add bounded OpenTelemetry SDK health observations and selected native transaction-acquisition histograms through explicit metrics options.
10
+ - Add bounded Postgres failed-job counts using ORM ID projections, with schema guards, native acquisition deadlines and read-only collector draining.
11
+ - Observe Streams retry promotion lateness using Redis time; cancellation cleanup sentinels omit unavailable age instead of reporting age since epoch.
12
+ - Generate typed runtimeMetrics opt-in and provider-owned SQL collector draining, including startup cleanup; HTTP-only defaults and borrowed tracing ownership remain intact.
13
+ - Keep additional SQL observation dialects, sustained operational qualification and encryption rotation explicit follow-up work.
14
+
3
15
  ## 2.2.9
4
16
 
5
17
  - Support native cancellable transaction acquisition
@@ -0,0 +1,23 @@
1
+ declare const outcomes: readonly ["acquired", "failed", "aborted"];
2
+ type AcquisitionOutcome = (typeof outcomes)[number];
3
+ interface AcquisitionHistogram {
4
+ outcome: AcquisitionOutcome;
5
+ count: number;
6
+ sumSeconds: number;
7
+ buckets: readonly number[];
8
+ }
9
+ interface TransactionAcquisitionSnapshot {
10
+ inflight: number;
11
+ outcomes: readonly AcquisitionHistogram[];
12
+ }
13
+ declare const metricsBrand: unique symbol;
14
+ interface TransactionAcquisitionMetrics {
15
+ readonly [metricsBrand]: true;
16
+ snapshot(): TransactionAcquisitionSnapshot;
17
+ }
18
+ declare function createTransactionAcquisitionMetrics(): TransactionAcquisitionMetrics;
19
+ /** Internal native-reservation boundary, never a business/BEGIN/savepoint timer. */
20
+ declare function observeTransactionAcquisition<T>(metrics: TransactionAcquisitionMetrics, acquire: () => Promise<T>, signal?: AbortSignal): Promise<T>;
21
+ declare function renderTransactionAcquisitionMetrics(snapshot: TransactionAcquisitionSnapshot): string;
22
+ export type { TransactionAcquisitionMetrics, TransactionAcquisitionSnapshot };
23
+ export { createTransactionAcquisitionMetrics, observeTransactionAcquisition, renderTransactionAcquisitionMetrics, };
@@ -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;
@@ -1,9 +1,14 @@
1
+ import { type TransactionAcquisitionMetrics } from "./acquisitionMetrics.ts";
1
2
  import type { DatabaseConnection } from "./baseRepository.ts";
2
3
  import { hasActiveTransaction, requestTransactionRollback } from "./transactionControl.ts";
3
4
  interface TransactionOptions {
4
5
  /** Cancel admission/pool checkout only. Does not cancel SQL or an admitted business callback. */
5
6
  acquisitionSignal?: AbortSignal;
7
+ /** Observe native checkout only; requires a reserve-capable adapter. */
8
+ acquisitionMetrics?: TransactionAcquisitionMetrics;
6
9
  }
7
10
  declare function runInTransaction<TValue>(operation: (connection: DatabaseConnection) => Promise<TValue>, options?: TransactionOptions): Promise<TValue>;
11
+ export type { TransactionAcquisitionMetrics, TransactionAcquisitionSnapshot, } from "./acquisitionMetrics.ts";
12
+ export { createTransactionAcquisitionMetrics, renderTransactionAcquisitionMetrics, } from "./acquisitionMetrics.ts";
8
13
  export type { TransactionOptions };
9
14
  export { hasActiveTransaction, requestTransactionRollback, runInTransaction };
@@ -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;
@@ -0,0 +1,17 @@
1
+ interface FailedJobMetricsSnapshot {
2
+ sampleLimit: number;
3
+ count: number;
4
+ capped: boolean;
5
+ }
6
+ interface FailedJobMetricsOptions {
7
+ timeoutMs?: number;
8
+ sampleLimit?: number;
9
+ }
10
+ interface FailedJobMetricsCollector {
11
+ collect(): Promise<FailedJobMetricsSnapshot>;
12
+ close(): Promise<void>;
13
+ }
14
+ declare function createFailedJobMetricsCollector(options?: FailedJobMetricsOptions): FailedJobMetricsCollector;
15
+ declare function renderFailedJobMetrics(snapshot: FailedJobMetricsSnapshot): string;
16
+ export type { FailedJobMetricsCollector, FailedJobMetricsOptions, FailedJobMetricsSnapshot };
17
+ export { createFailedJobMetricsCollector, renderFailedJobMetrics };
@@ -26,6 +26,8 @@ interface QueuePrioritySnapshot {
26
26
  inflightCapped: boolean;
27
27
  retryDue: number;
28
28
  retryWaiting: number;
29
+ /** Oldest positive due-score lateness; unavailable for lists or cancellation sentinels. */
30
+ retryActionableLatenessSeconds?: number | null;
29
31
  quarantined: number;
30
32
  /** Residence age of the oldest current stream entry, not original job age across retries. */
31
33
  oldestStreamEntryAgeSeconds: number | null;
@@ -39,5 +41,7 @@ declare const INFLIGHT_SAMPLE_LIMIT = 500;
39
41
  declare function readRedisQueueSnapshot(redisUrl: string, options?: RedisQueueSnapshotOptions): Promise<RedisQueueSnapshot>;
40
42
  /** Fixed metric/label vocabulary; no job IDs, tenant IDs, payloads or credentials. */
41
43
  declare function renderRedisQueueMetrics(snapshot: RedisQueueSnapshot): string;
44
+ export type { FailedJobMetricsCollector, FailedJobMetricsOptions, FailedJobMetricsSnapshot, } from "./failedJobMetrics";
45
+ export { createFailedJobMetricsCollector, renderFailedJobMetrics } from "./failedJobMetrics";
42
46
  export type { QueuePrioritySnapshot, RedisQueueSnapshot, RedisQueueSnapshotOptions };
43
47
  export { INFLIGHT_SAMPLE_LIMIT, readRedisQueueSnapshot, renderRedisQueueMetrics };
@@ -0,0 +1,27 @@
1
+ import { type MeterProvider } from "@opentelemetry/api";
2
+ import type { SpanExporter } from "@opentelemetry/sdk-trace-base";
3
+ interface TracingMetricsSnapshot {
4
+ enabled: boolean;
5
+ exporterConfigured: boolean;
6
+ active: boolean;
7
+ queueSize: number | null;
8
+ queueCapacity: number | null;
9
+ droppedSpans: number;
10
+ exportedBatches: number;
11
+ failedBatches: number;
12
+ exportedSpans: number;
13
+ failedSpans: number;
14
+ exportDeadlineExceededBatches: number;
15
+ flushFailures: number;
16
+ shutdownFailures: number;
17
+ }
18
+ /** A fixed projection of SDK self-observations, not a general metric registry. */
19
+ declare function createTracingHealth(enabled: boolean, exporterConfigured: boolean, timeoutMillis: number): {
20
+ state: TracingMetricsSnapshot;
21
+ meterProvider: MeterProvider;
22
+ snapshot(): TracingMetricsSnapshot;
23
+ wrap(exporter: SpanExporter): SpanExporter;
24
+ };
25
+ declare function renderTracingMetrics(snapshot: TracingMetricsSnapshot): string;
26
+ export type { TracingMetricsSnapshot };
27
+ export { createTracingHealth, renderTracingMetrics };
@@ -1,6 +1,7 @@
1
1
  import { type Context, type Tracer } from "@opentelemetry/api";
2
2
  import { W3CTraceContextPropagator } from "@opentelemetry/core";
3
3
  import { type SpanExporter } from "@opentelemetry/sdk-trace-base";
4
+ import { type TracingMetricsSnapshot } from "./health";
4
5
  type TracingOptions = {
5
6
  exporter?: SpanExporter;
6
7
  endpoint?: string;
@@ -15,6 +16,7 @@ type TracingRuntime = {
15
16
  tracer: Tracer;
16
17
  propagator: W3CTraceContextPropagator;
17
18
  run<T>(parent: Context, callback: () => T): T;
19
+ metrics(): TracingMetricsSnapshot;
18
20
  forceFlush(): Promise<void>;
19
21
  shutdown(): Promise<void>;
20
22
  };
@@ -1,6 +1,8 @@
1
1
  import type { Middleware } from "@getstrata/core/http/middleware";
2
2
  import { type TracingRuntime } from "./otel";
3
3
  declare function createTracingMiddleware(runtime?: TracingRuntime): Middleware;
4
+ export type { TracingMetricsSnapshot } from "./health";
5
+ export { renderTracingMetrics } from "./health";
4
6
  export type { TracingOptions, TracingRuntime } from "./otel";
5
7
  export { acquireTracingRuntime, createTracingRuntime, getTracingRuntime } from "./otel";
6
8
  export { createTracingMiddleware };
@@ -129,6 +129,9 @@ function unwrapTransactionRollback(error, depth = 0) {
129
129
  }
130
130
  return unwrapTransactionRollback(error.cause, depth + 1);
131
131
  }
132
+ function hasActiveTransaction() {
133
+ return hasActiveDatabaseConnection() && (transactionScopes.getStore()?.rollback.length ?? 0) > 0;
134
+ }
132
135
  function requestTransactionRollback() {
133
136
  const state = transactionScopes.getStore();
134
137
  const index = state ? state.rollback.length - 1 : -1;
@@ -344,6 +347,55 @@ var init_deferredModelEvents = __esm(() => {
344
347
  deferredModelEvents = createAsyncContextStore("@getstrata/deferredModelEvents");
345
348
  });
346
349
 
350
+ // ../../src/core/database/acquisitionMetrics.ts
351
+ async function observeTransactionAcquisition(metrics, acquire, signal) {
352
+ const state = states.get(metrics);
353
+ if (!state)
354
+ throw new TypeError("Transaction acquisition metrics must be created by the framework factory.");
355
+ const started = performance.now();
356
+ state.inflight++;
357
+ let outcome = "acquired";
358
+ try {
359
+ return await acquire();
360
+ } catch (error) {
361
+ outcome = signal?.aborted ? "aborted" : "failed";
362
+ throw error;
363
+ } finally {
364
+ state.inflight--;
365
+ const elapsed = Math.max(0, performance.now() - started) / 1000;
366
+ const histogram = state.outcomes[outcomes.indexOf(outcome)];
367
+ if (histogram) {
368
+ histogram.count++;
369
+ histogram.sumSeconds += elapsed;
370
+ for (let index = 0;index < ACQUISITION_BUCKETS_SECONDS.length; index++) {
371
+ const bound = ACQUISITION_BUCKETS_SECONDS[index];
372
+ if (bound !== undefined && elapsed <= bound)
373
+ histogram.buckets[index] = (histogram.buckets[index] ?? 0) + 1;
374
+ }
375
+ }
376
+ }
377
+ }
378
+ var ACQUISITION_BUCKETS_SECONDS, outcomes, metricsBrand, states;
379
+ var init_acquisitionMetrics = __esm(() => {
380
+ ACQUISITION_BUCKETS_SECONDS = [
381
+ 0.001,
382
+ 0.005,
383
+ 0.01,
384
+ 0.025,
385
+ 0.05,
386
+ 0.1,
387
+ 0.25,
388
+ 0.5,
389
+ 1,
390
+ 2.5,
391
+ 5,
392
+ 10
393
+ ];
394
+ outcomes = ["acquired", "failed", "aborted"];
395
+ metricsBrand = Symbol("transaction acquisition metrics");
396
+ states = new WeakMap;
397
+ });
398
+
347
399
  // ../../src/core/database/connection.ts
348
400
  function createDatabaseConnection(source) {
349
401
  return {
@@ -712,9 +764,13 @@ async function runInTransaction(operation, options = {}) {
712
764
  throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
713
765
  }
714
766
  return await settleTransaction(() => runWithDeferredModelEvents(async () => {
715
- if (signal && !pool.reserve)
716
- throw new Error("Active database connection does not support cancellable transaction acquisition.");
717
- const reserved = signal ? await pool.reserve?.({ signal }) : undefined;
767
+ let reserved;
768
+ if (signal || options.acquisitionMetrics) {
769
+ if (!pool.reserve)
770
+ throw new Error(signal ? "Active database connection does not support cancellable transaction acquisition." : "Active database connection does not support observable transaction acquisition.");
771
+ const reserve = pool.reserve.bind(pool);
772
+ reserved = options.acquisitionMetrics ? await observeTransactionAcquisition(options.acquisitionMetrics, () => reserve({ signal }), signal) : await reserve({ signal });
773
+ }
718
774
  const connection = reserved ?? pool;
719
775
  try {
720
776
  signal?.throwIfAborted();
@@ -732,10 +788,12 @@ var transactionFrame, outerFrames;
732
788
  var init_transaction = __esm(() => {
733
789
  init_deferredModelEvents();
734
790
  init_asyncContextStore();
791
+ init_acquisitionMetrics();
735
792
  init_connection();
736
793
  init_connectionContext();
737
794
  init_repositoryConnection();
738
795
  init_transactionControl();
796
+ init_acquisitionMetrics();
739
797
  transactionFrame = createAsyncContextStore("@getstrata/transactionFrame");
740
798
  outerFrames = new WeakMap;
741
799
  });
@@ -74,6 +74,9 @@ function unwrapTransactionRollback(error, depth = 0) {
74
74
  }
75
75
  return unwrapTransactionRollback(error.cause, depth + 1);
76
76
  }
77
+ function hasActiveTransaction() {
78
+ return hasActiveDatabaseConnection() && (transactionScopes.getStore()?.rollback.length ?? 0) > 0;
79
+ }
77
80
  function requestTransactionRollback() {
78
81
  const state = transactionScopes.getStore();
79
82
  const index = state ? state.rollback.length - 1 : -1;
@@ -300,6 +303,55 @@ var init_deferredModelEvents = __esm(() => {
300
303
  deferredModelEvents = createAsyncContextStore("@getstrata/deferredModelEvents");
301
304
  });
302
305
 
306
+ // ../../src/core/database/acquisitionMetrics.ts
307
+ async function observeTransactionAcquisition(metrics, acquire, signal) {
308
+ const state = states.get(metrics);
309
+ if (!state)
310
+ throw new TypeError("Transaction acquisition metrics must be created by the framework factory.");
311
+ const started = performance.now();
312
+ state.inflight++;
313
+ let outcome = "acquired";
314
+ try {
315
+ return await acquire();
316
+ } catch (error) {
317
+ outcome = signal?.aborted ? "aborted" : "failed";
318
+ throw error;
319
+ } finally {
320
+ state.inflight--;
321
+ const elapsed = Math.max(0, performance.now() - started) / 1000;
322
+ const histogram = state.outcomes[outcomes.indexOf(outcome)];
323
+ if (histogram) {
324
+ histogram.count++;
325
+ histogram.sumSeconds += elapsed;
326
+ for (let index = 0;index < ACQUISITION_BUCKETS_SECONDS.length; index++) {
327
+ const bound = ACQUISITION_BUCKETS_SECONDS[index];
328
+ if (bound !== undefined && elapsed <= bound)
329
+ histogram.buckets[index] = (histogram.buckets[index] ?? 0) + 1;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ var ACQUISITION_BUCKETS_SECONDS, outcomes, metricsBrand, states;
335
+ var init_acquisitionMetrics = __esm(() => {
336
+ ACQUISITION_BUCKETS_SECONDS = [
337
+ 0.001,
338
+ 0.005,
339
+ 0.01,
340
+ 0.025,
341
+ 0.05,
342
+ 0.1,
343
+ 0.25,
344
+ 0.5,
345
+ 1,
346
+ 2.5,
347
+ 5,
348
+ 10
349
+ ];
350
+ outcomes = ["acquired", "failed", "aborted"];
351
+ metricsBrand = Symbol("transaction acquisition metrics");
352
+ states = new WeakMap;
353
+ });
354
+
303
355
  // ../../src/core/database/connection.ts
304
356
  function createDatabaseConnection(source) {
305
357
  return {
@@ -668,9 +720,13 @@ async function runInTransaction(operation, options = {}) {
668
720
  throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
669
721
  }
670
722
  return await settleTransaction(() => runWithDeferredModelEvents(async () => {
671
- if (signal && !pool.reserve)
672
- throw new Error("Active database connection does not support cancellable transaction acquisition.");
673
- const reserved = signal ? await pool.reserve?.({ signal }) : undefined;
723
+ let reserved;
724
+ if (signal || options.acquisitionMetrics) {
725
+ if (!pool.reserve)
726
+ throw new Error(signal ? "Active database connection does not support cancellable transaction acquisition." : "Active database connection does not support observable transaction acquisition.");
727
+ const reserve = pool.reserve.bind(pool);
728
+ reserved = options.acquisitionMetrics ? await observeTransactionAcquisition(options.acquisitionMetrics, () => reserve({ signal }), signal) : await reserve({ signal });
729
+ }
674
730
  const connection = reserved ?? pool;
675
731
  try {
676
732
  signal?.throwIfAborted();
@@ -688,10 +744,12 @@ var transactionFrame, outerFrames;
688
744
  var init_transaction = __esm(() => {
689
745
  init_deferredModelEvents();
690
746
  init_asyncContextStore();
747
+ init_acquisitionMetrics();
691
748
  init_connection();
692
749
  init_connectionContext();
693
750
  init_repositoryConnection();
694
751
  init_transactionControl();
752
+ init_acquisitionMetrics();
695
753
  transactionFrame = createAsyncContextStore("@getstrata/transactionFrame");
696
754
  outerFrames = new WeakMap;
697
755
  });
@@ -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.");
@@ -52,6 +52,9 @@ function unwrapTransactionRollback(error, depth = 0) {
52
52
  }
53
53
  return unwrapTransactionRollback(error.cause, depth + 1);
54
54
  }
55
+ function hasActiveTransaction() {
56
+ return hasActiveDatabaseConnection() && (transactionScopes.getStore()?.rollback.length ?? 0) > 0;
57
+ }
55
58
  function requestTransactionRollback() {
56
59
  const state = transactionScopes.getStore();
57
60
  const index = state ? state.rollback.length - 1 : -1;