@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 +12 -0
- package/dist/core/database/acquisitionMetrics.d.ts +23 -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/database/transaction.d.ts +5 -0
- package/dist/core/events/outbox/metrics.d.ts +1 -1
- package/dist/core/queue/failedJobMetrics.d.ts +17 -0
- package/dist/core/queue/queueMetrics.d.ts +4 -0
- package/dist/core/tracing/health.d.ts +27 -0
- package/dist/core/tracing/otel.d.ts +2 -0
- package/dist/core/tracing/tracingMiddleware.d.ts +2 -0
- package/dist/entries/audit/exportAuditLogs.js +61 -3
- package/dist/entries/auth/scimAuthMiddleware.js +61 -3
- package/dist/entries/database/sqliteConnection.js +137 -1
- package/dist/entries/http/response.js +3 -0
- package/dist/entries/jobs/exportAuditLogsJob.js +61 -3
- package/dist/entries/queue/queueMetrics.js +1052 -7
- package/dist/entries/tenant/databaseTenantContext.js +61 -3
- package/dist/entries/tenant/tenantDatabaseScope.js +61 -3
- package/dist/entries/tracing/tracingMiddleware.js +188 -5
- package/dist/framework/public-api.d.ts +6 -0
- package/dist/index.js +704 -105
- package/package.json +2 -1
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
|
-
*
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
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;
|