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.
Files changed (42) hide show
  1. package/dist/adapters/cockroachdb.js +1 -1
  2. package/dist/adapters/index.d.ts +7 -4
  3. package/dist/adapters/index.js +1 -1
  4. package/dist/adapters/yugabytedb.js +1 -1
  5. package/dist/cjs/adapters/cockroachdb.js +1 -1
  6. package/dist/cjs/adapters/index.js +1 -1
  7. package/dist/cjs/adapters/yugabytedb.js +1 -1
  8. package/dist/cjs/cli/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +45 -7
  12. package/dist/cjs/client.js +102 -1
  13. package/dist/cjs/errors.js +44 -1
  14. package/dist/cjs/generate.js +86 -0
  15. package/dist/cjs/index.js +10 -1
  16. package/dist/cjs/nested-write.js +557 -0
  17. package/dist/cjs/observe.js +145 -0
  18. package/dist/cjs/query/builder.js +271 -23
  19. package/dist/cli/index.d.ts +1 -0
  20. package/dist/cli/index.js +64 -0
  21. package/dist/cli/observe-ui.d.ts +2 -0
  22. package/dist/cli/observe-ui.js +180 -0
  23. package/dist/cli/observe.d.ts +20 -0
  24. package/dist/cli/observe.js +237 -0
  25. package/dist/cli/studio.d.ts +10 -2
  26. package/dist/cli/studio.js +45 -7
  27. package/dist/client.d.ts +32 -2
  28. package/dist/client.js +102 -2
  29. package/dist/errors.d.ts +23 -0
  30. package/dist/errors.js +41 -0
  31. package/dist/generate.js +86 -0
  32. package/dist/index.d.ts +5 -3
  33. package/dist/index.js +4 -2
  34. package/dist/nested-write.d.ts +95 -0
  35. package/dist/nested-write.js +551 -0
  36. package/dist/observe.d.ts +36 -0
  37. package/dist/observe.js +141 -0
  38. package/dist/query/builder.d.ts +45 -12
  39. package/dist/query/builder.js +239 -24
  40. package/dist/query/index.d.ts +2 -2
  41. package/dist/query/types.d.ts +76 -8
  42. package/package.json +2 -2
@@ -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
@@ -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, WithResult } from './types.js';
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<WithResult<T, R, W> | null>;
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<WithResult<T, R, W>[]>;
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<WithResult<T, R, W>, void, undefined>;
194
- findFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<WithResult<T, R, W> | null>;
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<WithResult<T, R, W>>;
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<WithResult<T, R, W>>;
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.