turbine-orm 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/powql.js CHANGED
@@ -15,10 +15,20 @@
15
15
  * - `create`/`createMany`/`update`/`delete` use PowDB 0.7.0's trailing
16
16
  * `returning` keyword (`RETURNING *`, all columns) to surface affected rows
17
17
  * in one round-trip. `upsert` is the lone exception — its statement does not
18
- * accept `returning`, so it reselects the row by PK.
19
- * - The PK is generated client-side (UUID) when the column has a default.
20
- * - `with` (nested relations) degrades to **batched N+1 loaders** D
21
- * round-trips for depth D, not one query. manyToMany is Phase B.
18
+ * accept `returning`, so it reselects the row by PK (a composite-PK upsert
19
+ * reselects-or-writes inside one flat transaction).
20
+ * - The PK is server-assigned when the column is `isGenerated` (PowDB's `auto`
21
+ * int read back via `returning`); otherwise a defaulted **string** PK is
22
+ * generated client-side (UUID).
23
+ * - `with` (nested relations) uses **batched N+1 loaders** — D round-trips for
24
+ * depth D, not one query — including manyToMany (junction → targets).
25
+ * - **Relation filters** (`some`/`none`/`every`, all cardinalities incl. m2m)
26
+ * are resolved client-side to a literal `in (…)` list, never an IN-subquery:
27
+ * PowDB's executor caches a subquery's result by plan shape and would return
28
+ * a stale prior result for a later subquery of the same shape.
29
+ * - **Nested writes** (relation ops in `create`/`update` data) run through the
30
+ * shared nested-write engine as one flat top-level transaction (PowDB is
31
+ * single-writer, no savepoints).
22
32
  * - pgvector / JSON / array filters and cursor streaming throw
