turbine-orm 0.15.0 → 0.18.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/README.md +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- 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 +5 -1
- package/dist/cjs/client.js +218 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +164 -10
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +604 -25
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- 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.js +5 -1
- package/dist/client.d.ts +129 -2
- package/dist/client.js +220 -2
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +164 -10
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +121 -1
- package/dist/query/builder.js +605 -26
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- package/package.json +18 -16
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().`);
|
|
@@ -137,17 +137,29 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
|
|
|
137
137
|
}
|
|
138
138
|
validateOps(relName, ops, false);
|
|
139
139
|
}
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
//
|
|
140
|
+
// belongsTo relations put the foreign key on the PARENT row, so they must be
|
|
141
|
+
// resolved BEFORE the parent is inserted — otherwise a NOT NULL FK column
|
|
142
|
+
// fails on the initial INSERT. We resolve each belongsTo op (create/connect/
|
|
143
|
+
// connectOrCreate) to its referenced row and fold the FK values into the
|
|
144
|
+
// parent's own INSERT.
|
|
145
|
+
const belongsToFks = {};
|
|
146
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
147
|
+
const rel = tableMeta.relations[relName];
|
|
148
|
+
if (rel.type === 'belongsTo') {
|
|
149
|
+
Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Insert the parent row (scalars + resolved belongsTo foreign keys)
|
|
153
|
+
const parentRow = (await ctx.tx.table(tableName).create({
|
|
154
|
+
data: { ...scalars, ...belongsToFks },
|
|
155
|
+
}));
|
|
156
|
+
// Process hasMany / hasOne relations — their FK lives on the CHILD, so they
|
|
157
|
+
// need the parent row to exist first.
|
|
143
158
|
for (const [relName, ops] of Object.entries(relations)) {
|
|
144
159
|
const rel = tableMeta.relations[relName];
|
|
145
160
|
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
146
161
|
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
147
162
|
}
|
|
148
|
-
else if (rel.type === 'belongsTo') {
|
|
149
|
-
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
150
|
-
}
|
|
151
163
|
}
|
|
152
164
|
// Build the `with` clause for the final read to return the full tree
|
|
153
165
|
const withClause = {};
|
|
@@ -216,9 +228,25 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
216
228
|
if (ops.delete !== undefined) {
|
|
217
229
|
await processDelete(ctx, rel, ops.delete);
|
|
218
230
|
}
|
|
231
|
+
// update
|
|
232
|
+
if (ops.update !== undefined) {
|
|
233
|
+
await processNestedUpdate(ctx, rel, ops.update);
|
|
234
|
+
}
|
|
235
|
+
// upsert
|
|
236
|
+
if (ops.upsert !== undefined) {
|
|
237
|
+
await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
|
|
238
|
+
}
|
|
219
239
|
}
|
|
220
240
|
else if (rel.type === 'belongsTo') {
|
|
221
241
|
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
242
|
+
// update (belongsTo — derive where from parent FK)
|
|
243
|
+
if (ops.update !== undefined) {
|
|
244
|
+
await processBelongsToUpdate(ctx, rel, ops.update, parentRow, tableName);
|
|
245
|
+
}
|
|
246
|
+
// upsert (belongsTo)
|
|
247
|
+
if (ops.upsert !== undefined) {
|
|
248
|
+
await processBelongsToUpsert(ctx, rel, ops.upsert, parentRow, tableName);
|
|
249
|
+
}
|
|
222
250
|
if (ops.disconnect !== undefined) {
|
|
223
251
|
// For belongsTo disconnect, null out the FK on the parent
|
|
224
252
|
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
@@ -296,6 +324,58 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
|
|
|
296
324
|
// ---------------------------------------------------------------------------
|
|
297
325
|
// belongsTo create operations
|
|
298
326
|
// ---------------------------------------------------------------------------
|
|
327
|
+
/**
|
|
328
|
+
* Resolve a belongsTo relation's create/connect/connectOrCreate op to the
|
|
329
|
+
* foreign-key value(s) that belong on the PARENT row, returning them keyed by
|
|
330
|
+
* the parent's own field names so they can be merged into the parent INSERT.
|
|
331
|
+
*
|
|
332
|
+
* Used by the create path only. (The update path uses processBelongsToCreate,
|
|
333
|
+
* which UPDATEs the FK after the parent already exists.)
|
|
334
|
+
*/
|
|
335
|
+
async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path, relName) {
|
|
336
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
337
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
338
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
339
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
340
|
+
let relatedRow = null;
|
|
341
|
+
if (ops.create !== undefined) {
|
|
342
|
+
const items = toArray(ops.create);
|
|
343
|
+
if (items.length > 0) {
|
|
344
|
+
relatedRow = (await executeNestedCreate(ctx, rel.to, items[0], depth + 1, [...path, relName]));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else if (ops.connect !== undefined) {
|
|
348
|
+
const items = toArray(ops.connect);
|
|
349
|
+
if (items.length > 0) {
|
|
350
|
+
const target = items[0];
|
|
351
|
+
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
352
|
+
if (!relatedRow) {
|
|
353
|
+
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (ops.connectOrCreate !== undefined) {
|
|
358
|
+
const items = toArray(ops.connectOrCreate);
|
|
359
|
+
if (items.length > 0) {
|
|
360
|
+
const op = items[0];
|
|
361
|
+
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: op.where }));
|
|
362
|
+
if (!relatedRow) {
|
|
363
|
+
// For belongsTo the FK lives on the parent, so the related row is
|
|
364
|
+
// created plainly (no FK injection) and we read its reference key.
|
|
365
|
+
relatedRow = (await ctx.tx.table(rel.to).create({ data: op.create }));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const fkScalars = {};
|
|
370
|
+
if (relatedRow) {
|
|
371
|
+
for (let i = 0; i < fks.length; i++) {
|
|
372
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
373
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
374
|
+
fkScalars[fkField] = relatedRow[refField];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return fkScalars;
|
|
378
|
+
}
|
|
299
379
|
async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, depth, path, relName) {
|
|
300
380
|
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
301
381
|
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
@@ -452,6 +532,80 @@ async function processSet(ctx, rel, setItems, parentRow) {
|
|
|
452
532
|
await ctx.tx.table(rel.to).update({ where: target, data: updateData });
|
|
453
533
|
}
|
|
454
534
|
}
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// update / upsert operations (update-context only)
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
async function processNestedUpdate(ctx, rel, updateArg) {
|
|
539
|
+
const items = toArray(updateArg);
|
|
540
|
+
for (const item of items) {
|
|
541
|
+
if (!item.where || !item.data) {
|
|
542
|
+
throw new ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
|
|
543
|
+
}
|
|
544
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
|
|
548
|
+
const items = toArray(upsertArg);
|
|
549
|
+
for (const item of items) {
|
|
550
|
+
if (!item.where || !item.create || !item.update) {
|
|
551
|
+
throw new ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
552
|
+
}
|
|
553
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
554
|
+
if (existing) {
|
|
555
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
|
|
559
|
+
await ctx.tx.table(rel.to).create({ data: injected });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTable) {
|
|
564
|
+
const item = updateArg;
|
|
565
|
+
if (!item.data) {
|
|
566
|
+
throw new ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
|
|
567
|
+
}
|
|
568
|
+
// Derive where from parent's FK values
|
|
569
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
570
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
571
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
572
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
573
|
+
const where = {};
|
|
574
|
+
for (let i = 0; i < fks.length; i++) {
|
|
575
|
+
const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
576
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
577
|
+
where[refField] = parentRow[fkField];
|
|
578
|
+
}
|
|
579
|
+
await ctx.tx.table(rel.to).update({ where, data: item.data });
|
|
580
|
+
}
|
|
581
|
+
async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
|
|
582
|
+
const item = upsertArg;
|
|
583
|
+
if (!item.where || !item.create || !item.update) {
|
|
584
|
+
throw new ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
585
|
+
}
|
|
586
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
587
|
+
if (existing) {
|
|
588
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
// Create the related row, then update parent's FK to point at it
|
|
592
|
+
const createdRow = (await ctx.tx.table(rel.to).create({ data: item.create }));
|
|
593
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
594
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
595
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
596
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
597
|
+
const updateData = {};
|
|
598
|
+
for (let i = 0; i < fks.length; i++) {
|
|
599
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
600
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
601
|
+
updateData[fkField] = createdRow[refField];
|
|
602
|
+
}
|
|
603
|
+
await ctx.tx.table(parentTable).update({
|
|
604
|
+
where: pkWhere(parentMeta, parentRow),
|
|
605
|
+
data: updateData,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
455
609
|
async function processDelete(ctx, rel, deleteArg) {
|
|
456
610
|
const items = toArray(deleteArg);
|
|
457
611
|
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;
|
|
@@ -100,10 +114,20 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
100
114
|
private readonly columnArrayTypeMap;
|
|
101
115
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
102
116
|
private readonly deepWithWarned;
|
|
117
|
+
/**
|
|
118
|
+
* Per-table memo of date columns keyed by their camelCase FIELD name.
|
|
119
|
+
* `meta.dateColumns` is keyed by raw snake_case column name, which matches
|
|
120
|
+
* top-level rows from pg. Nested relation rows arrive from json_build_object
|
|
121
|
+
* with camelCase keys, so they need this camelCase-keyed set to be coerced
|
|
122
|
+
* to Date as well (otherwise nested dates leak through as strings).
|
|
123
|
+
*/
|
|
124
|
+
private readonly camelDateFieldCache;
|
|
103
125
|
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
104
126
|
private readonly txScoped;
|
|
105
127
|
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
106
128
|
private readonly options?;
|
|
129
|
+
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
130
|
+
private currentAction;
|
|
107
131
|
constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
108
132
|
/** Quote an identifier through the active SQL dialect. */
|
|
109
133
|
private q;
|
|
@@ -133,6 +157,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
133
157
|
* exactly once per table without bleeding state between assertions.
|
|
134
158
|
*/
|
|
135
159
|
resetUnlimitedWarnings(): void;
|
|
160
|
+
private emitQueryEvent;
|
|
136
161
|
/**
|
|
137
162
|
* Execute a pool.query with an optional timeout.
|
|
138
163
|
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
@@ -234,6 +259,24 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
234
259
|
buildCount(args?: CountArgs<T>): DeferredQuery<number>;
|
|
235
260
|
groupBy(args: GroupByArgs<T>): Promise<Record<string, unknown>[]>;
|
|
236
261
|
buildGroupBy(args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
|
|
262
|
+
/**
|
|
263
|
+
* Build the SQL fragments for a {@link HavingClause}.
|
|
264
|
+
*
|
|
265
|
+
* Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
|
|
266
|
+
* from a **schema-validated, quoted** column identifier — `this.toColumn()`
|
|
267
|
+
* throws {@link ValidationError} for unknown fields and `this.q()` quotes via
|
|
268
|
+
* the dialect, so no unvalidated identifier ever reaches the SQL string. Every
|
|
269
|
+
* comparison value is pushed onto the shared `params` array and referenced by
|
|
270
|
+
* a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
|
|
271
|
+
* interpolation of user values.
|
|
272
|
+
*/
|
|
273
|
+
private buildHavingClauses;
|
|
274
|
+
/**
|
|
275
|
+
* Convert a single having filter into one or more parameterized SQL
|
|
276
|
+
* comparisons against the given aggregate expression. A bare number is
|
|
277
|
+
* shorthand for equality. Unknown operator keys throw {@link ValidationError}.
|
|
278
|
+
*/
|
|
279
|
+
private buildHavingNumericClauses;
|
|
237
280
|
aggregate(args: AggregateArgs<T>): Promise<AggregateResult<T>>;
|
|
238
281
|
buildAggregate(args: AggregateArgs<T>): DeferredQuery<AggregateResult<T>>;
|
|
239
282
|
/**
|
|
@@ -294,6 +337,19 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
294
337
|
private collectJsonFilterParams;
|
|
295
338
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
296
339
|
private collectArrayFilterParams;
|
|
340
|
+
/**
|
|
341
|
+
* Collect params for an orderBy clause. Only vector KNN ordering pushes a
|
|
342
|
+
* param (the `$n::vector` query vector); plain direction ordering is
|
|
343
|
+
* parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
|
|
344
|
+
* param re-collection stays in lockstep.
|
|
345
|
+
*/
|
|
346
|
+
private collectOrderByParams;
|
|
347
|
+
/**
|
|
348
|
+
* Collect params for a vector distance WHERE filter. Mirrors
|
|
349
|
+
* {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
|
|
350
|
+
* the comparison threshold(s).
|
|
351
|
+
*/
|
|
352
|
+
private collectVectorFilterParams;
|
|
297
353
|
/**
|
|
298
354
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
299
355
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -351,9 +407,40 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
351
407
|
* Each operator key becomes its own clause, all ANDed together.
|
|
352
408
|
*/
|
|
353
409
|
private buildOperatorClauses;
|
|
354
|
-
/**
|
|
410
|
+
/**
|
|
411
|
+
* Build ORDER BY clause from an object.
|
|
412
|
+
*
|
|
413
|
+
* Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
|
|
414
|
+
* columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
|
|
415
|
+
* Vector ordering binds the query vector as a `$n::vector` param, so a `params`
|
|
416
|
+
* array MUST be supplied when a vector ordering may be present (top-level
|
|
417
|
+
* findMany path). When `params` is omitted (groupBy / relation path) a vector
|
|
418
|
+
* ordering throws — KNN ordering is only supported at the top level.
|
|
419
|
+
*/
|
|
355
420
|
private buildOrderBy;
|
|
421
|
+
/**
|
|
422
|
+
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
423
|
+
* fixed allow-list, validating the target column is actually a `vector`
|
|
424
|
+
* column. Throws {@link ValidationError} for an unknown metric or a
|
|
425
|
+
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
426
|
+
*/
|
|
427
|
+
private vectorOperator;
|
|
428
|
+
/**
|
|
429
|
+
* Validate and bind a query vector as a single `$n::vector` parameter.
|
|
430
|
+
* Every element must be a finite number (no NaN / Infinity / strings) so a
|
|
431
|
+
* malformed array can never produce a broken `::vector` literal, and the array
|
|
432
|
+
* is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
|
|
433
|
+
* placeholder string.
|
|
434
|
+
*/
|
|
435
|
+
private pushVectorParam;
|
|
356
436
|
/** Parse a flat row: convert snake_case to camelCase + Date coercion */
|
|
437
|
+
/**
|
|
438
|
+
* Returns the set of camelCase field names for a table's date columns,
|
|
439
|
+
* derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
|
|
440
|
+
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
441
|
+
* dates the same way top-level rows do.
|
|
442
|
+
*/
|
|
443
|
+
private getCamelDateFields;
|
|
357
444
|
private parseRow;
|
|
358
445
|
/** Parse a row that may contain JSON nested relation columns */
|
|
359
446
|
private parseNestedRow;
|
|
@@ -492,6 +579,27 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
492
579
|
* evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
|
|
493
580
|
*/
|
|
494
581
|
private buildRelationSubquery;
|
|
582
|
+
/**
|
|
583
|
+
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
584
|
+
* table through a junction (join) table.
|
|
585
|
+
*
|
|
586
|
+
* Shape (no LIMIT/ORDER):
|
|
587
|
+
* ```sql
|
|
588
|
+
* SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
|
|
589
|
+
* FROM <target> <talias>
|
|
590
|
+
* JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
|
|
591
|
+
* WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
|
|
592
|
+
* ```
|
|
593
|
+
*
|
|
594
|
+
* With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
|
|
595
|
+
* applies BEFORE aggregation (identical strategy to hasMany).
|
|
596
|
+
*
|
|
597
|
+
* Cardinality is always 'many' → empty-array fallback, never NULL.
|
|
598
|
+
*
|
|
599
|
+
* IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
|
|
600
|
+
* {@link collectRelationSubqueryParams} or pipeline batching will desync.
|
|
601
|
+
*/
|
|
602
|
+
private buildManyToManySubquery;
|
|
495
603
|
/**
|
|
496
604
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
497
605
|
* Used to detect JSONB/array columns for specialized operators.
|
|
@@ -513,6 +621,18 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
513
621
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
514
622
|
*/
|
|
515
623
|
private buildArrayFilterClauses;
|
|
624
|
+
/**
|
|
625
|
+
* Build SQL clauses for a pgvector distance WHERE filter:
|
|
626
|
+
*
|
|
627
|
+
* `"embedding" <-> $1::vector < $2`
|
|
628
|
+
*
|
|
629
|
+
* The query vector is bound as a `$n::vector` param (never interpolated), the
|
|
630
|
+
* metric maps to an operator via a fixed allow-list, and each comparison
|
|
631
|
+
* threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
|
|
632
|
+
* per supplied comparator (all ANDed). Param push order matches
|
|
633
|
+
* {@link collectVectorFilterParams}.
|
|
634
|
+
*/
|
|
635
|
+
private buildVectorFilterClauses;
|
|
516
636
|
/**
|
|
517
637
|
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
518
638
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|