turbine-orm 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/adapters/cockroachdb.js +1 -1
  2. package/dist/adapters/index.d.ts +7 -4
  3. package/dist/adapters/index.js +1 -1
  4. package/dist/adapters/yugabytedb.js +1 -1
  5. package/dist/cjs/adapters/cockroachdb.js +1 -1
  6. package/dist/cjs/adapters/index.js +1 -1
  7. package/dist/cjs/adapters/yugabytedb.js +1 -1
  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 +45 -7
  12. package/dist/cjs/client.js +102 -1
  13. package/dist/cjs/errors.js +44 -1
  14. package/dist/cjs/generate.js +86 -0
  15. package/dist/cjs/index.js +10 -1
  16. package/dist/cjs/nested-write.js +557 -0
  17. package/dist/cjs/observe.js +145 -0
  18. package/dist/cjs/query/builder.js +271 -23
  19. package/dist/cli/index.d.ts +1 -0
  20. package/dist/cli/index.js +64 -0
  21. package/dist/cli/observe-ui.d.ts +2 -0
  22. package/dist/cli/observe-ui.js +180 -0
  23. package/dist/cli/observe.d.ts +20 -0
  24. package/dist/cli/observe.js +237 -0
  25. package/dist/cli/studio.d.ts +10 -2
  26. package/dist/cli/studio.js +45 -7
  27. package/dist/client.d.ts +32 -2
  28. package/dist/client.js +102 -2
  29. package/dist/errors.d.ts +23 -0
  30. package/dist/errors.js +41 -0
  31. package/dist/generate.js +86 -0
  32. package/dist/index.d.ts +5 -3
  33. package/dist/index.js +4 -2
  34. package/dist/nested-write.d.ts +95 -0
  35. package/dist/nested-write.js +551 -0
  36. package/dist/observe.d.ts +36 -0
  37. package/dist/observe.js +141 -0
  38. package/dist/query/builder.d.ts +45 -12
  39. package/dist/query/builder.js +239 -24
  40. package/dist/query/index.d.ts +2 -2
  41. package/dist/query/types.d.ts +76 -8
  42. package/package.json +2 -2