23
33
  * {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
24
34
  *
@@ -26,6 +36,7 @@
26
36
  */
27
37
  import { randomUUID } from 'node:crypto';
28
38
  import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
+ import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
29
40
  import { PowdbFloatParam, powqlColumnType, rowToEntity } from './powdb.js';
30
41
  import { escapeLike } from './query/utils.js';
31
42
  import { normalizeKeyColumns, } from './schema.js';
@@ -188,7 +199,10 @@ export class PowqlInterface {
188
199
  parts.push(`not (${sub})`);
189
200
  }
190
201
  else if (this.meta.relations[key]) {
191
- parts.push(this.buildRelationFilter(this.meta.relations[key], value, params));
202
+ // Relation filters are pre-resolved to scalar in/notIn by
203
+ // resolveRelationFilters() before buildWhere runs — reaching here means
204
+ // a caller skipped that step (an internal bug, not user error).
205
+ throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
192
206
  }
193
207
  else {
194
208
  parts.push(this.buildFieldCondition(key, value, params));
@@ -276,41 +290,147 @@ export class PowqlInterface {
276
290
  return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
277
291
  }
278
292
  /**
279
- * Relation filter (`some`/`none`/`every`) via the verified IN-subquery form
280
- * `Outer filter .localKey in (Target filter <e> { .targetKey })`. `exists`
281
- * correlation is unreliable on PowDB, so it is never used.
293
+ * Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
294
+ * into a plain scalar `in`/`notIn` condition on the **local key**, by running
295
+ * the inner predicate as its own query and materializing the matching keys as
296
+ * a literal list. The compiled where is then relation-free and `buildWhere`
297
+ * emits only `in (<literal list>)`.
298
+ *
299
+ * Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
300
+ * executor caches a subquery's result by **plan shape, ignoring the literal**,
301
+ * so a second subquery of the same shape with a different value returns the
302
+ * first one's stale rows (reproduced live on the embedded engine; the
303
+ * single-statement literal `in (list)` form is always correct). Resolving
304
+ * client-side trades extra round-trips for correctness, and recurses — nested
305
+ * relation filters in the inner predicate resolve when the target query runs.
282
306
  */
283
- buildRelationFilter(rel, filter, params) {
307
+ async resolveRelationFilters(where, timeout) {
308
+ if (!where)
309
+ return where;
310
+ const scalar = {};
311
+ const relConds = [];
312
+ for (const [key, value] of Object.entries(where)) {
313
+ if (value === undefined)
314
+ continue;
315
+ if (key === 'AND' || key === 'OR') {
316
+ scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFilters(w, timeout)));
317
+ }
318
+ else if (key === 'NOT') {
319
+ scalar[key] = await this.resolveRelationFilters(value, timeout);
320
+ }
321
+ else if (this.meta.relations[key]) {
322
+ relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
323
+ }
324
+ else {
325
+ scalar[key] = value;
326
+ }
327
+ }
328
+ if (!relConds.length)
329
+ return scalar;
330
+ const hasScalar = Object.keys(scalar).length > 0;
331
+ return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
332
+ }
333
+ /** Resolve one hasMany/hasOne/belongsTo filter to `{ localField: { in|notIn: [...] } }`. */
334
+ async resolveRelationCondition(rel, filter, timeout) {
335
+ const mode = filter.some ? 'some' : filter.none ? 'none' : filter.every ? 'every' : null;
336
+ if (!mode)
337
+ return {};
338
+ const innerWhere = filter[mode];
339
+ const innerEmpty = !innerWhere || Object.keys(innerWhere).length === 0;
284
340
  if (rel.type === 'manyToMany') {
285
- throw new UnsupportedFeatureError('manyToMany relation filters', 'PowDB', `relation "${rel.name}" — junction-table relation filters are Phase B`);
341
+ return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
286
342
  }
287
343
  const fk = normalizeKeyColumns(rel.foreignKey);
288
344
  const rk = normalizeKeyColumns(rel.referenceKey);
289
345
  if (fk.length > 1 || rk.length > 1) {
290
- throw new UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key`);
346
+ throw new UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key — PowQL has no tuple-\`in\` to express it`);
291
347
  }
292
- // hasMany/hasOne: outer.referenceKey ∈ target.foreignKey.
293
- // belongsTo: outer.foreignKey ∈ target.referenceKey.
294
- const localKey = rel.type === 'belongsTo' ? fk[0] : rk[0];
295
- const targetKey = rel.type === 'belongsTo' ? rk[0] : fk[0];
296
348
  const targetMeta = this.schema.tables[rel.to];
297
349
  if (!targetMeta)
298
350
  throw new ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
299
- const sub = (whereObj, negateInner) => {
300
- const innerQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
301
- const inner = innerQi.buildWhere(whereObj, params);
302
- const filterClause = inner ? ` filter ${negateInner ? `not (${inner})` : inner}` : '';
303
- return `${rel.to}${filterClause} { .${targetKey} }`;
351
+ // hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
352
+ // belongsTo: localKey = our foreignKey, collect the target's referenceKey.
353
+ const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
354
+ const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
355
+ const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
356
+ const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
357
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
358
+ const collect = async (w) => {
359
+ const rows = await targetQi.findMany({
360
+ where: w,
361
+ select: { [childField]: true },
362
+ timeout,
363
+ });
364
+ return [...new Set(rows.map((r) => r[childField]).filter((v) => v != null))];
304
365
  };
305
- if (filter.some)
306
- return `.${localKey} in (${sub(filter.some, false)})`;
307
- if (filter.none)
308
- return `.${localKey} not in (${sub(filter.none, false)})`;
309
- if (filter.every) {
310
- // Parents with no child failing the predicate.
311
- return `.${localKey} not in (${sub(filter.every, true)})`;
366
+ if (mode === 'some')
367
+ return { [localField]: { in: await collect(innerWhere) } };
368
+ if (mode === 'none')
369
+ return { [localField]: { notIn: await collect(innerWhere) } };
370
+ // every: a parent qualifies unless it has a child FAILING the predicate.
371
+ if (innerEmpty)
372
+ return {}; // every:{} ⇒ trivially true for all parents
373
+ return { [localField]: { notIn: await collect({ NOT: innerWhere }) } };
374
+ }
375
+ /** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
376
+ async resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout) {
377
+ const through = rel.through;
378
+ if (!through)
379
+ throw new ValidationError(`[turbine] manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
380
+ const sourceJ = normalizeKeyColumns(through.sourceKey);
381
+ const targetJ = normalizeKeyColumns(through.targetKey);
382
+ const sourceRef = normalizeKeyColumns(rel.referenceKey);
383
+ const targetMeta = this.schema.tables[rel.to];
384
+ if (!targetMeta)
385
+ throw new ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
386
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
387
+ throw new UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}" — PowQL has no tuple-\`in\` for composite junction/target keys`);
312
388
  }
313
- return this.alwaysFalse();
389
+ const sourceJCol = sourceJ[0];
390
+ const targetJCol = targetJ[0];
391
+ const sourceRefCol = sourceRef[0];
392
+ const targetPkCol = targetMeta.primaryKey[0];
393
+ const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
394
+ const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
395
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
396
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
397
+ const collectTargetPks = async (w) => {
398
+ const rows = await targetQi.findMany({
399
+ where: w,
400
+ select: { [targetPkField]: true },
401
+ timeout,
402
+ });
403
+ return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
404
+ };
405
+ // Junction source keys linking any of `targetPks` (literal IN-list — never a subquery).
406
+ const sourcesForTargets = async (targetPks) => {
407
+ if (!targetPks.length)
408
+ return [];
409
+ const out = new Set();
410
+ for (let i = 0; i < targetPks.length; i += MAX_RELATION_KEYS) {
411
+ const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
412
+ const params = [];
413
+ const ph = chunk.map((v) => this.param(v, params)).join(', ');
414
+ const { rows } = await this.exec(`${through.table} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
415
+ for (const r of rows) {
416
+ const v = r[sourceJCol];
417
+ if (v != null)
418
+ out.add(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
419
+ }
420
+ }
421
+ return [...out];
422
+ };
423
+ if (mode === 'some') {
424
+ return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
425
+ }
426
+ if (mode === 'none') {
427
+ return { [sourceRefField]: { notIn: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
428
+ }
429
+ // every: exclude parents that link a target FAILING the predicate.
430
+ if (innerEmpty)
431
+ return {}; // every:{} ⇒ trivially true
432
+ const failing = await sourcesForTargets(await collectTargetPks({ NOT: innerWhere }));
433
+ return { [sourceRefField]: { notIn: failing } };
314
434
  }
315
435
  // -------------------------------------------------------------------------
316
436
  // Projection / order
@@ -430,7 +550,8 @@ export class PowqlInterface {
430
550
  throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
431
551
  }
432
552
  const params = [];
433
- const where = this.buildWhere(args.where, params);
553
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
554
+ const where = this.buildWhere(resolvedWhere, params);
434
555
  const cols = this.projectedColumns(args.select, args.omit);
435
556
  const distinct = args.distinct?.length ? ' distinct' : '';
436
557
  const filter = where ? ` filter ${where}` : '';
@@ -497,7 +618,8 @@ export class PowqlInterface {
497
618
  if (!rel)
498
619
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
499
620
  if (rel.type === 'manyToMany') {
500
- throw new UnsupportedFeatureError('manyToMany nested reads', 'PowDB', `relation "${relName}" junction loading is Phase B`);
621
+ await this.loadManyToMany(parents, rel, relName, opt, timeout);
622
+ continue;
501
623
  }
502
624
  const fk = normalizeKeyColumns(rel.foreignKey);
503
625
  const rk = normalizeKeyColumns(rel.referenceKey);
@@ -547,6 +669,95 @@ export class PowqlInterface {
547
669
  }
548
670
  }
549
671
  }
672
+ /**
673
+ * manyToMany nested read — a three-hop batched loader (no `json_agg`/join
674
+ * pushdown): (1) read the junction rows for all parents in `sourceKey in (…)`
675
+ * chunks, (2) read the target rows for the collected `targetKey`s, (3) stitch
676
+ * each parent → its junction rows → its targets in memory. Mirrors the
677
+ * single-key N+1 loaders; the junction's source/target columns must be single
678
+ * (composite junction keys would need PowQL tuple-`in`, which it lacks).
679
+ */
680
+ async loadManyToMany(parents, rel, relName, opt, timeout) {
681
+ const through = rel.through;
682
+ if (!through)
683
+ throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
684
+ const sourceJ = normalizeKeyColumns(through.sourceKey);
685
+ const targetJ = normalizeKeyColumns(through.targetKey);
686
+ const sourceRef = normalizeKeyColumns(rel.referenceKey);
687
+ const targetMeta = this.schema.tables[rel.to];
688
+ if (!targetMeta)
689
+ throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
690
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
691
+ throw new UnsupportedFeatureError('composite-key manyToMany', 'PowDB', `relation "${relName}" — PowQL has no tuple-\`in\`, so composite junction/target keys can't be loaded`);
692
+ }
693
+ const sourceJCol = sourceJ[0];
694
+ const targetJCol = targetJ[0];
695
+ const sourceRefCol = sourceRef[0];
696
+ const targetPkCol = targetMeta.primaryKey[0];
697
+ const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
698
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
699
+ const targetPkColMeta = targetMeta.columns.find((c) => c.name === targetPkCol);
700
+ const parentKeys = [
701
+ ...new Set(parents.map((p) => p[parentRefField]).filter((k) => k != null)),
702
+ ];
703
+ if (!parentKeys.length) {
704
+ for (const parent of parents)
705
+ parent[relName] = [];
706
+ return;
707
+ }
708
+ // (1) Junction rows: sourceKeyVal(String) → [targetKeyVal(String)].
709
+ const targetsBySource = new Map();
710
+ const allTargetVals = new Set();
711
+ for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
712
+ const chunk = parentKeys.slice(i, i + MAX_RELATION_KEYS);
713
+ const params = [];
714
+ const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
715
+ const powql = `${through.table} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
716
+ const { rows } = await this.exec(powql, params, timeout);
717
+ for (const row of rows) {
718
+ const sv = String(row[sourceJCol]);
719
+ const tv = String(row[targetJCol]);
720
+ const bucket = targetsBySource.get(sv);
721
+ if (bucket)
722
+ bucket.push(tv);
723
+ else
724
+ targetsBySource.set(sv, [tv]);
725
+ allTargetVals.add(tv);
726
+ }
727
+ }
728
+ // (2) Target rows by PK, honouring the relation's own where/with/select/…
729
+ const options = (opt === true ? {} : opt);
730
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
731
+ const targetByPk = new Map();
732
+ const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
733
+ for (let i = 0; i < targetValList.length; i += MAX_RELATION_KEYS) {
734
+ const chunk = targetValList.slice(i, i + MAX_RELATION_KEYS);
735
+ const where = {
736
+ ...options.where,
737
+ [targetPkField]: { in: chunk },
738
+ };
739
+ const targets = (await targetQi.findMany({
740
+ ...options,
741
+ where,
742
+ with: options.with,
743
+ timeout: options.timeout ?? timeout,
744
+ }));
745
+ for (const t of targets)
746
+ targetByPk.set(String(t[targetPkField]), t);
747
+ }
748
+ // (3) Stitch: each parent → its junction targets (m2m is always a list).
749
+ for (const parent of parents) {
750
+ const sv = String(parent[parentRefField]);
751
+ const tvs = targetsBySource.get(sv) ?? [];
752
+ const children = [];
753
+ for (const tv of tvs) {
754
+ const child = targetByPk.get(tv);
755
+ if (child)
756
+ children.push(child);
757
+ }
758
+ parent[relName] = children;
759
+ }
760
+ }
550
761
  // -------------------------------------------------------------------------
551
762
  // Writes (reselect — PowDB has no RETURNING)
552
763
  // -------------------------------------------------------------------------
@@ -557,19 +768,27 @@ export class PowqlInterface {
557
768
  if (value === undefined)
558
769
  continue;
559
770
  if (this.meta.relations[field]) {
560
- throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" in write data Phase B`);
771
+ throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" nested writes need create()/update(), not createMany()/upsert()`);
561
772
  }
