turbine-orm 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/cockroachdb.js +1 -1
- package/dist/adapters/index.d.ts +7 -4
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/yugabytedb.js +1 -1
- package/dist/cjs/adapters/cockroachdb.js +1 -1
- package/dist/cjs/adapters/index.js +1 -1
- package/dist/cjs/adapters/yugabytedb.js +1 -1
- package/dist/cjs/cli/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/cli/studio.js +45 -7
- package/dist/cjs/client.js +102 -1
- package/dist/cjs/errors.js +44 -1
- package/dist/cjs/generate.js +86 -0
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/nested-write.js +557 -0
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +271 -23
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/cli/studio.d.ts +10 -2
- package/dist/cli/studio.js +45 -7
- package/dist/client.d.ts +32 -2
- package/dist/client.js +102 -2
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +41 -0
- package/dist/generate.js +86 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/nested-write.d.ts +95 -0
- package/dist/nested-write.js +551 -0
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +45 -12
- package/dist/query/builder.js +239 -24
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +76 -8
- package/package.json +2 -2
package/dist/observe.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm — Observability module
|
|
3
|
+
*
|
|
4
|
+
* Buffers query metrics in memory (keyed by model:action per minute bucket),
|
|
5
|
+
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
|
|
6
|
+
* to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
|
|
7
|
+
* so metrics writes never contend with the application pool.
|
|
8
|
+
*/
|
|
9
|
+
import pg from 'pg';
|
|
10
|
+
function floorToMinute(date) {
|
|
11
|
+
const d = new Date(date);
|
|
12
|
+
d.setSeconds(0, 0);
|
|
13
|
+
return d;
|
|
14
|
+
}
|
|
15
|
+
function percentile(sorted, p) {
|
|
16
|
+
if (sorted.length === 0)
|
|
17
|
+
return 0;
|
|
18
|
+
const idx = Math.ceil(p * sorted.length) - 1;
|
|
19
|
+
return sorted[Math.max(0, idx)];
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Schema DDL
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const SCHEMA_DDL = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS _turbine_metrics (
|
|
26
|
+
id BIGSERIAL PRIMARY KEY,
|
|
27
|
+
bucket TIMESTAMPTZ NOT NULL,
|
|
28
|
+
model TEXT NOT NULL,
|
|
29
|
+
action TEXT NOT NULL,
|
|
30
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
avg_ms REAL NOT NULL DEFAULT 0,
|
|
32
|
+
p50_ms REAL NOT NULL DEFAULT 0,
|
|
33
|
+
p95_ms REAL NOT NULL DEFAULT 0,
|
|
34
|
+
p99_ms REAL NOT NULL DEFAULT 0,
|
|
35
|
+
error_count INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
UNIQUE(bucket, model, action)
|
|
37
|
+
);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_turbine_metrics_bucket ON _turbine_metrics(bucket);
|
|
39
|
+
`;
|
|
40
|
+
const UPSERT_SQL = `
|
|
41
|
+
INSERT INTO _turbine_metrics (bucket, model, action, count, avg_ms, p50_ms, p95_ms, p99_ms, error_count)
|
|
42
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
43
|
+
ON CONFLICT (bucket, model, action) DO UPDATE SET
|
|
44
|
+
count = _turbine_metrics.count + EXCLUDED.count,
|
|
45
|
+
avg_ms = (_turbine_metrics.avg_ms * _turbine_metrics.count + EXCLUDED.avg_ms * EXCLUDED.count)
|
|
46
|
+
/ (_turbine_metrics.count + EXCLUDED.count),
|
|
47
|
+
p50_ms = EXCLUDED.p50_ms,
|
|
48
|
+
p95_ms = EXCLUDED.p95_ms,
|
|
49
|
+
p99_ms = EXCLUDED.p99_ms,
|
|
50
|
+
error_count = _turbine_metrics.error_count + EXCLUDED.error_count
|
|
51
|
+
`;
|
|
52
|
+
const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Observe engine
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
export class ObserveEngine {
|
|
57
|
+
pool;
|
|
58
|
+
buffer = new Map();
|
|
59
|
+
currentBucket;
|
|
60
|
+
flushIntervalMs;
|
|
61
|
+
retentionDays;
|
|
62
|
+
timer;
|
|
63
|
+
listener;
|
|
64
|
+
stopped = false;
|
|
65
|
+
constructor(config) {
|
|
66
|
+
this.pool = new pg.Pool({ connectionString: config.connectionString, max: 1 });
|
|
67
|
+
this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
|
|
68
|
+
this.retentionDays = config.retentionDays ?? 30;
|
|
69
|
+
this.currentBucket = floorToMinute(new Date());
|
|
70
|
+
this.listener = (event) => {
|
|
71
|
+
if (this.stopped)
|
|
72
|
+
return;
|
|
73
|
+
const nowBucket = floorToMinute(new Date());
|
|
74
|
+
if (nowBucket.getTime() !== this.currentBucket.getTime()) {
|
|
75
|
+
this.currentBucket = nowBucket;
|
|
76
|
+
}
|
|
77
|
+
const key = `${event.model}:${event.action}`;
|
|
78
|
+
let entry = this.buffer.get(key);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
entry = { durations: [], errors: 0 };
|
|
81
|
+
this.buffer.set(key, entry);
|
|
82
|
+
}
|
|
83
|
+
entry.durations.push(event.duration);
|
|
84
|
+
if (event.error)
|
|
85
|
+
entry.errors++;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
getListener() {
|
|
89
|
+
return this.listener;
|
|
90
|
+
}
|
|
91
|
+
async init() {
|
|
92
|
+
await this.pool.query(SCHEMA_DDL);
|
|
93
|
+
this.timer = setInterval(() => {
|
|
94
|
+
this.flush().catch(() => { });
|
|
95
|
+
}, this.flushIntervalMs);
|
|
96
|
+
// Unref so it doesn't keep the process alive
|
|
97
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
98
|
+
this.timer.unref();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async flush() {
|
|
102
|
+
if (this.buffer.size === 0)
|
|
103
|
+
return;
|
|
104
|
+
const bucket = this.currentBucket;
|
|
105
|
+
const entries = new Map(this.buffer);
|
|
106
|
+
this.buffer.clear();
|
|
107
|
+
for (const [key, entry] of entries) {
|
|
108
|
+
const [model, action] = key.split(':');
|
|
109
|
+
const sorted = entry.durations.slice().sort((a, b) => a - b);
|
|
110
|
+
const count = sorted.length;
|
|
111
|
+
const avg = sorted.reduce((s, v) => s + v, 0) / count;
|
|
112
|
+
const p50 = percentile(sorted, 0.5);
|
|
113
|
+
const p95 = percentile(sorted, 0.95);
|
|
114
|
+
const p99 = percentile(sorted, 0.99);
|
|
115
|
+
try {
|
|
116
|
+
await this.pool.query(UPSERT_SQL, [bucket, model, action, count, avg, p50, p95, p99, entry.errors]);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Fire-and-forget — never throw from flush
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Best effort
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async stop() {
|
|
130
|
+
this.stopped = true;
|
|
131
|
+
if (this.timer)
|
|
132
|
+
clearInterval(this.timer);
|
|
133
|
+
await this.flush();
|
|
134
|
+
await this.pool.end();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Exported helpers for testing
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
export { floorToMinute, percentile };
|
|
141
|
+
//# sourceMappingURL=observe.js.map
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type pg from 'pg';
|
|
14
14
|
import type { Dialect } from '../dialect.js';
|
|
15
15
|
import type { SchemaMetadata } from '../schema.js';
|
|
16
|
-
import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause
|
|
16
|
+
import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
|
|
17
17
|
export interface DeferredQuery<T> {
|
|
18
18
|
/** SQL text with $1, $2 placeholders */
|
|
19
19
|
sql: string;
|
|
@@ -36,6 +36,18 @@ export type MiddlewareFn = (params: {
|
|
|
36
36
|
action: string;
|
|
37
37
|
args: Record<string, unknown>;
|
|
38
38
|
}) => Promise<unknown>) => Promise<unknown>;
|
|
39
|
+
/** Emitted after every query execution (success or failure). */
|
|
40
|
+
export interface QueryEvent {
|
|
41
|
+
sql: string;
|
|
42
|
+
params: unknown[];
|
|
43
|
+
duration: number;
|
|
44
|
+
model: string;
|
|
45
|
+
action: string;
|
|
46
|
+
rows: number;
|
|
47
|
+
timestamp: Date;
|
|
48
|
+
error?: Error;
|
|
49
|
+
}
|
|
50
|
+
export type QueryEventListener = (event: QueryEvent) => void;
|
|
39
51
|
/** Options passed from TurbineClient to QueryInterface */
|
|
40
52
|
export interface QueryInterfaceOptions {
|
|
41
53
|
/** Default LIMIT applied to findMany() when no limit is specified */
|
|
@@ -67,6 +79,10 @@ export interface QueryInterfaceOptions {
|
|
|
67
79
|
sqlCache?: boolean;
|
|
68
80
|
/** SQL dialect implementation. Defaults to PostgreSQL. */
|
|
69
81
|
dialect?: Dialect;
|
|
82
|
+
/** @internal Set by TransactionClient — signals that this QI runs inside an active transaction. */
|
|
83
|
+
_txScoped?: boolean;
|
|
84
|
+
/** @internal Callback from TurbineClient for query event emission. */
|
|
85
|
+
_onQuery?: (event: QueryEvent) => void;
|
|
70
86
|
}
|
|
71
87
|
export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
72
88
|
private readonly pool;
|
|
@@ -98,6 +114,12 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
98
114
|
private readonly columnArrayTypeMap;
|
|
99
115
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
100
116
|
private readonly deepWithWarned;
|
|
117
|
+
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
118
|
+
private readonly txScoped;
|
|
119
|
+
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
120
|
+
private readonly options?;
|
|
121
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
122
|
+
private currentAction;
|
|
101
123
|
constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
102
124
|
/** Quote an identifier through the active SQL dialect. */
|
|
103
125
|
private q;
|
|
@@ -127,6 +149,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
127
149
|
* exactly once per table without bleeding state between assertions.
|
|
128
150
|
*/
|
|
129
151
|
resetUnlimitedWarnings(): void;
|
|
152
|
+
private emitQueryEvent;
|
|
130
153
|
/**
|
|
131
154
|
* Execute a pool.query with an optional timeout.
|
|
132
155
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
@@ -143,9 +166,9 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
143
166
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
144
167
|
*/
|
|
145
168
|
private executeWithMiddleware;
|
|
146
|
-
findUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<
|
|
147
|
-
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T | null>;
|
|
148
|
-
findMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<
|
|
169
|
+
findUnique<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O> | null>;
|
|
170
|
+
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T | null>;
|
|
171
|
+
findMany<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>[]>;
|
|
149
172
|
/**
|
|
150
173
|
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
151
174
|
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
@@ -162,7 +185,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
162
185
|
* Used by the dev-only deep-with warning guard.
|
|
163
186
|
*/
|
|
164
187
|
private measureWithDepth;
|
|
165
|
-
buildFindMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T[]>;
|
|
188
|
+
buildFindMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T[]>;
|
|
166
189
|
/**
|
|
167
190
|
* Stream rows from a findMany query using PostgreSQL cursors.
|
|
168
191
|
* Returns an AsyncIterable that yields individual rows, fetching in batches internally.
|
|
@@ -190,19 +213,24 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
190
213
|
* }
|
|
191
214
|
* ```
|
|
192
215
|
*/
|
|
193
|
-
findManyStream<W extends TypedWithClause<R> = {}>(args?: FindManyStreamArgs<T, R, W>): AsyncGenerator<
|
|
194
|
-
findFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<
|
|
195
|
-
buildFindFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T | null>;
|
|
196
|
-
findFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<
|
|
197
|
-
buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T>;
|
|
198
|
-
findUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<
|
|
199
|
-
buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T>;
|
|
216
|
+
findManyStream<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyStreamArgs<T, R, W, S, O>): AsyncGenerator<QueryResult<T, R, W, S, O>, void, undefined>;
|
|
217
|
+
findFirst<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O> | null>;
|
|
218
|
+
buildFindFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T | null>;
|
|
219
|
+
findFirstOrThrow<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>>;
|
|
220
|
+
buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
|
|
221
|
+
findUniqueOrThrow<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>>;
|
|
222
|
+
buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
|
|
200
223
|
create(args: CreateArgs<T>): Promise<T>;
|
|
201
224
|
buildCreate(args: CreateArgs<T>): DeferredQuery<T>;
|
|
202
225
|
createMany(args: CreateManyArgs<T>): Promise<T[]>;
|
|
203
226
|
buildCreateMany(args: CreateManyArgs<T>): DeferredQuery<T[]>;
|
|
204
227
|
update(args: UpdateArgs<T>): Promise<T>;
|
|
205
228
|
buildUpdate(args: UpdateArgs<T>): DeferredQuery<T>;
|
|
229
|
+
private nestedCreate;
|
|
230
|
+
private nestedUpdate;
|
|
231
|
+
private runInImplicitTx;
|
|
232
|
+
private buildNestedCtx;
|
|
233
|
+
private makeTxProxy;
|
|
206
234
|
delete(args: DeleteArgs<T>): Promise<T>;
|
|
207
235
|
buildDelete(args: DeleteArgs<T>): DeferredQuery<T>;
|
|
208
236
|
upsert(args: UpsertArgs<T>): Promise<T>;
|
|
@@ -502,6 +530,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
502
530
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
503
531
|
*/
|
|
504
532
|
private buildArrayFilterClauses;
|
|
533
|
+
/**
|
|
534
|
+
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
535
|
+
* The config name is validated to prevent injection (only alphanumeric + underscore).
|
|
536
|
+
*/
|
|
537
|
+
private buildTextSearchClause;
|
|
505
538
|
/**
|
|
506
539
|
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
507
540
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|