@@ -0,0 +1,95 @@
1
+ /**
2
+ * turbine-orm — Nested write engine
3
+ *
4
+ * Tree-walking create/update that resolves relation fields in `data` into
5
+ * batched SQL operations within a transaction. Supports create, connect,
6
+ * connectOrCreate, disconnect, set, delete, update, and upsert on related
7
+ * records at arbitrary depth (capped at 10).
8
+ *
9
+ * This module is imported by `query/builder.ts` when the `data` argument
10
+ * of `create()` or `update()` contains relation fields. It never imports
11
+ * `client.ts` directly — the transaction handle is passed in via
12
+ * `NestedWriteContext`.
13
+ */
14
+ import type { RelationDef, SchemaMetadata, TableMetadata } from './schema.js';
15
+ export interface ExtractedFields {
16
+ scalars: Record<string, unknown>;
17
+ relations: Record<string, Record<string, unknown>>;
18
+ }
19
+ /**
20
+ * Transaction context for nested write operations.
21
+ * Matches the subset of TransactionClient that we actually use.
22
+ */
23
+ export interface NestedWriteContext {
24
+ schema: SchemaMetadata;
25
+ tx: {
26
+ table<T extends object>(name: string): {
27
+ create(args: {
28
+ data: Partial<T>;
29
+ }): Promise<T>;
30
+ createMany(args: {
31
+ data: Partial<T>[];
32
+ }): Promise<T[]>;
33
+ update(args: {
34
+ where: Record<string, unknown>;
35
+ data: Record<string, unknown>;
36
+ }): Promise<T>;
37
+ updateMany(args: {
38
+ where: Record<string, unknown>;
39
+ data: Record<string, unknown>;
40
+ allowFullTableScan?: boolean;
41
+ }): Promise<{
42
+ count: number;
43
+ }>;
44
+ delete(args: {
45
+ where: Record<string, unknown>;
46
+ }): Promise<T>;
47
+ deleteMany(args: {
48
+ where: Record<string, unknown>;
49
+ }): Promise<{
50
+ count: number;
51
+ }>;
52
+ findMany(args: {
53
+ where: Record<string, unknown>;
54
+ }): Promise<T[]>;
55
+ findUnique(args: {
56
+ where: Record<string, unknown>;
57
+ with?: Record<string, unknown>;
58
+ }): Promise<T | null>;
59
+ };
60
+ };
61
+ }
62
+ /**
63
+ * Separates scalar data fields from relation operation fields.
64
+ *
65
+ * A key is treated as a relation field only when:
66
+ * 1. It matches a relation name in `tableMeta.relations`
67
+ * 2. Its value is a non-null, non-array, non-Date plain object
68
+ *
69
+ * Everything else goes into `scalars`.
70
+ */
71
+ export declare function extractRelationFields(data: Record<string, unknown>, tableMeta: TableMetadata): ExtractedFields;
72
+ /**
73
+ * Quick check: does `data` contain any relation fields that would trigger
74
+ * the nested write path? Used by QueryInterface to decide whether to
75
+ * delegate to the nested write engine or take the fast scalar-only path.
76
+ */
77
+ export declare function hasRelationFields(data: Record<string, unknown>, tableMeta: TableMetadata): boolean;
78
+ /**
79
+ * Inject the parent row's PK value(s) as FK field(s) into child data.
80
+ * Handles composite keys. Returns a new object (does not mutate input).
81
+ */
82
+ export declare function injectForeignKey(childData: Record<string, unknown>, relation: RelationDef, parentRow: Record<string, unknown>, schema: SchemaMetadata): Record<string, unknown>;
83
+ /**
84
+ * Tree-walking create: inserts the parent row, then processes each relation
85
+ * operation (create, connect, connectOrCreate), and finally reads back the
86
+ * full tree using `findUnique` with an auto-built `with` clause.
87
+ */
88
+ export declare function executeNestedCreate(ctx: NestedWriteContext, tableName: string, data: Record<string, unknown>, depth?: number, path?: string[]): Promise<Record<string, unknown>>;
89
+ /**
90
+ * Tree-walking update: updates the parent row with scalar data, then
91
+ * processes each relation operation (create, connect, connectOrCreate,
92
+ * disconnect, set, delete), and reads back the full tree.
93
+ */
94
+ export declare function executeNestedUpdate(ctx: NestedWriteContext, tableName: string, where: Record<string, unknown>, data: Record<string, unknown>, depth?: number, path?: string[]): Promise<Record<string, unknown>>;
95
+ //# sourceMappingURL=nested-write.d.ts.map
@@ -0,0 +1,551 @@
1
+ /**
2
+ * turbine-orm — Nested write engine
3
+ *
4
+ * Tree-walking create/update that resolves relation fields in `data` into
5
+ * batched SQL operations within a transaction. Supports create, connect,
6
+ * connectOrCreate, disconnect, set, delete, update, and upsert on related
7
+ * records at arbitrary depth (capped at 10).
8
+ *
9
+ * This module is imported by `query/builder.ts` when the `data` argument
10
+ * of `create()` or `update()` contains relation fields. It never imports
11
+ * `client.ts` directly — the transaction handle is passed in via
12
+ * `NestedWriteContext`.
13
+ */
14
+ import { CircularRelationError, RelationError, ValidationError } from './errors.js';
15
+ import { normalizeKeyColumns } from './schema.js';
16
+ const MAX_DEPTH = 10;
17
+ const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
18
+ const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete', 'update', 'upsert']);
19
+ // ---------------------------------------------------------------------------
20
+ // Pure helpers (exported for testing)
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Separates scalar data fields from relation operation fields.
24
+ *
25
+ * A key is treated as a relation field only when:
26
+ * 1. It matches a relation name in `tableMeta.relations`
27
+ * 2. Its value is a non-null, non-array, non-Date plain object
28
+ *
29
+ * Everything else goes into `scalars`.
30
+ */
31
+ export function extractRelationFields(data, tableMeta) {
32
+ const scalars = {};
33
+ const relations = {};
34
+ for (const [key, value] of Object.entries(data)) {
35
+ if (key in tableMeta.relations &&
36
+ value !== null &&
37
+ typeof value === 'object' &&
38
+ !Array.isArray(value) &&
39
+ !(value instanceof Date)) {
40
+ relations[key] = value;
41
+ }
42
+ else {
43
+ scalars[key] = value;
44
+ }
45
+ }
46
+ return { scalars, relations };
47
+ }
48
+ /**
49
+ * Quick check: does `data` contain any relation fields that would trigger
50
+ * the nested write path? Used by QueryInterface to decide whether to
51
+ * delegate to the nested write engine or take the fast scalar-only path.
52
+ */
53
+ export function hasRelationFields(data, tableMeta) {
54
+ for (const key of Object.keys(data)) {
55
+ if (key in tableMeta.relations) {
56
+ const val = data[key];
57
+ if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
58
+ return true;
59
+ }
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ /**
65
+ * Inject the parent row's PK value(s) as FK field(s) into child data.
66
+ * Handles composite keys. Returns a new object (does not mutate input).
67
+ */
68
+ export function injectForeignKey(childData, relation, parentRow, schema) {
69
+ const fks = normalizeKeyColumns(relation.foreignKey);
70
+ const refs = normalizeKeyColumns(relation.referenceKey);
71
+ const childTable = schema.tables[relation.to];
72
+ const result = { ...childData };
73
+ for (let i = 0; i < fks.length; i++) {
74
+ const fkCol = fks[i];
75
+ const refCol = refs[i];
76
+ const refField = schema.tables[relation.from]?.reverseColumnMap[refCol] ?? refCol;
77
+ const fkField = childTable?.reverseColumnMap[fkCol] ?? fkCol;
78
+ result[fkField] = parentRow[refField];
79
+ }
80
+ return result;
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Internal helpers
84
+ // ---------------------------------------------------------------------------
85
+ function toArray(value) {
86
+ return Array.isArray(value) ? value : [value];
87
+ }
88
+ /**
89
+ * Validate that all operation keys in a nested write are recognized and
90
+ * allowed for the current context (create vs update).
91
+ */
92
+ function validateOps(relationName, ops, isUpdate) {
93
+ for (const opName of Object.keys(ops)) {
94
+ if (!CREATE_ONLY_OPS.has(opName) && !UPDATE_ONLY_OPS.has(opName)) {
95
+ throw new ValidationError(`[turbine] Unknown nested write operation "${opName}" on relation "${relationName}". ` +
96
+ `Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete, update, upsert' : ''}.`);
97
+ }
98
+ if (!isUpdate && UPDATE_ONLY_OPS.has(opName)) {
99
+ throw new ValidationError(`[turbine] Operation "${opName}" on relation "${relationName}" is only valid inside update(), not create().`);
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Build a PK-based where clause from a parent row and its table metadata.
105
+ */
106
+ function pkWhere(tableMeta, row) {
107
+ const where = {};
108
+ for (const col of tableMeta.primaryKey) {
109
+ const field = tableMeta.reverseColumnMap[col] ?? col;
110
+ where[field] = row[field];
111
+ }
112
+ return where;
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // executeNestedCreate
116
+ // ---------------------------------------------------------------------------
117
+ /**
118
+ * Tree-walking create: inserts the parent row, then processes each relation
119
+ * operation (create, connect, connectOrCreate), and finally reads back the
120
+ * full tree using `findUnique` with an auto-built `with` clause.
121
+ */
122
+ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
123
+ if (depth > MAX_DEPTH) {
124
+ throw new CircularRelationError(path);
125
+ }
126
+ const tableMeta = ctx.schema.tables[tableName];
127
+ if (!tableMeta) {
128
+ throw new ValidationError(`[turbine] Unknown table "${tableName}".`);
129
+ }
130
+ const { scalars, relations } = extractRelationFields(data, tableMeta);
131
+ // Validate all relation operations
132
+ for (const [relName, ops] of Object.entries(relations)) {
133
+ const rel = tableMeta.relations[relName];
134
+ if (!rel) {
135
+ throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${tableName}". ` +
136
+ `Available relations: ${Object.keys(tableMeta.relations).join(', ') || '(none)'}.`);
137
+ }
138
+ validateOps(relName, ops, false);
139
+ }
140
+ // Insert the parent row
141
+ const parentRow = (await ctx.tx.table(tableName).create({ data: scalars }));
142
+ // Process each relation
143
+ for (const [relName, ops] of Object.entries(relations)) {
144
+ const rel = tableMeta.relations[relName];
145
+ if (rel.type === 'hasMany' || rel.type === 'hasOne') {
146
+ await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
147
+ }
148
+ else if (rel.type === 'belongsTo') {
149
+ await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
150
+ }
151
+ }
152
+ // Build the `with` clause for the final read to return the full tree
153
+ const withClause = {};
154
+ for (const relName of Object.keys(relations)) {
155
+ withClause[relName] = true;
156
+ }
157
+ // Final read using existing json_agg machinery
158
+ const fullRow = await ctx.tx.table(tableName).findUnique({
159
+ where: pkWhere(tableMeta, parentRow),
160
+ with: Object.keys(withClause).length > 0 ? withClause : undefined,
161
+ });
162
+ return (fullRow ?? parentRow);
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // executeNestedUpdate
166
+ // ---------------------------------------------------------------------------
167
+ /**
168
+ * Tree-walking update: updates the parent row with scalar data, then
169
+ * processes each relation operation (create, connect, connectOrCreate,
170
+ * disconnect, set, delete), and reads back the full tree.
171
+ */
172
+ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path = []) {
173
+ if (depth > MAX_DEPTH) {
174
+ throw new CircularRelationError(path);
175
+ }
176
+ const tableMeta = ctx.schema.tables[tableName];
177
+ if (!tableMeta) {
178
+ throw new ValidationError(`[turbine] Unknown table "${tableName}".`);
179
+ }
180
+ const { scalars, relations } = extractRelationFields(data, tableMeta);
181
+ // Validate all relation operations
182
+ for (const [relName, ops] of Object.entries(relations)) {
183
+ const rel = tableMeta.relations[relName];
184
+ if (!rel) {
185
+ throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${tableName}". ` +
186
+ `Available relations: ${Object.keys(tableMeta.relations).join(', ') || '(none)'}.`);
187
+ }
188
+ validateOps(relName, ops, true);
189
+ }
190
+ // Update parent row with scalar data (may be empty if only relation ops)
191
+ let parentRow;
192
+ if (Object.keys(scalars).length > 0) {
193
+ parentRow = (await ctx.tx.table(tableName).update({ where, data: scalars }));
194
+ }
195
+ else {
196
+ parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
197
+ if (!parentRow) {
198
+ throw new ValidationError(`[turbine] update: no ${tableName} row found matching ${JSON.stringify(where)}.`);
199
+ }
200
+ }
201
+ // Process each relation
202
+ for (const [relName, ops] of Object.entries(relations)) {
203
+ const rel = tableMeta.relations[relName];
204
+ if (rel.type === 'hasMany' || rel.type === 'hasOne') {
205
+ // create, connect, connectOrCreate — same as nested create
206
+ await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
207
+ // disconnect
208
+ if (ops.disconnect !== undefined) {
209
+ await processDisconnect(ctx, rel, ops.disconnect, relName);
210
+ }
211
+ // set
212
+ if (ops.set !== undefined) {
213
+ await processSet(ctx, rel, ops.set, parentRow);
214
+ }
215
+ // delete
216
+ if (ops.delete !== undefined) {
217
+ await processDelete(ctx, rel, ops.delete);
218
+ }
219
+ // update
220
+ if (ops.update !== undefined) {
221
+ await processNestedUpdate(ctx, rel, ops.update);
222
+ }
223
+ // upsert
224
+ if (ops.upsert !== undefined) {
225
+ await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
226
+ }
227
+ }
228
+ else if (rel.type === 'belongsTo') {
229
+ await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
230
+ // update (belongsTo — derive where from parent FK)
231
+ if (ops.update !== undefined) {
232
+ await processBelongsToUpdate(ctx, rel, ops.update, parentRow, tableName);
233
+ }
234
+ // upsert (belongsTo)
235
+ if (ops.upsert !== undefined) {
236
+ await processBelongsToUpsert(ctx, rel, ops.upsert, parentRow, tableName);
237
+ }
238
+ if (ops.disconnect !== undefined) {
239
+ // For belongsTo disconnect, null out the FK on the parent
240
+ const fks = normalizeKeyColumns(rel.foreignKey);
241
+ const nullable = fks.every((fk) => {
242
+ const col = tableMeta.columns.find((c) => c.name === fk);
243
+ return col?.nullable ?? false;
244
+ });
245
+ if (!nullable) {
246
+ throw new ValidationError(`[turbine] Cannot disconnect "${relName}": foreign key column(s) ${fks.join(', ')} are NOT NULL. Use delete instead.`);
247
+ }
248
+ const updateData = {};
249
+ for (const fk of fks) {
250
+ const field = tableMeta.reverseColumnMap[fk] ?? fk;
251
+ updateData[field] = null;
252
+ }
253
+ await ctx.tx.table(tableName).update({
254
+ where: pkWhere(tableMeta, parentRow),
255
+ data: updateData,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ // Final read with all touched relations
261
+ const withClause = {};
262
+ for (const relName of Object.keys(relations)) {
263
+ withClause[relName] = true;
264
+ }
265
+ const fullRow = await ctx.tx.table(tableName).findUnique({
266
+ where: pkWhere(tableMeta, parentRow),
267
+ with: Object.keys(withClause).length > 0 ? withClause : undefined,
268
+ });
269
+ return (fullRow ?? parentRow);
270
+ }
271
+ // ---------------------------------------------------------------------------
272
+ // hasMany/hasOne create operations
273
+ // ---------------------------------------------------------------------------
274
+ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName) {
275
+ // create
276
+ if (ops.create !== undefined) {
277
+ const items = toArray(ops.create);
278
+ if (items.length > 0) {
279
+ // Check if any items have nested relations (need per-row recursion)
280
+ const childTable = ctx.schema.tables[rel.to];
281
+ const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => k in (childTable.relations ?? {})));
282
+ if (hasNested) {
283
+ // Per-row recursive create for items with nested relations
284
+ for (const item of items) {
285
+ const injected = injectForeignKey(item, rel, parentRow, ctx.schema);
286
+ await executeNestedCreate(ctx, rel.to, injected, depth + 1, [...path, relName]);
287
+ }
288
+ }
289
+ else {
290
+ // Batch via createMany (UNNEST) — fast path
291
+ const injected = items.map((item) => injectForeignKey(item, rel, parentRow, ctx.schema));
292
+ await ctx.tx.table(rel.to).createMany({ data: injected });
293
+ }
294
+ }
295
+ }
296
+ // connect
297
+ if (ops.connect !== undefined) {
298
+ const items = toArray(ops.connect);
299
+ if (items.length > 0) {
300
+ await batchConnect(ctx, rel, items, parentRow);
301
+ }
302
+ }
303
+ // connectOrCreate
304
+ if (ops.connectOrCreate !== undefined) {
305
+ const items = toArray(ops.connectOrCreate);
306
+ for (const item of items) {
307
+ const op = item;
308
+ await connectOrCreate(ctx, rel, op, parentRow);
309
+ }
310
+ }
311
+ }
312
+ // ---------------------------------------------------------------------------
313
+ // belongsTo create operations
314
+ // ---------------------------------------------------------------------------
315
+ async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, depth, path, relName) {
316
+ const fks = normalizeKeyColumns(rel.foreignKey);
317
+ const refs = normalizeKeyColumns(rel.referenceKey);
318
+ // create — insert the related row, then update parent's FK
319
+ if (ops.create !== undefined) {
320
+ const items = toArray(ops.create);
321
+ if (items.length > 0) {
322
+ const relatedRow = (await executeNestedCreate(ctx, rel.to, items[0], depth + 1, [...path, relName]));
323
+ const updateData = {};
324
+ const relatedTable = ctx.schema.tables[rel.to];
325
+ for (let i = 0; i < fks.length; i++) {
326
+ const fkField = ctx.schema.tables[parentTable]?.reverseColumnMap[fks[i]] ?? fks[i];
327
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
328
+ updateData[fkField] = relatedRow[refField];
329
+ }
330
+ const parentMeta = ctx.schema.tables[parentTable];
331
+ await ctx.tx.table(parentTable).update({
332
+ where: pkWhere(parentMeta, parentRow),
333
+ data: updateData,
334
+ });
335
+ }
336
+ }
337
+ // connect — validate existence, update parent's FK
338
+ if (ops.connect !== undefined) {
339
+ const items = toArray(ops.connect);
340
+ if (items.length > 0) {
341
+ const target = items[0];
342
+ const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
343
+ if (!existing) {
344
+ throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
345
+ }
346
+ const updateData = {};
347
+ const relatedTable = ctx.schema.tables[rel.to];
348
+ for (let i = 0; i < fks.length; i++) {
349
+ const fkField = ctx.schema.tables[parentTable]?.reverseColumnMap[fks[i]] ?? fks[i];
350
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
351
+ updateData[fkField] = existing[refField];
352
+ }
353
+ const parentMeta = ctx.schema.tables[parentTable];
354
+ await ctx.tx.table(parentTable).update({
355
+ where: pkWhere(parentMeta, parentRow),
356
+ data: updateData,
357
+ });
358
+ }
359
+ }
360
+ }
361
+ // ---------------------------------------------------------------------------
362
+ // connect, connectOrCreate, disconnect, set, delete helpers
363
+ // ---------------------------------------------------------------------------
364
+ async function batchConnect(ctx, rel, items, parentRow) {
365
+ const fks = normalizeKeyColumns(rel.foreignKey);
366
+ const refs = normalizeKeyColumns(rel.referenceKey);
367
+ const childTable = ctx.schema.tables[rel.to];
368
+ if (!childTable)
369
+ return;
370
+ // Validate all targets exist
371
+ for (const target of items) {
372
+ const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
373
+ if (!existing) {
374
+ throw new ValidationError(`[turbine] connect: no ${rel.to} row found matching ${JSON.stringify(target)}.`);
375
+ }
376
+ }
377
+ // Build FK update data to point children at parent
378
+ const updateData = {};
379
+ for (let i = 0; i < fks.length; i++) {
380
+ const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
381
+ const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
382
+ updateData[fkField] = parentRow[refField];
383
+ }
384
+ // Update each matching child
385
+ for (const target of items) {
386
+ await ctx.tx.table(rel.to).update({ where: target, data: updateData });
387
+ }
388
+ }
389
+ async function connectOrCreate(ctx, rel, op, parentRow) {
390
+ const fks = normalizeKeyColumns(rel.foreignKey);
391
+ const refs = normalizeKeyColumns(rel.referenceKey);
392
+ const childTable = ctx.schema.tables[rel.to];
393
+ if (!childTable)
394
+ return;
395
+ // Try to find existing
396
+ let row = (await ctx.tx.table(rel.to).findUnique({ where: op.where }));
397
+ if (!row) {
398
+ // Create with FK injected
399
+ const injected = injectForeignKey(op.create, rel, parentRow, ctx.schema);
400
+ row = (await ctx.tx.table(rel.to).create({ data: injected }));
401
+ }
402
+ else {
403
+ // Update FK to point to parent
404
+ const updateData = {};
405
+ for (let i = 0; i < fks.length; i++) {
406
+ const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
407
+ const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
408
+ updateData[fkField] = parentRow[refField];
409
+ }
410
+ await ctx.tx.table(rel.to).update({ where: op.where, data: updateData });
411
+ }
412
+ }
413
+ async function processDisconnect(ctx, rel, disconnectArg, relName) {
414
+ const fks = normalizeKeyColumns(rel.foreignKey);
415
+ const childTable = ctx.schema.tables[rel.to];
416
+ if (!childTable)
417
+ return;
418
+ // Check FK nullability
419
+ const nullable = fks.every((fk) => {
420
+ const col = childTable.columns.find((c) => c.name === fk);
421
+ return col?.nullable ?? false;
422
+ });
423
+ if (!nullable) {
424
+ throw new ValidationError(`[turbine] Cannot disconnect "${relName}": foreign key column(s) ${fks.join(', ')} on "${rel.to}" are NOT NULL. Use delete instead.`);
425
+ }
426
+ const items = toArray(disconnectArg);
427
+ const nullData = {};
428
+ for (const fk of fks) {
429
+ const field = childTable.reverseColumnMap[fk] ?? fk;
430
+ nullData[field] = null;
431
+ }
432
+ for (const target of items) {
433
+ await ctx.tx.table(rel.to).update({ where: target, data: nullData });
434
+ }
435
+ }
436
+ async function processSet(ctx, rel, setItems, parentRow) {
437
+ const fks = normalizeKeyColumns(rel.foreignKey);
438
+ const refs = normalizeKeyColumns(rel.referenceKey);
439
+ const childTable = ctx.schema.tables[rel.to];
440
+ if (!childTable)
441
+ return;
442
+ // Build parent FK match for finding current children
443
+ const parentWhere = {};
444
+ for (let i = 0; i < fks.length; i++) {
445
+ const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
446
+ const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
447
+ parentWhere[fkField] = parentRow[refField];
448
+ }
449
+ // Disconnect all current children
450
+ const nullData = {};
451
+ for (const fk of fks) {
452
+ const field = childTable.reverseColumnMap[fk] ?? fk;
453
+ nullData[field] = null;
454
+ }
455
+ await ctx.tx.table(rel.to).updateMany({
456
+ where: parentWhere,
457
+ data: nullData,
458
+ allowFullTableScan: true,
459
+ });
460
+ // Connect the specified items
461
+ const updateData = {};
462
+ for (let i = 0; i < fks.length; i++) {
463
+ const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
464
+ const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
465
+ updateData[fkField] = parentRow[refField];
466
+ }
467
+ for (const target of setItems) {
468
+ await ctx.tx.table(rel.to).update({ where: target, data: updateData });
469
+ }
470
+ }
471
+ // ---------------------------------------------------------------------------
472
+ // update / upsert operations (update-context only)
473
+ // ---------------------------------------------------------------------------
474
+ async function processNestedUpdate(ctx, rel, updateArg) {
475
+ const items = toArray(updateArg);
476
+ for (const item of items) {
477
+ if (!item.where || !item.data) {
478
+ throw new ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
479
+ }
480
+ await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
481
+ }
482
+ }
483
+ async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
484
+ const items = toArray(upsertArg);
485
+ for (const item of items) {
486
+ if (!item.where || !item.create || !item.update) {
487
+ throw new ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
488
+ }
489
+ const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
490
+ if (existing) {
491
+ await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
492
+ }
493
+ else {
494
+ const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
495
+ await ctx.tx.table(rel.to).create({ data: injected });
496
+ }
497
+ }
498
+ }
499
+ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTable) {
500
+ const item = updateArg;
501
+ if (!item.data) {
502
+ throw new ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
503
+ }
504
+ // Derive where from parent's FK values
505
+ const fks = normalizeKeyColumns(rel.foreignKey);
506
+ const refs = normalizeKeyColumns(rel.referenceKey);
507
+ const parentMeta = ctx.schema.tables[parentTable];
508
+ const relatedTable = ctx.schema.tables[rel.to];
509
+ const where = {};
510
+ for (let i = 0; i < fks.length; i++) {
511
+ const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
512
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
513
+ where[refField] = parentRow[fkField];
514
+ }
515
+ await ctx.tx.table(rel.to).update({ where, data: item.data });
516
+ }
517
+ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
518
+ const item = upsertArg;
519
+ if (!item.where || !item.create || !item.update) {
520
+ throw new ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
521
+ }
522
+ const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
523
+ if (existing) {
524
+ await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
525
+ }
526
+ else {
527
+ // Create the related row, then update parent's FK to point at it
528
+ const createdRow = (await ctx.tx.table(rel.to).create({ data: item.create }));
529
+ const fks = normalizeKeyColumns(rel.foreignKey);
530
+ const refs = normalizeKeyColumns(rel.referenceKey);
531
+ const parentMeta = ctx.schema.tables[parentTable];
532
+ const relatedTable = ctx.schema.tables[rel.to];
533
+ const updateData = {};
534
+ for (let i = 0; i < fks.length; i++) {
535
+ const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
536
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
537
+ updateData[fkField] = createdRow[refField];
538
+ }
539
+ await ctx.tx.table(parentTable).update({
540
+ where: pkWhere(parentMeta, parentRow),
541
+ data: updateData,
542
+ });
543
+ }
544
+ }
545
+ async function processDelete(ctx, rel, deleteArg) {
546
+ const items = toArray(deleteArg);
547
+ for (const target of items) {
548
+ await ctx.tx.table(rel.to).delete({ where: target });
549
+ }
550
+ }
551
+ //# sourceMappingURL=nested-write.js.map
@@ -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