562
773
  out.push({ col: this.column(field), value });
563
774
  }
564
775
  return out;
565
776
  }
566
- /** Fill in a client-generated UUID for a defaulted PK that wasn't supplied. */
777
+ /**
778
+ * Fill in a client-generated UUID for a defaulted **string** PK that wasn't
779
+ * supplied. A server-generated PK ({@link ColumnMetadata.isGenerated}, e.g. an
780
+ * `int` column with PowDB's `auto` modifier) is left untouched — PowDB assigns
781
+ * it and the trailing `returning` reads it back — as is any non-string PK.
782
+ */
567
783
  applyPkDefault(data) {
568
784
  const out = { ...data };
569
785
  for (const pk of this.meta.primaryKey) {
570
786
  const field = this.meta.reverseColumnMap[pk] ?? pk;
571
787
  const col = this.meta.columns.find((c) => c.name === pk);
572
- if (out[field] == null && col?.hasDefault && col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
788
+ if (out[field] == null &&
789
+ col?.hasDefault &&
790
+ !col.isGenerated &&
791
+ col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
573
792
  out[field] = randomUUID();
574
793
  }
575
794
  }
@@ -577,6 +796,9 @@ export class PowqlInterface {
577
796
  }
578
797
  async create(args) {
579
798
  return this.withMiddleware('create', args, async () => {
799
+ if (hasRelationFields(args.data, this.meta)) {
800
+ return this.nestedCreate(args);
801
+ }
580
802
  const data = this.applyPkDefault(args.data);
581
803
  const assigns = this.scalarData(data);
582
804
  const params = [];
@@ -606,8 +828,12 @@ export class PowqlInterface {
606
828
  }
607
829
  async update(args) {
608
830
  return this.withMiddleware('update', args, async () => {
831
+ if (hasRelationFields(args.data, this.meta)) {
832
+ return this.nestedUpdate(args);
833
+ }
609
834
  const params = [];
610
- const where = this.buildWhere(args.where, params);
835
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
836
+ const where = this.buildWhere(resolvedWhere, params);
611
837
  this.assertCompiledWhere(where, false, 'update');
612
838
  const setClause = this.buildUpdateAssignments(args.data, params);
613
839
  // `returning` hands back the post-update row(s); take the first (single-row contract).
@@ -621,7 +847,8 @@ export class PowqlInterface {
621
847
  async updateMany(args) {
622
848
  return this.withMiddleware('updateMany', args, async () => {
623
849
  const params = [];
624
- const where = this.buildWhere(args.where, params);
850
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
851
+ const where = this.buildWhere(resolvedWhere, params);
625
852
  this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
626
853
  const setClause = this.buildUpdateAssignments(args.data, params);
627
854
  const filter = where ? ` filter ${where}` : '';
@@ -636,7 +863,7 @@ export class PowqlInterface {
636
863
  if (value === undefined)
637
864
  continue;
638
865
  if (this.meta.relations[field]) {
639
- throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — Phase B`);
866
+ throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not updateMany()/upsert()`);
640
867
  }
641
868
  const colMeta = this.column(field);
642
869
  const ref = this.ref(field);
@@ -664,10 +891,75 @@ export class PowqlInterface {
664
891
  throw new ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
665
892
  return parts.join(', ');
666
893
  }
894
+ // -------------------------------------------------------------------------
895
+ // Nested writes — create/update whose `data` carries relation ops (create,
896
+ // connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
897
+ // the engine-agnostic nested-write engine (it only needs ctx.schema + the
898
+ // table accessors PowqlInterface already provides). Runs as ONE flat top-level
899
+ // PowDB transaction — single global write lock, no savepoints, so the whole
900
+ // tree commits or rolls back together (mirrors the SQL path's coverage:
901
+ // hasMany / hasOne / belongsTo; manyToMany nested writes are not handled by
902
+ // the shared engine on any backend).
903
+ // -------------------------------------------------------------------------
904
+ isTxScoped() {
905
+ return this.options._txScoped === true;
906
+ }
907
+ async nestedCreate(args) {
908
+ const data = args.data;
909
+ if (this.isTxScoped()) {
910
+ return executeNestedCreate(this.buildNestedCtx(), this.table, data);
911
+ }
912
+ return this.runInImplicitTx((ctx) => executeNestedCreate(ctx, this.table, data));
913
+ }
914
+ async nestedUpdate(args) {
915
+ const data = args.data;
916
+ const where = args.where;
917
+ if (this.isTxScoped()) {
918
+ return executeNestedUpdate(this.buildNestedCtx(), this.table, where, data);
919
+ }
920
+ return this.runInImplicitTx((ctx) => executeNestedUpdate(ctx, this.table, where, data));
921
+ }
922
+ /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
923
+ async runInImplicitTx(fn) {
924
+ // Route tx keywords through the dialect (like the SQL path) so this never
925
+ // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
926
+ const d = this.options.dialect;
927
+ const client = await this.pool.connect();
928
+ try {
929
+ await client.query(d?.beginStatement?.() ?? 'begin');
930
+ const { TransactionClient } = await import('./client.js');
931
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
932
+ const ctx = { schema: this.schema, tx: tx };
933
+ const result = await fn(ctx);
934
+ await client.query(d?.commitStatement?.() ?? 'commit');
935
+ return result;
936
+ }
937
+ catch (err) {
938
+ try {
939
+ await client.query(d?.rollbackStatement?.() ?? 'rollback');
940
+ }
941
+ catch {
942
+ /* best-effort — the connection may be gone */
943
+ }
944
+ throw err;
945
+ }
946
+ finally {
947
+ client.release();
948
+ }
949
+ }
950
+ /** Already inside a transaction: build a context whose table accessors reuse the pinned pool. */
951
+ buildNestedCtx() {
952
+ const opts = { ...this.options, _txScoped: true };
953
+ const tx = {
954
+ table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
955
+ };
956
+ return { schema: this.schema, tx: tx };
957
+ }
667
958
  async delete(args) {
668
959
  return this.withMiddleware('delete', args, async () => {
669
960
  const params = [];
670
- const where = this.buildWhere(args.where, params);
961
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
962
+ const where = this.buildWhere(resolvedWhere, params);
671
963
  this.assertCompiledWhere(where, false, 'delete');
672
964
  // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
673
965
  const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
@@ -680,7 +972,8 @@ export class PowqlInterface {
680
972
  async deleteMany(args) {
681
973
  return this.withMiddleware('deleteMany', args, async () => {
682
974
  const params = [];
683
- const where = this.buildWhere(args.where, params);
975
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
976
+ const where = this.buildWhere(resolvedWhere, params);
684
977
  this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
685
978
  const filter = where ? ` filter ${where}` : '';
686
979
  const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
@@ -691,8 +984,10 @@ export class PowqlInterface {
691
984
  return this.withMiddleware('upsert', args, async () => {
692
985
  const createData = this.applyPkDefault(args.create);
693
986
  const pkCol = this.meta.primaryKey[0];
694
- if (!pkCol || this.meta.primaryKey.length !== 1) {
695
- throw new UnsupportedFeatureError('composite-key upsert', 'PowDB', `table "${this.table}"`);
987
+ if (this.meta.primaryKey.length !== 1 || !pkCol) {
988
+ // PowQL's native `upsert on .col` takes a single conflict column, so a
989
+ // composite PK falls back to an atomic reselect-or-write transaction.
990
+ return this.upsertComposite(createData, args.update);
696
991
  }
697
992
  const params = [];
698
993
  const createBody = this.scalarData(createData)
@@ -711,13 +1006,42 @@ export class PowqlInterface {
711
1006
  return row;
712
1007
  });
713
1008
  }
1009
+ /**
1010
+ * Composite-key upsert: PowQL's `upsert … on .col` only takes one conflict
1011
+ * column, so reselect by the full composite PK and update-or-create inside one
1012
+ * flat transaction (PowDB single-writer makes the read-then-write safe from
1013
+ * concurrent writers; the transaction makes it atomic with the write).
1014
+ */
1015
+ async upsertComposite(createData, updateData) {
1016
+ // Accept either the camelCase field or the snake_case column in `create`
1017
+ // (create() resolves both), and key the where by field name.
1018
+ const pkPairs = this.meta.primaryKey.map((pk) => {
1019
+ const field = this.meta.reverseColumnMap[pk] ?? pk;
1020
+ return { field, value: createData[field] ?? createData[pk] };
1021
+ });
1022
+ if (pkPairs.some((p) => p.value == null)) {
1023
+ throw new ValidationError(`[turbine] upsert on "${this.table}" needs every composite-PK field in \`create\` (${pkPairs
1024
+ .map((p) => p.field)
1025
+ .join(', ')}).`);
1026
+ }
1027
+ const pkWhere = Object.fromEntries(pkPairs.map((p) => [p.field, p.value]));
1028
+ const run = async (ctx) => {
1029
+ const tbl = ctx.tx.table(this.table);
1030
+ const existing = await tbl.findUnique({ where: pkWhere });
1031
+ return existing
1032
+ ? (await tbl.update({ where: pkWhere, data: updateData }))
1033
+ : (await tbl.create({ data: createData }));
1034
+ };
1035
+ return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
1036
+ }
714
1037
  // -------------------------------------------------------------------------
715
1038
  // Aggregates
716
1039
  // -------------------------------------------------------------------------
717
1040
  async count(args = {}) {
718
1041
  return this.withMiddleware('count', (args ?? {}), async () => {
719
1042
  const params = [];
720
- const where = this.buildWhere(args.where, params);
1043
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1044
+ const where = this.buildWhere(resolvedWhere, params);
721
1045
  const filter = where ? ` filter ${where}` : '';
722
1046
  const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
723
1047
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
@@ -728,7 +1052,8 @@ export class PowqlInterface {
728
1052
  // One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
729
1053
  const result = {};
730
1054
  const filterParams = [];
731
- const where = this.buildWhere(args.where, filterParams);
1055
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1056
+ const where = this.buildWhere(resolvedWhere, filterParams);
732
1057
  const filter = where ? ` filter ${where}` : '';
733
1058
  const scalar = async (expr) => {
734
1059
  const params = [...filterParams];
@@ -765,7 +1090,8 @@ export class PowqlInterface {
765
1090
  async groupBy(args) {
766
1091
  return this.withMiddleware('groupBy', args, async () => {
767
1092
  const params = [];
768
- const where = this.buildWhere(args.where, params);
1093
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1094
+ const where = this.buildWhere(resolvedWhere, params);
769
1095
  const filter = where ? ` filter ${where}` : '';
770
1096
  const groupKeys = args.by.map((f) => this.ref(f));
771
1097
  // Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
package/dist/schema.d.ts CHANGED
@@ -51,6 +51,16 @@ export interface ColumnMetadata {
51
51
  nullable: boolean;
52
52
  /** Whether the column has a DEFAULT, is serial, or is generated */
53
53
  hasDefault: boolean;
54
+ /**
55
+ * Whether the **database server** generates this column's value on insert —
56
+ * a `serial`/`BIGSERIAL` sequence, an `IDENTITY` column, or PowDB's `auto`
57
+ * modifier. This is a strict subset of {@link hasDefault} (a server-generated
58
+ * column always reports `hasDefault: true`), but unlike a client-side default
59
+ * expression (`gen_random_uuid()`, `now()`) the value is assigned by the
60
+ * engine, so Turbine must NOT synthesize one client-side and the PowDB DDL
61
+ * emits the `auto` modifier. Optional / defaults to `false` for back-compat.
62
+ */
63
+ isGenerated?: boolean;
54
64
  /** Whether this is an array column */
55
65
  isArray: boolean;
56
66
  /** Dialect-specific array/bulk-insert type token when needed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {