turbine-orm 0.15.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/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/client.js +54 -0
- package/dist/cjs/nested-write.js +94 -4
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +66 -13
- 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/client.d.ts +9 -2
- package/dist/client.js +55 -1
- package/dist/index.d.ts +2 -1
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +94 -4
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +17 -0
- package/dist/query/builder.js +66 -13
- package/dist/query/index.d.ts +1 -1
- package/package.json +2 -2
package/dist/nested-write.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tree-walking create/update that resolves relation fields in `data` into
|
|
5
5
|
* batched SQL operations within a transaction. Supports create, connect,
|
|
6
|
-
* connectOrCreate, disconnect, set, and
|
|
7
|
-
* arbitrary depth (capped at 10).
|
|
6
|
+
* connectOrCreate, disconnect, set, delete, update, and upsert on related
|
|
7
|
+
* records at arbitrary depth (capped at 10).
|
|
8
8
|
*
|
|
9
9
|
* This module is imported by `query/builder.ts` when the `data` argument
|
|
10
10
|
* of `create()` or `update()` contains relation fields. It never imports
|
|
@@ -15,7 +15,7 @@ import { CircularRelationError, RelationError, ValidationError } from './errors.
|
|
|
15
15
|
import { normalizeKeyColumns } from './schema.js';
|
|
16
16
|
const MAX_DEPTH = 10;
|
|
17
17
|
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
18
|
-
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete']);
|
|
18
|
+
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete', 'update', 'upsert']);
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// Pure helpers (exported for testing)
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
@@ -93,7 +93,7 @@ function validateOps(relationName, ops, isUpdate) {
|
|
|
93
93
|
for (const opName of Object.keys(ops)) {
|
|
94
94
|
if (!CREATE_ONLY_OPS.has(opName) && !UPDATE_ONLY_OPS.has(opName)) {
|
|
95
95
|
throw new ValidationError(`[turbine] Unknown nested write operation "${opName}" on relation "${relationName}". ` +
|
|
96
|
-
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete' : ''}.`);
|
|
96
|
+
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete, update, upsert' : ''}.`);
|
|
97
97
|
}
|
|
98
98
|
if (!isUpdate && UPDATE_ONLY_OPS.has(opName)) {
|
|
99
99
|
throw new ValidationError(`[turbine] Operation "${opName}" on relation "${relationName}" is only valid inside update(), not create().`);
|
|
@@ -216,9 +216,25 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
216
216
|
if (ops.delete !== undefined) {
|
|
217
217
|
await processDelete(ctx, rel, ops.delete);
|
|
218
218
|
}
|
|
219
|
+
// update
|
|
220
|
+
if (ops.update !== undefined) {
|
|
221
|
+
await processNestedUpdate(ctx, rel, ops.update);
|
|
222
|
+
}
|
|
223
|
+
// upsert
|
|
224
|
+
if (ops.upsert !== undefined) {
|
|
225
|
+
await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
|
|
226
|
+
}
|
|
219
227
|
}
|
|
220
228
|
else if (rel.type === 'belongsTo') {
|
|
221
229
|
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
230
|
+
// update (belongsTo — derive where from parent FK)
|
|
231
|
+
if (ops.update !== undefined) {
|
|
232
|
+
await processBelongsToUpdate(ctx, rel, ops.update, parentRow, tableName);
|
|
233
|
+
}
|
|
234
|
+
// upsert (belongsTo)
|
|
235
|
+
if (ops.upsert !== undefined) {
|
|
236
|
+
await processBelongsToUpsert(ctx, rel, ops.upsert, parentRow, tableName);
|
|
237
|
+
}
|
|
222
238
|
if (ops.disconnect !== undefined) {
|
|
223
239
|
// For belongsTo disconnect, null out the FK on the parent
|
|
224
240
|
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
@@ -452,6 +468,80 @@ async function processSet(ctx, rel, setItems, parentRow) {
|
|
|
452
468
|
await ctx.tx.table(rel.to).update({ where: target, data: updateData });
|
|
453
469
|
}
|
|
454
470
|
}
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// update / upsert operations (update-context only)
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
async function processNestedUpdate(ctx, rel, updateArg) {
|
|
475
|
+
const items = toArray(updateArg);
|
|
476
|
+
for (const item of items) {
|
|
477
|
+
if (!item.where || !item.data) {
|
|
478
|
+
throw new ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
|
|
479
|
+
}
|
|
480
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
|
|
484
|
+
const items = toArray(upsertArg);
|
|
485
|
+
for (const item of items) {
|
|
486
|
+
if (!item.where || !item.create || !item.update) {
|
|
487
|
+
throw new ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
488
|
+
}
|
|
489
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
490
|
+
if (existing) {
|
|
491
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
|
|
495
|
+
await ctx.tx.table(rel.to).create({ data: injected });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTable) {
|
|
500
|
+
const item = updateArg;
|
|
501
|
+
if (!item.data) {
|
|
502
|
+
throw new ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
|
|
503
|
+
}
|
|
504
|
+
// Derive where from parent's FK values
|
|
505
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
506
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
507
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
508
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
509
|
+
const where = {};
|
|
510
|
+
for (let i = 0; i < fks.length; i++) {
|
|
511
|
+
const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
512
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
513
|
+
where[refField] = parentRow[fkField];
|
|
514
|
+
}
|
|
515
|
+
await ctx.tx.table(rel.to).update({ where, data: item.data });
|
|
516
|
+
}
|
|
517
|
+
async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
|
|
518
|
+
const item = upsertArg;
|
|
519
|
+
if (!item.where || !item.create || !item.update) {
|
|
520
|
+
throw new ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
521
|
+
}
|
|
522
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
523
|
+
if (existing) {
|
|
524
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
// Create the related row, then update parent's FK to point at it
|
|
528
|
+
const createdRow = (await ctx.tx.table(rel.to).create({ data: item.create }));
|
|
529
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
530
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
531
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
532
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
533
|
+
const updateData = {};
|
|
534
|
+
for (let i = 0; i < fks.length; i++) {
|
|
535
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
536
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
537
|
+
updateData[fkField] = createdRow[refField];
|
|
538
|
+
}
|
|
539
|
+
await ctx.tx.table(parentTable).update({
|
|
540
|
+
where: pkWhere(parentMeta, parentRow),
|
|
541
|
+
data: updateData,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
455
545
|
async function processDelete(ctx, rel, deleteArg) {
|
|
456
546
|
const items = toArray(deleteArg);
|
|
457
547
|
for (const target of items) {
|
|
@@ -0,0 +1,36 @@
|
|
|
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 type { QueryEventListener } from './query/index.js';
|
|
10
|
+
export interface ObserveConfig {
|
|
11
|
+
connectionString: string;
|
|
12
|
+
flushIntervalMs?: number;
|
|
13
|
+
retentionDays?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ObserveHandle {
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
declare function floorToMinute(date: Date): Date;
|
|
19
|
+
declare function percentile(sorted: number[], p: number): number;
|
|
20
|
+
export declare class ObserveEngine {
|
|
21
|
+
private readonly pool;
|
|
22
|
+
private readonly buffer;
|
|
23
|
+
private currentBucket;
|
|
24
|
+
private readonly flushIntervalMs;
|
|
25
|
+
private readonly retentionDays;
|
|
26
|
+
private timer;
|
|
27
|
+
private readonly listener;
|
|
28
|
+
private stopped;
|
|
29
|
+
constructor(config: ObserveConfig);
|
|
30
|
+
getListener(): QueryEventListener;
|
|
31
|
+
init(): Promise<void>;
|
|
32
|
+
flush(): Promise<void>;
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export { floorToMinute, percentile };
|
|
36
|
+
//# sourceMappingURL=observe.d.ts.map
|
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
|
@@ -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 */
|
|
@@ -69,6 +81,8 @@ export interface QueryInterfaceOptions {
|
|
|
69
81
|
dialect?: Dialect;
|
|
70
82
|
/** @internal Set by TransactionClient — signals that this QI runs inside an active transaction. */
|
|
71
83
|
_txScoped?: boolean;
|
|
84
|
+
/** @internal Callback from TurbineClient for query event emission. */
|
|
85
|
+
_onQuery?: (event: QueryEvent) => void;
|
|
72
86
|
}
|
|
73
87
|
export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
74
88
|
private readonly pool;
|
|
@@ -104,6 +118,8 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
104
118
|
private readonly txScoped;
|
|
105
119
|
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
106
120
|
private readonly options?;
|
|
121
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
122
|
+
private currentAction;
|
|
107
123
|
constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
108
124
|
/** Quote an identifier through the active SQL dialect. */
|
|
109
125
|
private q;
|
|
@@ -133,6 +149,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
133
149
|
* exactly once per table without bleeding state between assertions.
|
|
134
150
|
*/
|
|
135
151
|
resetUnlimitedWarnings(): void;
|
|
152
|
+
private emitQueryEvent;
|
|
136
153
|
/**
|
|
137
154
|
* Execute a pool.query with an optional timeout.
|
|
138
155
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
package/dist/query/builder.js
CHANGED
|
@@ -155,6 +155,8 @@ export class QueryInterface {
|
|
|
155
155
|
txScoped;
|
|
156
156
|
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
157
157
|
options;
|
|
158
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
159
|
+
currentAction = 'raw';
|
|
158
160
|
constructor(pool, table, schema, middlewares, options) {
|
|
159
161
|
this.pool = pool;
|
|
160
162
|
this.table = table;
|
|
@@ -236,12 +238,25 @@ export class QueryInterface {
|
|
|
236
238
|
resetUnlimitedWarnings() {
|
|
237
239
|
this.warnedTables.clear();
|
|
238
240
|
}
|
|
241
|
+
emitQueryEvent(sql, params, duration, action, rows, error) {
|
|
242
|
+
const onQuery = this.options?._onQuery;
|
|
243
|
+
if (!onQuery)
|
|
244
|
+
return;
|
|
245
|
+
try {
|
|
246
|
+
onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Listener errors must never crash a query
|
|
250
|
+
}
|
|
251
|
+
}
|
|
239
252
|
/**
|
|
240
253
|
* Execute a pool.query with an optional timeout.
|
|
241
254
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
242
255
|
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
243
256
|
*/
|
|
244
257
|
async queryWithTimeout(sql, params, timeout, preparedName) {
|
|
258
|
+
const start = performance.now();
|
|
259
|
+
const action = this.currentAction;
|
|
245
260
|
// Build the query argument — use object form with `name` for prepared
|
|
246
261
|
// statements, or the plain (text, values) form otherwise.
|
|
247
262
|
const usePrepared = preparedName && this.preparedStatementsEnabled;
|
|
@@ -250,10 +265,14 @@ export class QueryInterface {
|
|
|
250
265
|
: this.pool.query(sql, params);
|
|
251
266
|
if (!timeout) {
|
|
252
267
|
try {
|
|
253
|
-
|
|
268
|
+
const result = await exec;
|
|
269
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
270
|
+
return result;
|
|
254
271
|
}
|
|
255
272
|
catch (err) {
|
|
256
|
-
|
|
273
|
+
const wrapped = wrapPgError(err);
|
|
274
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
275
|
+
throw wrapped;
|
|
257
276
|
}
|
|
258
277
|
}
|
|
259
278
|
let timer;
|
|
@@ -261,10 +280,14 @@ export class QueryInterface {
|
|
|
261
280
|
timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
|
|
262
281
|
});
|
|
263
282
|
try {
|
|
264
|
-
|
|
283
|
+
const result = await Promise.race([exec, timeoutPromise]);
|
|
284
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
285
|
+
return result;
|
|
265
286
|
}
|
|
266
287
|
catch (err) {
|
|
267
|
-
|
|
288
|
+
const wrapped = wrapPgError(err);
|
|
289
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
290
|
+
throw wrapped;
|
|
268
291
|
}
|
|
269
292
|
finally {
|
|
270
293
|
clearTimeout(timer);
|
|
@@ -280,6 +303,7 @@ export class QueryInterface {
|
|
|
280
303
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
281
304
|
*/
|
|
282
305
|
async executeWithMiddleware(action, args, executor) {
|
|
306
|
+
this.currentAction = action;
|
|
283
307
|
if (this.middlewares.length === 0) {
|
|
284
308
|
return executor();
|
|
285
309
|
}
|
|
@@ -299,7 +323,6 @@ export class QueryInterface {
|
|
|
299
323
|
// -------------------------------------------------------------------------
|
|
300
324
|
// findUnique
|
|
301
325
|
// -------------------------------------------------------------------------
|
|
302
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
303
326
|
async findUnique(args) {
|
|
304
327
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
305
328
|
const deferred = this.buildFindUnique(args);
|
|
@@ -403,7 +426,6 @@ export class QueryInterface {
|
|
|
403
426
|
// -------------------------------------------------------------------------
|
|
404
427
|
// findMany
|
|
405
428
|
// -------------------------------------------------------------------------
|
|
406
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
407
429
|
async findMany(args) {
|
|
408
430
|
this.maybeWarnUnlimited(args);
|
|
409
431
|
// Dev-only: warn on deeply nested with clauses
|
|
@@ -605,7 +627,6 @@ export class QueryInterface {
|
|
|
605
627
|
* }
|
|
606
628
|
* ```
|
|
607
629
|
*/
|
|
608
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
609
630
|
async *findManyStream(args) {
|
|
610
631
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
611
632
|
const hasRelations = !!args?.with;
|
|
@@ -614,6 +635,7 @@ export class QueryInterface {
|
|
|
614
635
|
...args,
|
|
615
636
|
limit: batchSize + 1,
|
|
616
637
|
});
|
|
638
|
+
this.currentAction = 'findManyStream';
|
|
617
639
|
const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
|
|
618
640
|
if (speculativeResult.rows.length <= batchSize) {
|
|
619
641
|
// Small drain — yield all rows and return, no cursor needed
|
|
@@ -662,7 +684,6 @@ export class QueryInterface {
|
|
|
662
684
|
// -------------------------------------------------------------------------
|
|
663
685
|
// findFirst — like findMany but returns a single row or null
|
|
664
686
|
// -------------------------------------------------------------------------
|
|
665
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
666
687
|
async findFirst(args) {
|
|
667
688
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
668
689
|
const deferred = this.buildFindFirst(args);
|
|
@@ -688,7 +709,6 @@ export class QueryInterface {
|
|
|
688
709
|
// -------------------------------------------------------------------------
|
|
689
710
|
// findFirstOrThrow — like findFirst but throws if no record found
|
|
690
711
|
// -------------------------------------------------------------------------
|
|
691
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
692
712
|
async findFirstOrThrow(args) {
|
|
693
713
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
694
714
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
@@ -719,7 +739,6 @@ export class QueryInterface {
|
|
|
719
739
|
// -------------------------------------------------------------------------
|
|
720
740
|
// findUniqueOrThrow — like findUnique but throws if no record found
|
|
721
741
|
// -------------------------------------------------------------------------
|
|
722
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
723
742
|
async findUniqueOrThrow(args) {
|
|
724
743
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
725
744
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
@@ -1633,7 +1652,11 @@ export class QueryInterface {
|
|
|
1633
1652
|
const relDef = this.tableMeta.relations[key];
|
|
1634
1653
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1635
1654
|
const filterObj = value;
|
|
1636
|
-
if ('some' in filterObj ||
|
|
1655
|
+
if ('some' in filterObj ||
|
|
1656
|
+
'every' in filterObj ||
|
|
1657
|
+
'none' in filterObj ||
|
|
1658
|
+
'is' in filterObj ||
|
|
1659
|
+
'isNot' in filterObj) {
|
|
1637
1660
|
const relParts = [];
|
|
1638
1661
|
if (filterObj.some !== undefined)
|
|
1639
1662
|
relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
@@ -1641,6 +1664,10 @@ export class QueryInterface {
|
|
|
1641
1664
|
relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
1642
1665
|
if (filterObj.none !== undefined)
|
|
1643
1666
|
relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
1667
|
+
if (filterObj.is !== undefined)
|
|
1668
|
+
relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
1669
|
+
if (filterObj.isNot !== undefined)
|
|
1670
|
+
relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
1644
1671
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
1645
1672
|
continue;
|
|
1646
1673
|
}
|
|
@@ -1762,13 +1789,21 @@ export class QueryInterface {
|
|
|
1762
1789
|
const relationDef = this.tableMeta.relations[key];
|
|
1763
1790
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1764
1791
|
const filterObj = value;
|
|
1765
|
-
if ('some' in filterObj ||
|
|
1792
|
+
if ('some' in filterObj ||
|
|
1793
|
+
'every' in filterObj ||
|
|
1794
|
+
'none' in filterObj ||
|
|
1795
|
+
'is' in filterObj ||
|
|
1796
|
+
'isNot' in filterObj) {
|
|
1766
1797
|
if (filterObj.some !== undefined)
|
|
1767
1798
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
1768
1799
|
if (filterObj.none !== undefined)
|
|
1769
1800
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
1770
1801
|
if (filterObj.every !== undefined)
|
|
1771
1802
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
1803
|
+
if (filterObj.is !== undefined)
|
|
1804
|
+
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
1805
|
+
if (filterObj.isNot !== undefined)
|
|
1806
|
+
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
1772
1807
|
continue;
|
|
1773
1808
|
}
|
|
1774
1809
|
}
|
|
@@ -2121,7 +2156,11 @@ export class QueryInterface {
|
|
|
2121
2156
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2122
2157
|
const filterObj = value;
|
|
2123
2158
|
// Check if this is a relation filter (has some/every/none keys)
|
|
2124
|
-
if ('some' in filterObj ||
|
|
2159
|
+
if ('some' in filterObj ||
|
|
2160
|
+
'every' in filterObj ||
|
|
2161
|
+
'none' in filterObj ||
|
|
2162
|
+
'is' in filterObj ||
|
|
2163
|
+
'isNot' in filterObj) {
|
|
2125
2164
|
const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
|
|
2126
2165
|
if (relClause)
|
|
2127
2166
|
andClauses.push(relClause);
|
|
@@ -2238,6 +2277,20 @@ export class QueryInterface {
|
|
|
2238
2277
|
// "every" with empty filter = true (all match trivially)
|
|
2239
2278
|
}
|
|
2240
2279
|
}
|
|
2280
|
+
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
2281
|
+
if (filterObj.is !== undefined) {
|
|
2282
|
+
const subWhere = filterObj.is;
|
|
2283
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2284
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2285
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2286
|
+
}
|
|
2287
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
2288
|
+
if (filterObj.isNot !== undefined) {
|
|
2289
|
+
const subWhere = filterObj.isNot;
|
|
2290
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2291
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2292
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2293
|
+
}
|
|
2241
2294
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2242
2295
|
}
|
|
2243
2296
|
/**
|
package/dist/query/index.d.ts
CHANGED
|
@@ -10,6 +10,6 @@ export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, C
|
|
|
10
10
|
export { postgresDialect } from '../dialect.js';
|
|
11
11
|
export type { SqlCacheEntry } from './utils.js';
|
|
12
12
|
export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
|
|
13
|
-
export type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './builder.js';
|
|
13
|
+
export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions } from './builder.js';
|
|
14
14
|
export { QueryInterface } from './builder.js';
|
|
15
15
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. 1 dependency, ~110KB",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"status": "tsx src/cli/index.ts status",
|
|
50
50
|
"examples": "tsx examples/examples.ts",
|
|
51
51
|
"test": "tsx --test src/test/*.test.ts",
|
|
52
|
-
"test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts",
|
|
52
|
+
"test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts src/test/nested-write.test.ts src/test/nested-write-update-upsert.test.ts src/test/cursor-pagination.test.ts src/test/client-branches.test.ts src/test/is-isNot-filter.test.ts src/test/event-emitter.test.ts src/test/observe.test.ts",
|
|
53
53
|
"test:coverage": "c8 tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts",
|
|
54
54
|
"lint": "biome check src/",
|
|
55
55
|
"lint:fix": "biome check --write src/",
|