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/cjs/index.js
CHANGED
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.
|
|
38
|
-
exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = void 0;
|
|
37
|
+
exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
|
|
38
|
+
exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = void 0;
|
|
39
39
|
var index_js_1 = require("./adapters/index.js");
|
|
40
40
|
Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
|
|
41
41
|
Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
|
|
@@ -90,6 +90,9 @@ Object.defineProperty(exports, "pipelineSupported", { enumerable: true, get: fun
|
|
|
90
90
|
// Query builder
|
|
91
91
|
var index_js_2 = require("./query/index.js");
|
|
92
92
|
Object.defineProperty(exports, "QueryInterface", { enumerable: true, get: function () { return index_js_2.QueryInterface; } });
|
|
93
|
+
// Realtime — LISTEN/NOTIFY pub/sub
|
|
94
|
+
var realtime_js_1 = require("./realtime.js");
|
|
95
|
+
Object.defineProperty(exports, "validateChannel", { enumerable: true, get: function () { return realtime_js_1.validateChannel; } });
|
|
93
96
|
// Schema utilities
|
|
94
97
|
var schema_js_1 = require("./schema.js");
|
|
95
98
|
Object.defineProperty(exports, "camelToSnake", { enumerable: true, get: function () { return schema_js_1.camelToSnake; } });
|
|
@@ -102,6 +105,7 @@ Object.defineProperty(exports, "snakeToCamel", { enumerable: true, get: function
|
|
|
102
105
|
Object.defineProperty(exports, "snakeToPascal", { enumerable: true, get: function () { return schema_js_1.snakeToPascal; } });
|
|
103
106
|
// Schema builder — define schemas in TypeScript
|
|
104
107
|
var schema_builder_js_1 = require("./schema-builder.js");
|
|
108
|
+
Object.defineProperty(exports, "applyManyToManyRelations", { enumerable: true, get: function () { return schema_builder_js_1.applyManyToManyRelations; } });
|
|
105
109
|
Object.defineProperty(exports, "ColumnBuilder", { enumerable: true, get: function () { return schema_builder_js_1.ColumnBuilder; } });
|
|
106
110
|
Object.defineProperty(exports, "column", { enumerable: true, get: function () { return schema_builder_js_1.column; } });
|
|
107
111
|
Object.defineProperty(exports, "defineSchema", { enumerable: true, get: function () { return schema_builder_js_1.defineSchema; } });
|
|
@@ -116,3 +120,7 @@ Object.defineProperty(exports, "schemaToSQLString", { enumerable: true, get: fun
|
|
|
116
120
|
// Serverless / edge factory
|
|
117
121
|
var serverless_js_1 = require("./serverless.js");
|
|
118
122
|
Object.defineProperty(exports, "turbineHttp", { enumerable: true, get: function () { return serverless_js_1.turbineHttp; } });
|
|
123
|
+
// Typed raw SQL — Turbine's TypedSQL escape hatch
|
|
124
|
+
var typed_sql_js_1 = require("./typed-sql.js");
|
|
125
|
+
Object.defineProperty(exports, "buildTypedSql", { enumerable: true, get: function () { return typed_sql_js_1.buildTypedSql; } });
|
|
126
|
+
Object.defineProperty(exports, "TypedSqlQuery", { enumerable: true, get: function () { return typed_sql_js_1.TypedSqlQuery; } });
|
package/dist/cjs/introspect.js
CHANGED
|
@@ -281,6 +281,87 @@ async function introspect(options) {
|
|
|
281
281
|
referenceKey,
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
|
+
// ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
|
|
285
|
+
//
|
|
286
|
+
// Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
|
|
287
|
+
// junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
|
|
288
|
+
// table is a first-class entity, not a join table. Prisma and Drizzle both
|
|
289
|
+
// require explicit m2m declaration for exactly this reason.
|
|
290
|
+
//
|
|
291
|
+
// We only treat a table J as a PURE junction when ALL of these hold:
|
|
292
|
+
// 1. J's primary key is exactly two columns.
|
|
293
|
+
// 2. J has exactly two FKs, each single-column.
|
|
294
|
+
// 3. Each FK's source column is one of J's two PK columns (the PK *is* the
|
|
295
|
+
// two FK columns — no surrogate PK, no extra identity).
|
|
296
|
+
// 4. The two FKs target two DISTINCT tables (A and B).
|
|
297
|
+
// 5. J has no columns beyond those two FK/PK columns (no payload columns
|
|
298
|
+
// like `grade` or `created_at`).
|
|
299
|
+
//
|
|
300
|
+
// For such a J linking A and B we ADD a `manyToMany` relation on A → B and
|
|
301
|
+
// symmetrically on B → A, both routed `through` J. The existing belongsTo /
|
|
302
|
+
// hasMany relations derived from J's FKs are left untouched — this block
|
|
303
|
+
// never removes or renames anything. If the chosen relation name already
|
|
304
|
+
// exists on the source table (e.g. another relation grabbed it), we SKIP to
|
|
305
|
+
// stay additive.
|
|
306
|
+
for (const tableName of tableNames) {
|
|
307
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
308
|
+
if (pk.length !== 2)
|
|
309
|
+
continue;
|
|
310
|
+
// FKs whose source is this table.
|
|
311
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
312
|
+
if (tableFks.length !== 2)
|
|
313
|
+
continue;
|
|
314
|
+
// Both FKs must be single-column.
|
|
315
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
316
|
+
continue;
|
|
317
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
318
|
+
const pkSet = new Set(pk);
|
|
319
|
+
// Both FK columns must be the PK columns (and vice-versa).
|
|
320
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
321
|
+
continue;
|
|
322
|
+
if (new Set(fkCols).size !== 2)
|
|
323
|
+
continue;
|
|
324
|
+
// Two DISTINCT target tables.
|
|
325
|
+
const [fkA, fkB] = tableFks;
|
|
326
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
327
|
+
continue;
|
|
328
|
+
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
329
|
+
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
330
|
+
if (jCols.length !== 2)
|
|
331
|
+
continue;
|
|
332
|
+
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
333
|
+
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
334
|
+
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
335
|
+
const addM2M = (self, other) => {
|
|
336
|
+
const sourceTbl = self.targetTable; // A
|
|
337
|
+
const targetTbl = other.targetTable; // B
|
|
338
|
+
const relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
|
|
339
|
+
if (!relationsByTable.has(sourceTbl))
|
|
340
|
+
relationsByTable.set(sourceTbl, {});
|
|
341
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
342
|
+
// Additive-only: never clobber an existing relation name.
|
|
343
|
+
if (existing[relName])
|
|
344
|
+
return;
|
|
345
|
+
existing[relName] = {
|
|
346
|
+
type: 'manyToMany',
|
|
347
|
+
name: relName,
|
|
348
|
+
from: sourceTbl,
|
|
349
|
+
to: targetTbl,
|
|
350
|
+
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
351
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
352
|
+
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
353
|
+
// (mirrors the source-side reference for back-compat consumers).
|
|
354
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
355
|
+
through: {
|
|
356
|
+
table: tableName,
|
|
357
|
+
sourceKey: self.sourceColumns[0], // J col → A
|
|
358
|
+
targetKey: other.sourceColumns[0], // J col → B
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
addM2M(fkA, fkB); // A → B
|
|
363
|
+
addM2M(fkB, fkA); // B → A
|
|
364
|
+
}
|
|
284
365
|
// ----- Assemble TableMetadata for each table -----
|
|
285
366
|
const tables = {};
|
|
286
367
|
for (const tableName of tableNames) {
|
package/dist/cjs/nested-write.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Tree-walking create/update that resolves relation fields in `data` into
|
|
6
6
|
* batched SQL operations within a transaction. Supports create, connect,
|
|
7
|
-
* connectOrCreate, disconnect, set, and
|
|
8
|
-
* arbitrary depth (capped at 10).
|
|
7
|
+
* connectOrCreate, disconnect, set, delete, update, and upsert on related
|
|
8
|
+
* records at arbitrary depth (capped at 10).
|
|
9
9
|
*
|
|
10
10
|
* This module is imported by `query/builder.ts` when the `data` argument
|
|
11
11
|
* of `create()` or `update()` contains relation fields. It never imports
|
|
@@ -22,7 +22,7 @@ const errors_js_1 = require("./errors.js");
|
|
|
22
22
|
const schema_js_1 = require("./schema.js");
|
|
23
23
|
const MAX_DEPTH = 10;
|
|
24
24
|
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
25
|
-
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete']);
|
|
25
|
+
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete', 'update', 'upsert']);
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Pure helpers (exported for testing)
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
@@ -100,7 +100,7 @@ function validateOps(relationName, ops, isUpdate) {
|
|
|
100
100
|
for (const opName of Object.keys(ops)) {
|
|
101
101
|
if (!CREATE_ONLY_OPS.has(opName) && !UPDATE_ONLY_OPS.has(opName)) {
|
|
102
102
|
throw new errors_js_1.ValidationError(`[turbine] Unknown nested write operation "${opName}" on relation "${relationName}". ` +
|
|
103
|
-
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete' : ''}.`);
|
|
103
|
+
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete, update, upsert' : ''}.`);
|
|
104
104
|
}
|
|
105
105
|
if (!isUpdate && UPDATE_ONLY_OPS.has(opName)) {
|
|
106
106
|
throw new errors_js_1.ValidationError(`[turbine] Operation "${opName}" on relation "${relationName}" is only valid inside update(), not create().`);
|
|
@@ -144,17 +144,29 @@ async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
|
|
|
144
144
|
}
|
|
145
145
|
validateOps(relName, ops, false);
|
|
146
146
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
//
|
|
147
|
+
// belongsTo relations put the foreign key on the PARENT row, so they must be
|
|
148
|
+
// resolved BEFORE the parent is inserted — otherwise a NOT NULL FK column
|
|
149
|
+
// fails on the initial INSERT. We resolve each belongsTo op (create/connect/
|
|
150
|
+
// connectOrCreate) to its referenced row and fold the FK values into the
|
|
151
|
+
// parent's own INSERT.
|
|
152
|
+
const belongsToFks = {};
|
|
153
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
154
|
+
const rel = tableMeta.relations[relName];
|
|
155
|
+
if (rel.type === 'belongsTo') {
|
|
156
|
+
Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Insert the parent row (scalars + resolved belongsTo foreign keys)
|
|
160
|
+
const parentRow = (await ctx.tx.table(tableName).create({
|
|
161
|
+
data: { ...scalars, ...belongsToFks },
|
|
162
|
+
}));
|
|
163
|
+
// Process hasMany / hasOne relations — their FK lives on the CHILD, so they
|
|
164
|
+
// need the parent row to exist first.
|
|
150
165
|
for (const [relName, ops] of Object.entries(relations)) {
|
|
151
166
|
const rel = tableMeta.relations[relName];
|
|
152
167
|
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
153
168
|
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
154
169
|
}
|
|
155
|
-
else if (rel.type === 'belongsTo') {
|
|
156
|
-
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
157
|
-
}
|
|
158
170
|
}
|
|
159
171
|
// Build the `with` clause for the final read to return the full tree
|
|
160
172
|
const withClause = {};
|
|
@@ -223,9 +235,25 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
|
|
|
223
235
|
if (ops.delete !== undefined) {
|
|
224
236
|
await processDelete(ctx, rel, ops.delete);
|
|
225
237
|
}
|
|
238
|
+
// update
|
|
239
|
+
if (ops.update !== undefined) {
|
|
240
|
+
await processNestedUpdate(ctx, rel, ops.update);
|
|
241
|
+
}
|
|
242
|
+
// upsert
|
|
243
|
+
if (ops.upsert !== undefined) {
|
|
244
|
+
await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
|
|
245
|
+
}
|
|
226
246
|
}
|
|
227
247
|
else if (rel.type === 'belongsTo') {
|
|
228
248
|
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
249
|
+
// update (belongsTo — derive where from parent FK)
|
|
250
|
+
if (ops.update !== undefined) {
|
|
251
|
+
await processBelongsToUpdate(ctx, rel, ops.update, parentRow, tableName);
|
|
252
|
+
}
|
|
253
|
+
// upsert (belongsTo)
|
|
254
|
+
if (ops.upsert !== undefined) {
|
|
255
|
+
await processBelongsToUpsert(ctx, rel, ops.upsert, parentRow, tableName);
|
|
256
|
+
}
|
|
229
257
|
if (ops.disconnect !== undefined) {
|
|
230
258
|
// For belongsTo disconnect, null out the FK on the parent
|
|
231
259
|
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
@@ -303,6 +331,58 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
|
|
|
303
331
|
// ---------------------------------------------------------------------------
|
|
304
332
|
// belongsTo create operations
|
|
305
333
|
// ---------------------------------------------------------------------------
|
|
334
|
+
/**
|
|
335
|
+
* Resolve a belongsTo relation's create/connect/connectOrCreate op to the
|
|
336
|
+
* foreign-key value(s) that belong on the PARENT row, returning them keyed by
|
|
337
|
+
* the parent's own field names so they can be merged into the parent INSERT.
|
|
338
|
+
*
|
|
339
|
+
* Used by the create path only. (The update path uses processBelongsToCreate,
|
|
340
|
+
* which UPDATEs the FK after the parent already exists.)
|
|
341
|
+
*/
|
|
342
|
+
async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path, relName) {
|
|
343
|
+
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
344
|
+
const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
345
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
346
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
347
|
+
let relatedRow = null;
|
|
348
|
+
if (ops.create !== undefined) {
|
|
349
|
+
const items = toArray(ops.create);
|
|
350
|
+
if (items.length > 0) {
|
|
351
|
+
relatedRow = (await executeNestedCreate(ctx, rel.to, items[0], depth + 1, [...path, relName]));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (ops.connect !== undefined) {
|
|
355
|
+
const items = toArray(ops.connect);
|
|
356
|
+
if (items.length > 0) {
|
|
357
|
+
const target = items[0];
|
|
358
|
+
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
359
|
+
if (!relatedRow) {
|
|
360
|
+
throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (ops.connectOrCreate !== undefined) {
|
|
365
|
+
const items = toArray(ops.connectOrCreate);
|
|
366
|
+
if (items.length > 0) {
|
|
367
|
+
const op = items[0];
|
|
368
|
+
relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: op.where }));
|
|
369
|
+
if (!relatedRow) {
|
|
370
|
+
// For belongsTo the FK lives on the parent, so the related row is
|
|
371
|
+
// created plainly (no FK injection) and we read its reference key.
|
|
372
|
+
relatedRow = (await ctx.tx.table(rel.to).create({ data: op.create }));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const fkScalars = {};
|
|
377
|
+
if (relatedRow) {
|
|
378
|
+
for (let i = 0; i < fks.length; i++) {
|
|
379
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
380
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
381
|
+
fkScalars[fkField] = relatedRow[refField];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return fkScalars;
|
|
385
|
+
}
|
|
306
386
|
async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, depth, path, relName) {
|
|
307
387
|
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
308
388
|
const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
@@ -459,6 +539,80 @@ async function processSet(ctx, rel, setItems, parentRow) {
|
|
|
459
539
|
await ctx.tx.table(rel.to).update({ where: target, data: updateData });
|
|
460
540
|
}
|
|
461
541
|
}
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// update / upsert operations (update-context only)
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
async function processNestedUpdate(ctx, rel, updateArg) {
|
|
546
|
+
const items = toArray(updateArg);
|
|
547
|
+
for (const item of items) {
|
|
548
|
+
if (!item.where || !item.data) {
|
|
549
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
|
|
550
|
+
}
|
|
551
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
|
|
555
|
+
const items = toArray(upsertArg);
|
|
556
|
+
for (const item of items) {
|
|
557
|
+
if (!item.where || !item.create || !item.update) {
|
|
558
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
559
|
+
}
|
|
560
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
561
|
+
if (existing) {
|
|
562
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
|
|
566
|
+
await ctx.tx.table(rel.to).create({ data: injected });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTable) {
|
|
571
|
+
const item = updateArg;
|
|
572
|
+
if (!item.data) {
|
|
573
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
|
|
574
|
+
}
|
|
575
|
+
// Derive where from parent's FK values
|
|
576
|
+
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
577
|
+
const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
578
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
579
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
580
|
+
const where = {};
|
|
581
|
+
for (let i = 0; i < fks.length; i++) {
|
|
582
|
+
const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
583
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
584
|
+
where[refField] = parentRow[fkField];
|
|
585
|
+
}
|
|
586
|
+
await ctx.tx.table(rel.to).update({ where, data: item.data });
|
|
587
|
+
}
|
|
588
|
+
async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
|
|
589
|
+
const item = upsertArg;
|
|
590
|
+
if (!item.where || !item.create || !item.update) {
|
|
591
|
+
throw new errors_js_1.ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
592
|
+
}
|
|
593
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
|
|
594
|
+
if (existing) {
|
|
595
|
+
await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
// Create the related row, then update parent's FK to point at it
|
|
599
|
+
const createdRow = (await ctx.tx.table(rel.to).create({ data: item.create }));
|
|
600
|
+
const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
601
|
+
const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
602
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
603
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
604
|
+
const updateData = {};
|
|
605
|
+
for (let i = 0; i < fks.length; i++) {
|
|
606
|
+
const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
|
|
607
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
608
|
+
updateData[fkField] = createdRow[refField];
|
|
609
|
+
}
|
|
610
|
+
await ctx.tx.table(parentTable).update({
|
|
611
|
+
where: pkWhere(parentMeta, parentRow),
|
|
612
|
+
data: updateData,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
462
616
|
async function processDelete(ctx, rel, deleteArg) {
|
|
463
617
|
const items = toArray(deleteArg);
|
|
464
618
|
for (const target of items) {
|
|
@@ -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;
|