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.
Files changed (54) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +5 -1
  12. package/dist/cjs/client.js +218 -0
  13. package/dist/cjs/errors.js +35 -5
  14. package/dist/cjs/generate.js +14 -3
  15. package/dist/cjs/index.js +10 -2
  16. package/dist/cjs/introspect.js +81 -0
  17. package/dist/cjs/nested-write.js +164 -10
  18. package/dist/cjs/observe.js +145 -0
  19. package/dist/cjs/query/builder.js +604 -25
  20. package/dist/cjs/realtime.js +147 -0
  21. package/dist/cjs/schema-builder.js +86 -0
  22. package/dist/cjs/schema.js +10 -0
  23. package/dist/cjs/typed-sql.js +149 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +64 -0
  26. package/dist/cli/observe-ui.d.ts +2 -0
  27. package/dist/cli/observe-ui.js +180 -0
  28. package/dist/cli/observe.d.ts +20 -0
  29. package/dist/cli/observe.js +237 -0
  30. package/dist/cli/studio.js +5 -1
  31. package/dist/client.d.ts +129 -2
  32. package/dist/client.js +220 -2
  33. package/dist/errors.js +35 -5
  34. package/dist/generate.js +14 -3
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.js +5 -1
  37. package/dist/introspect.js +81 -0
  38. package/dist/nested-write.d.ts +2 -2
  39. package/dist/nested-write.js +164 -10
  40. package/dist/observe.d.ts +36 -0
  41. package/dist/observe.js +141 -0
  42. package/dist/query/builder.d.ts +121 -1
  43. package/dist/query/builder.js +605 -26
  44. package/dist/query/index.d.ts +2 -2
  45. package/dist/query/types.d.ts +126 -2
  46. package/dist/realtime.d.ts +71 -0
  47. package/dist/realtime.js +144 -0
  48. package/dist/schema-builder.d.ts +68 -1
  49. package/dist/schema-builder.js +85 -0
  50. package/dist/schema.d.ts +18 -1
  51. package/dist/schema.js +10 -0
  52. package/dist/typed-sql.d.ts +101 -0
  53. package/dist/typed-sql.js +145 -0
  54. 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.table = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = 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.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; } });
@@ -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) {
@@ -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 delete on related records at
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
- // Insert the parent row
148
- const parentRow = (await ctx.tx.table(tableName).create({ data: scalars }));
149
- // Process each relation
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;