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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm — Observability module
|
|
4
|
+
*
|
|
5
|
+
* Buffers query metrics in memory (keyed by model:action per minute bucket),
|
|
6
|
+
* then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
|
|
7
|
+
* to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
|
|
8
|
+
* so metrics writes never contend with the application pool.
|
|
9
|
+
*/
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.ObserveEngine = void 0;
|
|
15
|
+
exports.floorToMinute = floorToMinute;
|
|
16
|
+
exports.percentile = percentile;
|
|
17
|
+
const pg_1 = __importDefault(require("pg"));
|
|
18
|
+
function floorToMinute(date) {
|
|
19
|
+
const d = new Date(date);
|
|
20
|
+
d.setSeconds(0, 0);
|
|
21
|
+
return d;
|
|
22
|
+
}
|
|
23
|
+
function percentile(sorted, p) {
|
|
24
|
+
if (sorted.length === 0)
|
|
25
|
+
return 0;
|
|
26
|
+
const idx = Math.ceil(p * sorted.length) - 1;
|
|
27
|
+
return sorted[Math.max(0, idx)];
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Schema DDL
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const SCHEMA_DDL = `
|
|
33
|
+
CREATE TABLE IF NOT EXISTS _turbine_metrics (
|
|
34
|
+
id BIGSERIAL PRIMARY KEY,
|
|
35
|
+
bucket TIMESTAMPTZ NOT NULL,
|
|
36
|
+
model TEXT NOT NULL,
|
|
37
|
+
action TEXT NOT NULL,
|
|
38
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
avg_ms REAL NOT NULL DEFAULT 0,
|
|
40
|
+
p50_ms REAL NOT NULL DEFAULT 0,
|
|
41
|
+
p95_ms REAL NOT NULL DEFAULT 0,
|
|
42
|
+
p99_ms REAL NOT NULL DEFAULT 0,
|
|
43
|
+
error_count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
UNIQUE(bucket, model, action)
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_turbine_metrics_bucket ON _turbine_metrics(bucket);
|
|
47
|
+
`;
|
|
48
|
+
const UPSERT_SQL = `
|
|
49
|
+
INSERT INTO _turbine_metrics (bucket, model, action, count, avg_ms, p50_ms, p95_ms, p99_ms, error_count)
|
|
50
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
51
|
+
ON CONFLICT (bucket, model, action) DO UPDATE SET
|
|
52
|
+
count = _turbine_metrics.count + EXCLUDED.count,
|
|
53
|
+
avg_ms = (_turbine_metrics.avg_ms * _turbine_metrics.count + EXCLUDED.avg_ms * EXCLUDED.count)
|
|
54
|
+
/ (_turbine_metrics.count + EXCLUDED.count),
|
|
55
|
+
p50_ms = EXCLUDED.p50_ms,
|
|
56
|
+
p95_ms = EXCLUDED.p95_ms,
|
|
57
|
+
p99_ms = EXCLUDED.p99_ms,
|
|
58
|
+
error_count = _turbine_metrics.error_count + EXCLUDED.error_count
|
|
59
|
+
`;
|
|
60
|
+
const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Observe engine
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
class ObserveEngine {
|
|
65
|
+
pool;
|
|
66
|
+
buffer = new Map();
|
|
67
|
+
currentBucket;
|
|
68
|
+
flushIntervalMs;
|
|
69
|
+
retentionDays;
|
|
70
|
+
timer;
|
|
71
|
+
listener;
|
|
72
|
+
stopped = false;
|
|
73
|
+
constructor(config) {
|
|
74
|
+
this.pool = new pg_1.default.Pool({ connectionString: config.connectionString, max: 1 });
|
|
75
|
+
this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
|
|
76
|
+
this.retentionDays = config.retentionDays ?? 30;
|
|
77
|
+
this.currentBucket = floorToMinute(new Date());
|
|
78
|
+
this.listener = (event) => {
|
|
79
|
+
if (this.stopped)
|
|
80
|
+
return;
|
|
81
|
+
const nowBucket = floorToMinute(new Date());
|
|
82
|
+
if (nowBucket.getTime() !== this.currentBucket.getTime()) {
|
|
83
|
+
this.currentBucket = nowBucket;
|
|
84
|
+
}
|
|
85
|
+
const key = `${event.model}:${event.action}`;
|
|
86
|
+
let entry = this.buffer.get(key);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
entry = { durations: [], errors: 0 };
|
|
89
|
+
this.buffer.set(key, entry);
|
|
90
|
+
}
|
|
91
|
+
entry.durations.push(event.duration);
|
|
92
|
+
if (event.error)
|
|
93
|
+
entry.errors++;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
getListener() {
|
|
97
|
+
return this.listener;
|
|
98
|
+
}
|
|
99
|
+
async init() {
|
|
100
|
+
await this.pool.query(SCHEMA_DDL);
|
|
101
|
+
this.timer = setInterval(() => {
|
|
102
|
+
this.flush().catch(() => { });
|
|
103
|
+
}, this.flushIntervalMs);
|
|
104
|
+
// Unref so it doesn't keep the process alive
|
|
105
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
106
|
+
this.timer.unref();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async flush() {
|
|
110
|
+
if (this.buffer.size === 0)
|
|
111
|
+
return;
|
|
112
|
+
const bucket = this.currentBucket;
|
|
113
|
+
const entries = new Map(this.buffer);
|
|
114
|
+
this.buffer.clear();
|
|
115
|
+
for (const [key, entry] of entries) {
|
|
116
|
+
const [model, action] = key.split(':');
|
|
117
|
+
const sorted = entry.durations.slice().sort((a, b) => a - b);
|
|
118
|
+
const count = sorted.length;
|
|
119
|
+
const avg = sorted.reduce((s, v) => s + v, 0) / count;
|
|
120
|
+
const p50 = percentile(sorted, 0.5);
|
|
121
|
+
const p95 = percentile(sorted, 0.95);
|
|
122
|
+
const p99 = percentile(sorted, 0.99);
|
|
123
|
+
try {
|
|
124
|
+
await this.pool.query(UPSERT_SQL, [bucket, model, action, count, avg, p50, p95, p99, entry.errors]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Fire-and-forget — never throw from flush
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await this.pool.query(RETENTION_SQL, [this.retentionDays]);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Best effort
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async stop() {
|
|
138
|
+
this.stopped = true;
|
|
139
|
+
if (this.timer)
|
|
140
|
+
clearInterval(this.timer);
|
|
141
|
+
await this.flush();
|
|
142
|
+
await this.pool.end();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.ObserveEngine = ObserveEngine;
|
|
@@ -11,10 +11,44 @@
|
|
|
11
11
|
* Schema-driven: all column names, types, and relations come from introspected
|
|
12
12
|
* metadata — nothing is hardcoded.
|
|
13
13
|
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
14
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
48
|
exports.QueryInterface = void 0;
|
|
16
49
|
const dialect_js_1 = require("../dialect.js");
|
|
17
50
|
const errors_js_1 = require("../errors.js");
|
|
51
|
+
const nested_write_js_1 = require("../nested-write.js");
|
|
18
52
|
const schema_js_1 = require("../schema.js");
|
|
19
53
|
const utils_js_1 = require("./utils.js");
|
|
20
54
|
// ---------------------------------------------------------------------------
|
|
@@ -100,6 +134,28 @@ function findArrayUniqueKey(value) {
|
|
|
100
134
|
}
|
|
101
135
|
return null;
|
|
102
136
|
}
|
|
137
|
+
/** Known text search operator keys */
|
|
138
|
+
const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
|
|
139
|
+
/** Check if a value is a TextSearchFilter object */
|
|
140
|
+
function isTextSearchFilter(value) {
|
|
141
|
+
if (value === null ||
|
|
142
|
+
value === undefined ||
|
|
143
|
+
typeof value !== 'object' ||
|
|
144
|
+
Array.isArray(value) ||
|
|
145
|
+
value instanceof Date) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const keys = Object.keys(value);
|
|
149
|
+
// Must have 'search' key and only known text search keys
|
|
150
|
+
return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Validate a text search config name. Only alphanumeric characters and
|
|
154
|
+
* underscores are allowed to prevent SQL injection via the config parameter.
|
|
155
|
+
*/
|
|
156
|
+
function validateTextSearchConfig(config) {
|
|
157
|
+
return /^[a-zA-Z0-9_]+$/.test(config);
|
|
158
|
+
}
|
|
103
159
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
104
160
|
class QueryInterface {
|
|
105
161
|
pool;
|
|
@@ -131,6 +187,12 @@ class QueryInterface {
|
|
|
131
187
|
columnArrayTypeMap;
|
|
132
188
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
133
189
|
deepWithWarned = new Set();
|
|
190
|
+
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
191
|
+
txScoped;
|
|
192
|
+
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
193
|
+
options;
|
|
194
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
195
|
+
currentAction = 'raw';
|
|
134
196
|
constructor(pool, table, schema, middlewares, options) {
|
|
135
197
|
this.pool = pool;
|
|
136
198
|
this.table = table;
|
|
@@ -149,6 +211,8 @@ class QueryInterface {
|
|
|
149
211
|
this.preparedStatementsEnabled = options?.preparedStatements ?? true;
|
|
150
212
|
this.sqlCacheEnabled = options?.sqlCache !== false;
|
|
151
213
|
this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
214
|
+
this.txScoped = options?._txScoped ?? false;
|
|
215
|
+
this.options = options;
|
|
152
216
|
// Pre-compute column type lookup maps (TASK-26)
|
|
153
217
|
this.columnPgTypeMap = new Map();
|
|
154
218
|
this.columnArrayTypeMap = new Map();
|
|
@@ -210,12 +274,25 @@ class QueryInterface {
|
|
|
210
274
|
resetUnlimitedWarnings() {
|
|
211
275
|
this.warnedTables.clear();
|
|
212
276
|
}
|
|
277
|
+
emitQueryEvent(sql, params, duration, action, rows, error) {
|
|
278
|
+
const onQuery = this.options?._onQuery;
|
|
279
|
+
if (!onQuery)
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Listener errors must never crash a query
|
|
286
|
+
}
|
|
287
|
+
}
|
|
213
288
|
/**
|
|
214
289
|
* Execute a pool.query with an optional timeout.
|
|
215
290
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
216
291
|
* pg driver errors are translated to typed Turbine errors via wrapPgError.
|
|
217
292
|
*/
|
|
218
293
|
async queryWithTimeout(sql, params, timeout, preparedName) {
|
|
294
|
+
const start = performance.now();
|
|
295
|
+
const action = this.currentAction;
|
|
219
296
|
// Build the query argument — use object form with `name` for prepared
|
|
220
297
|
// statements, or the plain (text, values) form otherwise.
|
|
221
298
|
const usePrepared = preparedName && this.preparedStatementsEnabled;
|
|
@@ -224,10 +301,14 @@ class QueryInterface {
|
|
|
224
301
|
: this.pool.query(sql, params);
|
|
225
302
|
if (!timeout) {
|
|
226
303
|
try {
|
|
227
|
-
|
|
304
|
+
const result = await exec;
|
|
305
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
306
|
+
return result;
|
|
228
307
|
}
|
|
229
308
|
catch (err) {
|
|
230
|
-
|
|
309
|
+
const wrapped = (0, errors_js_1.wrapPgError)(err);
|
|
310
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
311
|
+
throw wrapped;
|
|
231
312
|
}
|
|
232
313
|
}
|
|
233
314
|
let timer;
|
|
@@ -235,10 +316,14 @@ class QueryInterface {
|
|
|
235
316
|
timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
|
|
236
317
|
});
|
|
237
318
|
try {
|
|
238
|
-
|
|
319
|
+
const result = await Promise.race([exec, timeoutPromise]);
|
|
320
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
|
|
321
|
+
return result;
|
|
239
322
|
}
|
|
240
323
|
catch (err) {
|
|
241
|
-
|
|
324
|
+
const wrapped = (0, errors_js_1.wrapPgError)(err);
|
|
325
|
+
this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
|
|
326
|
+
throw wrapped;
|
|
242
327
|
}
|
|
243
328
|
finally {
|
|
244
329
|
clearTimeout(timer);
|
|
@@ -254,6 +339,7 @@ class QueryInterface {
|
|
|
254
339
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
255
340
|
*/
|
|
256
341
|
async executeWithMiddleware(action, args, executor) {
|
|
342
|
+
this.currentAction = action;
|
|
257
343
|
if (this.middlewares.length === 0) {
|
|
258
344
|
return executor();
|
|
259
345
|
}
|
|
@@ -273,7 +359,6 @@ class QueryInterface {
|
|
|
273
359
|
// -------------------------------------------------------------------------
|
|
274
360
|
// findUnique
|
|
275
361
|
// -------------------------------------------------------------------------
|
|
276
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
277
362
|
async findUnique(args) {
|
|
278
363
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
279
364
|
const deferred = this.buildFindUnique(args);
|
|
@@ -377,7 +462,6 @@ class QueryInterface {
|
|
|
377
462
|
// -------------------------------------------------------------------------
|
|
378
463
|
// findMany
|
|
379
464
|
// -------------------------------------------------------------------------
|
|
380
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
381
465
|
async findMany(args) {
|
|
382
466
|
this.maybeWarnUnlimited(args);
|
|
383
467
|
// Dev-only: warn on deeply nested with clauses
|
|
@@ -579,7 +663,6 @@ class QueryInterface {
|
|
|
579
663
|
* }
|
|
580
664
|
* ```
|
|
581
665
|
*/
|
|
582
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
583
666
|
async *findManyStream(args) {
|
|
584
667
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
585
668
|
const hasRelations = !!args?.with;
|
|
@@ -588,6 +671,7 @@ class QueryInterface {
|
|
|
588
671
|
...args,
|
|
589
672
|
limit: batchSize + 1,
|
|
590
673
|
});
|
|
674
|
+
this.currentAction = 'findManyStream';
|
|
591
675
|
const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
|
|
592
676
|
if (speculativeResult.rows.length <= batchSize) {
|
|
593
677
|
// Small drain — yield all rows and return, no cursor needed
|
|
@@ -636,7 +720,6 @@ class QueryInterface {
|
|
|
636
720
|
// -------------------------------------------------------------------------
|
|
637
721
|
// findFirst — like findMany but returns a single row or null
|
|
638
722
|
// -------------------------------------------------------------------------
|
|
639
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
640
723
|
async findFirst(args) {
|
|
641
724
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
642
725
|
const deferred = this.buildFindFirst(args);
|
|
@@ -662,7 +745,6 @@ class QueryInterface {
|
|
|
662
745
|
// -------------------------------------------------------------------------
|
|
663
746
|
// findFirstOrThrow — like findFirst but throws if no record found
|
|
664
747
|
// -------------------------------------------------------------------------
|
|
665
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
666
748
|
async findFirstOrThrow(args) {
|
|
667
749
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
668
750
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
@@ -693,7 +775,6 @@ class QueryInterface {
|
|
|
693
775
|
// -------------------------------------------------------------------------
|
|
694
776
|
// findUniqueOrThrow — like findUnique but throws if no record found
|
|
695
777
|
// -------------------------------------------------------------------------
|
|
696
|
-
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
697
778
|
async findUniqueOrThrow(args) {
|
|
698
779
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
699
780
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
@@ -726,6 +807,9 @@ class QueryInterface {
|
|
|
726
807
|
// -------------------------------------------------------------------------
|
|
727
808
|
async create(args) {
|
|
728
809
|
return this.executeWithMiddleware('create', args, async () => {
|
|
810
|
+
if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
|
|
811
|
+
return this.nestedCreate(args);
|
|
812
|
+
}
|
|
729
813
|
const deferred = this.buildCreate(args);
|
|
730
814
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
731
815
|
return deferred.transform(result);
|
|
@@ -808,6 +892,9 @@ class QueryInterface {
|
|
|
808
892
|
// -------------------------------------------------------------------------
|
|
809
893
|
async update(args) {
|
|
810
894
|
return this.executeWithMiddleware('update', args, async () => {
|
|
895
|
+
if ((0, nested_write_js_1.hasRelationFields)(args.data, this.tableMeta)) {
|
|
896
|
+
return this.nestedUpdate(args);
|
|
897
|
+
}
|
|
811
898
|
const deferred = this.buildUpdate(args);
|
|
812
899
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
813
900
|
return deferred.transform(result);
|
|
@@ -816,32 +903,62 @@ class QueryInterface {
|
|
|
816
903
|
buildUpdate(args) {
|
|
817
904
|
const dataObj = args.data;
|
|
818
905
|
const whereObj = args.where;
|
|
906
|
+
const lock = args.optimisticLock;
|
|
819
907
|
const setFp = this.fingerprintSet(dataObj);
|
|
820
908
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
821
|
-
const ck = `u:${setFp}|${whereFp}`;
|
|
909
|
+
const ck = lock ? null : `u:${setFp}|${whereFp}`;
|
|
822
910
|
const params = [];
|
|
823
|
-
const
|
|
911
|
+
const buildSql = () => {
|
|
824
912
|
const freshParams = [];
|
|
825
913
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
826
914
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
915
|
+
if (lock) {
|
|
916
|
+
const versionCol = this.toSqlColumn(lock.field);
|
|
917
|
+
setClauses.push(`${versionCol} = ${versionCol} + 1`);
|
|
918
|
+
}
|
|
827
919
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
828
|
-
|
|
920
|
+
let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
921
|
+
if (lock) {
|
|
922
|
+
const versionCol = this.toSqlColumn(lock.field);
|
|
923
|
+
freshParams.push(lock.expected);
|
|
924
|
+
const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
|
|
925
|
+
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
926
|
+
}
|
|
829
927
|
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
830
928
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
929
|
+
};
|
|
930
|
+
let sql;
|
|
931
|
+
let preparedName;
|
|
932
|
+
if (ck) {
|
|
933
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
934
|
+
sql = entry.sql;
|
|
935
|
+
preparedName = entry.name;
|
|
936
|
+
if (whereFp === '') {
|
|
937
|
+
this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
sql = buildSql();
|
|
835
942
|
}
|
|
836
|
-
// Collect params: SET first, then WHERE (same order as fresh build)
|
|
943
|
+
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
837
944
|
this.collectSetParams(dataObj, params);
|
|
838
945
|
this.collectWhereParams(whereObj, params);
|
|
946
|
+
if (lock) {
|
|
947
|
+
params.push(lock.expected);
|
|
948
|
+
}
|
|
839
949
|
return {
|
|
840
|
-
sql
|
|
950
|
+
sql,
|
|
841
951
|
params,
|
|
842
952
|
transform: (result) => {
|
|
843
953
|
const row = result.rows[0];
|
|
844
954
|
if (!row) {
|
|
955
|
+
if (lock) {
|
|
956
|
+
throw new errors_js_1.OptimisticLockError({
|
|
957
|
+
table: this.table,
|
|
958
|
+
versionField: lock.field,
|
|
959
|
+
expectedVersion: lock.expected,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
845
962
|
throw new errors_js_1.NotFoundError({
|
|
846
963
|
table: this.table,
|
|
847
964
|
where: args.where,
|
|
@@ -851,7 +968,75 @@ class QueryInterface {
|
|
|
851
968
|
return this.parseRow(row, this.table);
|
|
852
969
|
},
|
|
853
970
|
tag: `${this.table}.update`,
|
|
854
|
-
preparedName
|
|
971
|
+
preparedName,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
// -------------------------------------------------------------------------
|
|
975
|
+
// Nested write helpers (shared by create + update)
|
|
976
|
+
// -------------------------------------------------------------------------
|
|
977
|
+
async nestedCreate(args) {
|
|
978
|
+
const data = args.data;
|
|
979
|
+
if (this.txScoped) {
|
|
980
|
+
const ctx = this.buildNestedCtx();
|
|
981
|
+
return (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
|
|
982
|
+
}
|
|
983
|
+
return this.runInImplicitTx(async (ctx) => {
|
|
984
|
+
const result = await (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data);
|
|
985
|
+
return result;
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
async nestedUpdate(args) {
|
|
989
|
+
const data = args.data;
|
|
990
|
+
const where = args.where;
|
|
991
|
+
if (this.txScoped) {
|
|
992
|
+
const ctx = this.buildNestedCtx();
|
|
993
|
+
return (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
|
|
994
|
+
}
|
|
995
|
+
return this.runInImplicitTx(async (ctx) => {
|
|
996
|
+
const result = await (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data);
|
|
997
|
+
return result;
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
async runInImplicitTx(fn) {
|
|
1001
|
+
const client = await this.pool.connect();
|
|
1002
|
+
try {
|
|
1003
|
+
await client.query('BEGIN');
|
|
1004
|
+
const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('../client.js')));
|
|
1005
|
+
// biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
|
|
1006
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
|
|
1007
|
+
// biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
|
|
1008
|
+
const ctx = { schema: this.schema, tx: tx };
|
|
1009
|
+
const result = await fn(ctx);
|
|
1010
|
+
await client.query('COMMIT');
|
|
1011
|
+
return result;
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
try {
|
|
1015
|
+
await client.query('ROLLBACK');
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
// Best-effort rollback — connection may have died.
|
|
1019
|
+
}
|
|
1020
|
+
throw err;
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
client.release();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
buildNestedCtx() {
|
|
1027
|
+
const pool = this.pool;
|
|
1028
|
+
const schema = this.schema;
|
|
1029
|
+
const middlewares = this.middlewares;
|
|
1030
|
+
const opts = { ...this.options, _txScoped: true };
|
|
1031
|
+
return {
|
|
1032
|
+
schema,
|
|
1033
|
+
tx: this.makeTxProxy(pool, schema, middlewares, opts),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
// biome-ignore lint/suspicious/noExplicitAny: bridges MiddlewareFn[] ↔ Middleware[] and QI ↔ NestedWriteContext type gap
|
|
1037
|
+
makeTxProxy(pool, schema, middlewares, opts) {
|
|
1038
|
+
return {
|
|
1039
|
+
table: (name) => new QueryInterface(pool, name, schema, middlewares, opts),
|
|
855
1040
|
};
|
|
856
1041
|
}
|
|
857
1042
|
// -------------------------------------------------------------------------
|
|
@@ -1503,7 +1688,11 @@ class QueryInterface {
|
|
|
1503
1688
|
const relDef = this.tableMeta.relations[key];
|
|
1504
1689
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1505
1690
|
const filterObj = value;
|
|
1506
|
-
if ('some' in filterObj ||
|
|
1691
|
+
if ('some' in filterObj ||
|
|
1692
|
+
'every' in filterObj ||
|
|
1693
|
+
'none' in filterObj ||
|
|
1694
|
+
'is' in filterObj ||
|
|
1695
|
+
'isNot' in filterObj) {
|
|
1507
1696
|
const relParts = [];
|
|
1508
1697
|
if (filterObj.some !== undefined)
|
|
1509
1698
|
relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
@@ -1511,6 +1700,10 @@ class QueryInterface {
|
|
|
1511
1700
|
relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
1512
1701
|
if (filterObj.none !== undefined)
|
|
1513
1702
|
relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
1703
|
+
if (filterObj.is !== undefined)
|
|
1704
|
+
relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
1705
|
+
if (filterObj.isNot !== undefined)
|
|
1706
|
+
relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
1514
1707
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
1515
1708
|
continue;
|
|
1516
1709
|
}
|
|
@@ -1541,6 +1734,12 @@ class QueryInterface {
|
|
|
1541
1734
|
parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
|
|
1542
1735
|
continue;
|
|
1543
1736
|
}
|
|
1737
|
+
// Text search filter
|
|
1738
|
+
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
1739
|
+
const cfg = value.config ?? 'english';
|
|
1740
|
+
parts.push(`${key}:fts(${cfg})`);
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1544
1743
|
// Plain equality
|
|
1545
1744
|
parts.push(`${key}:eq`);
|
|
1546
1745
|
}
|
|
@@ -1626,13 +1825,21 @@ class QueryInterface {
|
|
|
1626
1825
|
const relationDef = this.tableMeta.relations[key];
|
|
1627
1826
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1628
1827
|
const filterObj = value;
|
|
1629
|
-
if ('some' in filterObj ||
|
|
1828
|
+
if ('some' in filterObj ||
|
|
1829
|
+
'every' in filterObj ||
|
|
1830
|
+
'none' in filterObj ||
|
|
1831
|
+
'is' in filterObj ||
|
|
1832
|
+
'isNot' in filterObj) {
|
|
1630
1833
|
if (filterObj.some !== undefined)
|
|
1631
1834
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
1632
1835
|
if (filterObj.none !== undefined)
|
|
1633
1836
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
1634
1837
|
if (filterObj.every !== undefined)
|
|
1635
1838
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
1839
|
+
if (filterObj.is !== undefined)
|
|
1840
|
+
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
1841
|
+
if (filterObj.isNot !== undefined)
|
|
1842
|
+
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
1636
1843
|
continue;
|
|
1637
1844
|
}
|
|
1638
1845
|
}
|
|
@@ -1656,6 +1863,11 @@ class QueryInterface {
|
|
|
1656
1863
|
continue;
|
|
1657
1864
|
}
|
|
1658
1865
|
}
|
|
1866
|
+
// Text search filter
|
|
1867
|
+
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
1868
|
+
params.push(value.search);
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1659
1871
|
// Operator objects
|
|
1660
1872
|
if (isWhereOperator(value)) {
|
|
1661
1873
|
this.collectOperatorParams(value, params);
|
|
@@ -1980,7 +2192,11 @@ class QueryInterface {
|
|
|
1980
2192
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1981
2193
|
const filterObj = value;
|
|
1982
2194
|
// Check if this is a relation filter (has some/every/none keys)
|
|
1983
|
-
if ('some' in filterObj ||
|
|
2195
|
+
if ('some' in filterObj ||
|
|
2196
|
+
'every' in filterObj ||
|
|
2197
|
+
'none' in filterObj ||
|
|
2198
|
+
'is' in filterObj ||
|
|
2199
|
+
'isNot' in filterObj) {
|
|
1984
2200
|
const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
|
|
1985
2201
|
if (relClause)
|
|
1986
2202
|
andClauses.push(relClause);
|
|
@@ -2030,6 +2246,12 @@ class QueryInterface {
|
|
|
2030
2246
|
`(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
|
|
2031
2247
|
}
|
|
2032
2248
|
}
|
|
2249
|
+
// Handle full-text search filter
|
|
2250
|
+
if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
|
|
2251
|
+
const tsClause = this.buildTextSearchClause(column, value, params);
|
|
2252
|
+
andClauses.push(tsClause);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2033
2255
|
// Handle operator objects
|
|
2034
2256
|
if (isWhereOperator(value)) {
|
|
2035
2257
|
const opClauses = this.buildOperatorClauses(column, value, params);
|
|
@@ -2091,6 +2313,20 @@ class QueryInterface {
|
|
|
2091
2313
|
// "every" with empty filter = true (all match trivially)
|
|
2092
2314
|
}
|
|
2093
2315
|
}
|
|
2316
|
+
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
2317
|
+
if (filterObj.is !== undefined) {
|
|
2318
|
+
const subWhere = filterObj.is;
|
|
2319
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2320
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2321
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2322
|
+
}
|
|
2323
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
2324
|
+
if (filterObj.isNot !== undefined) {
|
|
2325
|
+
const subWhere = filterObj.isNot;
|
|
2326
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
2327
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
2328
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
2329
|
+
}
|
|
2094
2330
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2095
2331
|
}
|
|
2096
2332
|
/**
|
|
@@ -2672,6 +2908,18 @@ class QueryInterface {
|
|
|
2672
2908
|
}
|
|
2673
2909
|
return clauses;
|
|
2674
2910
|
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
2913
|
+
* The config name is validated to prevent injection (only alphanumeric + underscore).
|
|
2914
|
+
*/
|
|
2915
|
+
buildTextSearchClause(column, filter, params) {
|
|
2916
|
+
const config = filter.config ?? 'english';
|
|
2917
|
+
if (!validateTextSearchConfig(config)) {
|
|
2918
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
|
|
2919
|
+
}
|
|
2920
|
+
params.push(filter.search);
|
|
2921
|
+
return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${this.p(params.length)})`;
|
|
2922
|
+
}
|
|
2675
2923
|
/**
|
|
2676
2924
|
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
2677
2925
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* turbine seed — Run seed file
|
|
14
14
|
* turbine status — Show schema summary
|
|
15
15
|
* turbine studio — Launch local read-only web UI
|
|
16
|
+
* turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
16
17
|
*
|
|
17
18
|
* Usage:
|
|
18
19
|
* DATABASE_URL=postgres://... npx turbine generate
|