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/cjs/powql.js CHANGED
@@ -16,19 +16,63 @@
16
16
  * - `create`/`createMany`/`update`/`delete` use PowDB 0.7.0's trailing
17
17
  * `returning` keyword (`RETURNING *`, all columns) to surface affected rows
18
18
  * in one round-trip. `upsert` is the lone exception — its statement does not
19
- * accept `returning`, so it reselects the row by PK.
20
- * - The PK is generated client-side (UUID) when the column has a default.
21
- * - `with` (nested relations) degrades to **batched N+1 loaders** D
22
- * round-trips for depth D, not one query. manyToMany is Phase B.
19
+ * accept `returning`, so it reselects the row by PK (a composite-PK upsert
20
+ * reselects-or-writes inside one flat transaction).
21
+ * - The PK is server-assigned when the column is `isGenerated` (PowDB's `auto`
22
+ * int read back via `returning`); otherwise a defaulted **string** PK is
23
+ * generated client-side (UUID).
24
+ * - `with` (nested relations) uses **batched N+1 loaders** — D round-trips for
25
+ * depth D, not one query — including manyToMany (junction → targets).
26
+ * - **Relation filters** (`some`/`none`/`every`, all cardinalities incl. m2m)
27
+ * are resolved client-side to a literal `in (…)` list, never an IN-subquery:
28
+ * PowDB's executor caches a subquery's result by plan shape and would return
29
+ * a stale prior result for a later subquery of the same shape.
30
+ * - **Nested writes** (relation ops in `create`/`update` data) run through the
31
+ * shared nested-write engine as one flat top-level transaction (PowDB is
32
+ * single-writer, no savepoints).
23
33
  * - pgvector / JSON / array filters and cursor streaming throw
24
34
  * {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
25
35
  *
26
36
  * @module
27
37
  */
38
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
39
+ if (k2 === undefined) k2 = k;
40
+ var desc = Object.getOwnPropertyDescriptor(m, k);
41
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
42
+ desc = { enumerable: true, get: function() { return m[k]; } };
43
+ }
44
+ Object.defineProperty(o, k2, desc);
45
+ }) : (function(o, m, k, k2) {
46
+ if (k2 === undefined) k2 = k;
47
+ o[k2] = m[k];
48
+ }));
49
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
50
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
51
+ }) : function(o, v) {
52
+ o["default"] = v;
53
+ });
54
+ var __importStar = (this && this.__importStar) || (function () {
55
+ var ownKeys = function(o) {
56
+ ownKeys = Object.getOwnPropertyNames || function (o) {
57
+ var ar = [];
58
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
59
+ return ar;
60
+ };
61
+ return ownKeys(o);
62
+ };
63
+ return function (mod) {
64
+ if (mod && mod.__esModule) return mod;
65
+ var result = {};
66
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
67
+ __setModuleDefault(result, mod);
68
+ return result;
69
+ };
70
+ })();
28
71
  Object.defineProperty(exports, "__esModule", { value: true });
29
72
  exports.PowqlInterface = void 0;
30
73
  const node_crypto_1 = require("node:crypto");
31
74
  const errors_js_1 = require("./errors.js");
75
+ const nested_write_js_1 = require("./nested-write.js");
32
76
  const powdb_js_1 = require("./powdb.js");
33
77
  const utils_js_1 = require("./query/utils.js");
34
78
  const schema_js_1 = require("./schema.js");
@@ -191,7 +235,10 @@ class PowqlInterface {
191
235
  parts.push(`not (${sub})`);
192
236
  }
193
237
  else if (this.meta.relations[key]) {
194
- parts.push(this.buildRelationFilter(this.meta.relations[key], value, params));
238
+ // Relation filters are pre-resolved to scalar in/notIn by
239
+ // resolveRelationFilters() before buildWhere runs — reaching here means
240
+ // a caller skipped that step (an internal bug, not user error).
241
+ throw new errors_js_1.ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
195
242
  }
196
243
  else {
197
244
  parts.push(this.buildFieldCondition(key, value, params));
@@ -279,41 +326,147 @@ class PowqlInterface {
279
326
  return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
280
327
  }
281
328
  /**
282
- * Relation filter (`some`/`none`/`every`) via the verified IN-subquery form
283
- * `Outer filter .localKey in (Target filter <e> { .targetKey })`. `exists`
284
- * correlation is unreliable on PowDB, so it is never used.
329
+ * Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
330
+ * into a plain scalar `in`/`notIn` condition on the **local key**, by running
331
+ * the inner predicate as its own query and materializing the matching keys as
332
+ * a literal list. The compiled where is then relation-free and `buildWhere`
333
+ * emits only `in (<literal list>)`.
334
+ *
335
+ * Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
336
+ * executor caches a subquery's result by **plan shape, ignoring the literal**,
337
+ * so a second subquery of the same shape with a different value returns the
338
+ * first one's stale rows (reproduced live on the embedded engine; the
339
+ * single-statement literal `in (list)` form is always correct). Resolving
340
+ * client-side trades extra round-trips for correctness, and recurses — nested
341
+ * relation filters in the inner predicate resolve when the target query runs.
285
342
  */
286
- buildRelationFilter(rel, filter, params) {
343
+ async resolveRelationFilters(where, timeout) {
344
+ if (!where)
345
+ return where;
346
+ const scalar = {};
347
+ const relConds = [];
348
+ for (const [key, value] of Object.entries(where)) {
349
+ if (value === undefined)
350
+ continue;
351
+ if (key === 'AND' || key === 'OR') {
352
+ scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFilters(w, timeout)));
353
+ }
354
+ else if (key === 'NOT') {
355
+ scalar[key] = await this.resolveRelationFilters(value, timeout);
356
+ }
357
+ else if (this.meta.relations[key]) {
358
+ relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
359
+ }
360
+ else {
361
+ scalar[key] = value;
362
+ }
363
+ }
364
+ if (!relConds.length)
365
+ return scalar;
366
+ const hasScalar = Object.keys(scalar).length > 0;
367
+ return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
368
+ }
369
+ /** Resolve one hasMany/hasOne/belongsTo filter to `{ localField: { in|notIn: [...] } }`. */
370
+ async resolveRelationCondition(rel, filter, timeout) {
371
+ const mode = filter.some ? 'some' : filter.none ? 'none' : filter.every ? 'every' : null;
372
+ if (!mode)
373
+ return {};
374
+ const innerWhere = filter[mode];
375
+ const innerEmpty = !innerWhere || Object.keys(innerWhere).length === 0;
287
376
  if (rel.type === 'manyToMany') {
288
- throw new errors_js_1.UnsupportedFeatureError('manyToMany relation filters', 'PowDB', `relation "${rel.name}" — junction-table relation filters are Phase B`);
377
+ return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
289
378
  }
290
379
  const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
291
380
  const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
292
381
  if (fk.length > 1 || rk.length > 1) {
293
- throw new errors_js_1.UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key`);
382
+ throw new errors_js_1.UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key — PowQL has no tuple-\`in\` to express it`);
294
383
  }
295
- // hasMany/hasOne: outer.referenceKey ∈ target.foreignKey.
296
- // belongsTo: outer.foreignKey ∈ target.referenceKey.
297
- const localKey = rel.type === 'belongsTo' ? fk[0] : rk[0];
298
- const targetKey = rel.type === 'belongsTo' ? rk[0] : fk[0];
299
384
  const targetMeta = this.schema.tables[rel.to];
300
385
  if (!targetMeta)
301
386
  throw new errors_js_1.ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
302
- const sub = (whereObj, negateInner) => {
303
- const innerQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
304
- const inner = innerQi.buildWhere(whereObj, params);
305
- const filterClause = inner ? ` filter ${negateInner ? `not (${inner})` : inner}` : '';
306
- return `${rel.to}${filterClause} { .${targetKey} }`;
387
+ // hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
388
+ // belongsTo: localKey = our foreignKey, collect the target's referenceKey.
389
+ const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
390
+ const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
391
+ const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
392
+ const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
393
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
394
+ const collect = async (w) => {
395
+ const rows = await targetQi.findMany({
396
+ where: w,
397
+ select: { [childField]: true },
398
+ timeout,
399
+ });
400
+ return [...new Set(rows.map((r) => r[childField]).filter((v) => v != null))];
401
+ };
402
+ if (mode === 'some')
403
+ return { [localField]: { in: await collect(innerWhere) } };
404
+ if (mode === 'none')
405
+ return { [localField]: { notIn: await collect(innerWhere) } };
406
+ // every: a parent qualifies unless it has a child FAILING the predicate.
407
+ if (innerEmpty)
408
+ return {}; // every:{} ⇒ trivially true for all parents
409
+ return { [localField]: { notIn: await collect({ NOT: innerWhere }) } };
410
+ }
411
+ /** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
412
+ async resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout) {
413
+ const through = rel.through;
414
+ if (!through)
415
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
416
+ const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
417
+ const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
418
+ const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
419
+ const targetMeta = this.schema.tables[rel.to];
420
+ if (!targetMeta)
421
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
422
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
423
+ throw new errors_js_1.UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}" — PowQL has no tuple-\`in\` for composite junction/target keys`);
424
+ }
425
+ const sourceJCol = sourceJ[0];
426
+ const targetJCol = targetJ[0];
427
+ const sourceRefCol = sourceRef[0];
428
+ const targetPkCol = targetMeta.primaryKey[0];
429
+ const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
430
+ const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
431
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
432
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
433
+ const collectTargetPks = async (w) => {
434
+ const rows = await targetQi.findMany({
435
+ where: w,
436
+ select: { [targetPkField]: true },
437
+ timeout,
438
+ });
439
+ return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
440
+ };
441
+ // Junction source keys linking any of `targetPks` (literal IN-list — never a subquery).
442
+ const sourcesForTargets = async (targetPks) => {
443
+ if (!targetPks.length)
444
+ return [];
445
+ const out = new Set();
446
+ for (let i = 0; i < targetPks.length; i += MAX_RELATION_KEYS) {
447
+ const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
448
+ const params = [];
449
+ const ph = chunk.map((v) => this.param(v, params)).join(', ');
450
+ const { rows } = await this.exec(`${through.table} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
451
+ for (const r of rows) {
452
+ const v = r[sourceJCol];
453
+ if (v != null)
454
+ out.add(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
455
+ }
456
+ }
457
+ return [...out];
307
458
  };
308
- if (filter.some)
309
- return `.${localKey} in (${sub(filter.some, false)})`;
310
- if (filter.none)
311
- return `.${localKey} not in (${sub(filter.none, false)})`;
312
- if (filter.every) {
313
- // Parents with no child failing the predicate.
314
- return `.${localKey} not in (${sub(filter.every, true)})`;
459
+ if (mode === 'some') {
460
+ return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
315
461
  }
316
- return this.alwaysFalse();
462
+ if (mode === 'none') {
463
+ return { [sourceRefField]: { notIn: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
464
+ }
465
+ // every: exclude parents that link a target FAILING the predicate.
466
+ if (innerEmpty)
467
+ return {}; // every:{} ⇒ trivially true
468
+ const failing = await sourcesForTargets(await collectTargetPks({ NOT: innerWhere }));
469
+ return { [sourceRefField]: { notIn: failing } };
317
470
  }
318
471
  // -------------------------------------------------------------------------
319
472
  // Projection / order
@@ -433,7 +586,8 @@ class PowqlInterface {
433
586
  throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
434
587
  }
435
588
  const params = [];
436
- const where = this.buildWhere(args.where, params);
589
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
590
+ const where = this.buildWhere(resolvedWhere, params);
437
591
  const cols = this.projectedColumns(args.select, args.omit);
438
592
  const distinct = args.distinct?.length ? ' distinct' : '';
439
593
  const filter = where ? ` filter ${where}` : '';
@@ -500,7 +654,8 @@ class PowqlInterface {
500
654
  if (!rel)
501
655
  throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
502
656
  if (rel.type === 'manyToMany') {
503
- throw new errors_js_1.UnsupportedFeatureError('manyToMany nested reads', 'PowDB', `relation "${relName}" junction loading is Phase B`);
657
+ await this.loadManyToMany(parents, rel, relName, opt, timeout);
658
+ continue;
504
659
  }
505
660
  const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
506
661
  const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
@@ -550,6 +705,95 @@ class PowqlInterface {
550
705
  }
551
706
  }
552
707
  }
708
+ /**
709
+ * manyToMany nested read — a three-hop batched loader (no `json_agg`/join
710
+ * pushdown): (1) read the junction rows for all parents in `sourceKey in (…)`
711
+ * chunks, (2) read the target rows for the collected `targetKey`s, (3) stitch
712
+ * each parent → its junction rows → its targets in memory. Mirrors the
713
+ * single-key N+1 loaders; the junction's source/target columns must be single
714
+ * (composite junction keys would need PowQL tuple-`in`, which it lacks).
715
+ */
716
+ async loadManyToMany(parents, rel, relName, opt, timeout) {
717
+ const through = rel.through;
718
+ if (!through)
719
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
720
+ const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
721
+ const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
722
+ const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
723
+ const targetMeta = this.schema.tables[rel.to];
724
+ if (!targetMeta)
725
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
726
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
727
+ throw new errors_js_1.UnsupportedFeatureError('composite-key manyToMany', 'PowDB', `relation "${relName}" — PowQL has no tuple-\`in\`, so composite junction/target keys can't be loaded`);
728
+ }
729
+ const sourceJCol = sourceJ[0];
730
+ const targetJCol = targetJ[0];
731
+ const sourceRefCol = sourceRef[0];
732
+ const targetPkCol = targetMeta.primaryKey[0];
733
+ const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
734
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
735
+ const targetPkColMeta = targetMeta.columns.find((c) => c.name === targetPkCol);
736
+ const parentKeys = [
737
+ ...new Set(parents.map((p) => p[parentRefField]).filter((k) => k != null)),
738
+ ];
739
+ if (!parentKeys.length) {
740
+ for (const parent of parents)
741
+ parent[relName] = [];
742
+ return;
743
+ }
744
+ // (1) Junction rows: sourceKeyVal(String) → [targetKeyVal(String)].
745
+ const targetsBySource = new Map();
746
+ const allTargetVals = new Set();
747
+ for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
748
+ const chunk = parentKeys.slice(i, i + MAX_RELATION_KEYS);
749
+ const params = [];
750
+ const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
751
+ const powql = `${through.table} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
752
+ const { rows } = await this.exec(powql, params, timeout);
753
+ for (const row of rows) {
754
+ const sv = String(row[sourceJCol]);
755
+ const tv = String(row[targetJCol]);
756
+ const bucket = targetsBySource.get(sv);
757
+ if (bucket)
758
+ bucket.push(tv);
759
+ else
760
+ targetsBySource.set(sv, [tv]);
761
+ allTargetVals.add(tv);
762
+ }
763
+ }
764
+ // (2) Target rows by PK, honouring the relation's own where/with/select/…
765
+ const options = (opt === true ? {} : opt);
766
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
767
+ const targetByPk = new Map();
768
+ const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
769
+ for (let i = 0; i < targetValList.length; i += MAX_RELATION_KEYS) {
770
+ const chunk = targetValList.slice(i, i + MAX_RELATION_KEYS);
771
+ const where = {
772
+ ...options.where,
773
+ [targetPkField]: { in: chunk },
774
+ };
775
+ const targets = (await targetQi.findMany({
776
+ ...options,
777
+ where,
778
+ with: options.with,
779
+ timeout: options.timeout ?? timeout,
780
+ }));
781
+ for (const t of targets)
782
+ targetByPk.set(String(t[targetPkField]), t);
783
+ }
784
+ // (3) Stitch: each parent → its junction targets (m2m is always a list).
785
+ for (const parent of parents) {
786
+ const sv = String(parent[parentRefField]);
787
+ const tvs = targetsBySource.get(sv) ?? [];
788
+ const children = [];
789
+ for (const tv of tvs) {
790
+ const child = targetByPk.get(tv);
791
+ if (child)
792
+ children.push(child);
793
+ }
794
+ parent[relName] = children;
795
+ }
796
+ }
553
797
  // -------------------------------------------------------------------------
554
798
  // Writes (reselect — PowDB has no RETURNING)
555
799
  // -------------------------------------------------------------------------
@@ -560,19 +804,27 @@ class PowqlInterface {
560
804
  if (value === undefined)
561
805
  continue;
562
806
  if (this.meta.relations[field]) {
563
- throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" in write data Phase B`);
807
+ throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" nested writes need create()/update(), not createMany()/upsert()`);
564
808
  }
565
809
  out.push({ col: this.column(field), value });
566
810
  }
567
811
  return out;
568
812
  }
569
- /** Fill in a client-generated UUID for a defaulted PK that wasn't supplied. */
813
+ /**
814
+ * Fill in a client-generated UUID for a defaulted **string** PK that wasn't
815
+ * supplied. A server-generated PK ({@link ColumnMetadata.isGenerated}, e.g. an
816
+ * `int` column with PowDB's `auto` modifier) is left untouched — PowDB assigns
817
+ * it and the trailing `returning` reads it back — as is any non-string PK.
818
+ */
570
819
  applyPkDefault(data) {
571
820
  const out = { ...data };
572
821
  for (const pk of this.meta.primaryKey) {
573
822
  const field = this.meta.reverseColumnMap[pk] ?? pk;
574
823
  const col = this.meta.columns.find((c) => c.name === pk);
575
- if (out[field] == null && col?.hasDefault && col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
824
+ if (out[field] == null &&
825
+ col?.hasDefault &&
826
+ !col.isGenerated &&
827
+ col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
576
828
  out[field] = (0, node_crypto_1.randomUUID)();
577
829
  }
578
830
  }
@@ -580,6 +832,9 @@ class PowqlInterface {
580
832
  }
581
833
  async create(args) {
582
834
  return this.withMiddleware('create', args, async () => {
835
+ if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
836
+ return this.nestedCreate(args);
837
+ }
583
838
  const data = this.applyPkDefault(args.data);
584
839
  const assigns = this.scalarData(data);
585
840
  const params = [];
@@ -609,8 +864,12 @@ class PowqlInterface {
609
864
  }
610
865
  async update(args) {
611
866
  return this.withMiddleware('update', args, async () => {
867
+ if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
868
+ return this.nestedUpdate(args);
869
+ }
612
870
  const params = [];
613
- const where = this.buildWhere(args.where, params);
871
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
872
+ const where = this.buildWhere(resolvedWhere, params);
614
873
  this.assertCompiledWhere(where, false, 'update');
615
874
  const setClause = this.buildUpdateAssignments(args.data, params);
616
875
  // `returning` hands back the post-update row(s); take the first (single-row contract).
@@ -624,7 +883,8 @@ class PowqlInterface {
624
883
  async updateMany(args) {
625
884
  return this.withMiddleware('updateMany', args, async () => {
626
885
  const params = [];
627
- const where = this.buildWhere(args.where, params);
886
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
887
+ const where = this.buildWhere(resolvedWhere, params);
628
888
  this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
629
889
  const setClause = this.buildUpdateAssignments(args.data, params);
630
890
  const filter = where ? ` filter ${where}` : '';
@@ -639,7 +899,7 @@ class PowqlInterface {
639
899
  if (value === undefined)
640
900
  continue;
641
901
  if (this.meta.relations[field]) {
642
- throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — Phase B`);
902
+ throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not updateMany()/upsert()`);
643
903
  }
644
904
  const colMeta = this.column(field);
645
905
  const ref = this.ref(field);
@@ -667,10 +927,75 @@ class PowqlInterface {
667
927
  throw new errors_js_1.ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
668
928
  return parts.join(', ');
669
929
  }
930
+ // -------------------------------------------------------------------------
931
+ // Nested writes — create/update whose `data` carries relation ops (create,
932
+ // connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
933
+ // the engine-agnostic nested-write engine (it only needs ctx.schema + the
934
+ // table accessors PowqlInterface already provides). Runs as ONE flat top-level
935
+ // PowDB transaction — single global write lock, no savepoints, so the whole
936
+ // tree commits or rolls back together (mirrors the SQL path's coverage:
937
+ // hasMany / hasOne / belongsTo; manyToMany nested writes are not handled by
938
+ // the shared engine on any backend).
939
+ // -------------------------------------------------------------------------
940
+ isTxScoped() {
941
+ return this.options._txScoped === true;
942
+ }
943
+ async nestedCreate(args) {
944
+ const data = args.data;
945
+ if (this.isTxScoped()) {
946
+ return (0, nested_write_js_1.executeNestedCreate)(this.buildNestedCtx(), this.table, data);
947
+ }
948
+ return this.runInImplicitTx((ctx) => (0, nested_write_js_1.executeNestedCreate)(ctx, this.table, data));
949
+ }
950
+ async nestedUpdate(args) {
951
+ const data = args.data;
952
+ const where = args.where;
953
+ if (this.isTxScoped()) {
954
+ return (0, nested_write_js_1.executeNestedUpdate)(this.buildNestedCtx(), this.table, where, data);
955
+ }
956
+ return this.runInImplicitTx((ctx) => (0, nested_write_js_1.executeNestedUpdate)(ctx, this.table, where, data));
957
+ }
958
+ /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
959
+ async runInImplicitTx(fn) {
960
+ // Route tx keywords through the dialect (like the SQL path) so this never
961
+ // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
962
+ const d = this.options.dialect;
963
+ const client = await this.pool.connect();
964
+ try {
965
+ await client.query(d?.beginStatement?.() ?? 'begin');
966
+ const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
967
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
968
+ const ctx = { schema: this.schema, tx: tx };
969
+ const result = await fn(ctx);
970
+ await client.query(d?.commitStatement?.() ?? 'commit');
971
+ return result;
972
+ }
973
+ catch (err) {
974
+ try {
975
+ await client.query(d?.rollbackStatement?.() ?? 'rollback');
976
+ }
977
+ catch {
978
+ /* best-effort — the connection may be gone */
979
+ }
980
+ throw err;
981
+ }
982
+ finally {
983
+ client.release();
984
+ }
985
+ }
986
+ /** Already inside a transaction: build a context whose table accessors reuse the pinned pool. */
987
+ buildNestedCtx() {
988
+ const opts = { ...this.options, _txScoped: true };
989
+ const tx = {
990
+ table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
991
+ };
992
+ return { schema: this.schema, tx: tx };
993
+ }
670
994
  async delete(args) {
671
995
  return this.withMiddleware('delete', args, async () => {
672
996
  const params = [];
673
- const where = this.buildWhere(args.where, params);
997
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
998
+ const where = this.buildWhere(resolvedWhere, params);
674
999
  this.assertCompiledWhere(where, false, 'delete');
675
1000
  // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
676
1001
  const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
@@ -683,7 +1008,8 @@ class PowqlInterface {
683
1008
  async deleteMany(args) {
684
1009
  return this.withMiddleware('deleteMany', args, async () => {
685
1010
  const params = [];
686
- const where = this.buildWhere(args.where, params);
1011
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1012
+ const where = this.buildWhere(resolvedWhere, params);
687
1013
  this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
688
1014
  const filter = where ? ` filter ${where}` : '';
689
1015
  const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
@@ -694,8 +1020,10 @@ class PowqlInterface {
694
1020
  return this.withMiddleware('upsert', args, async () => {
695
1021
  const createData = this.applyPkDefault(args.create);
696
1022
  const pkCol = this.meta.primaryKey[0];
697
- if (!pkCol || this.meta.primaryKey.length !== 1) {
698
- throw new errors_js_1.UnsupportedFeatureError('composite-key upsert', 'PowDB', `table "${this.table}"`);
1023
+ if (this.meta.primaryKey.length !== 1 || !pkCol) {
1024
+ // PowQL's native `upsert on .col` takes a single conflict column, so a
1025
+ // composite PK falls back to an atomic reselect-or-write transaction.
1026
+ return this.upsertComposite(createData, args.update);
699
1027
  }
700
1028
  const params = [];
701
1029
  const createBody = this.scalarData(createData)
@@ -714,13 +1042,42 @@ class PowqlInterface {
714
1042
  return row;
715
1043
  });
716
1044
  }
1045
+ /**
1046
+ * Composite-key upsert: PowQL's `upsert … on .col` only takes one conflict
1047
+ * column, so reselect by the full composite PK and update-or-create inside one
1048
+ * flat transaction (PowDB single-writer makes the read-then-write safe from
1049
+ * concurrent writers; the transaction makes it atomic with the write).
1050
+ */
1051
+ async upsertComposite(createData, updateData) {
1052
+ // Accept either the camelCase field or the snake_case column in `create`
1053
+ // (create() resolves both), and key the where by field name.
1054
+ const pkPairs = this.meta.primaryKey.map((pk) => {
1055
+ const field = this.meta.reverseColumnMap[pk] ?? pk;
1056
+ return { field, value: createData[field] ?? createData[pk] };
1057
+ });
1058
+ if (pkPairs.some((p) => p.value == null)) {
1059
+ throw new errors_js_1.ValidationError(`[turbine] upsert on "${this.table}" needs every composite-PK field in \`create\` (${pkPairs
1060
+ .map((p) => p.field)
1061
+ .join(', ')}).`);
1062
+ }
1063
+ const pkWhere = Object.fromEntries(pkPairs.map((p) => [p.field, p.value]));
1064
+ const run = async (ctx) => {
1065
+ const tbl = ctx.tx.table(this.table);
1066
+ const existing = await tbl.findUnique({ where: pkWhere });
1067
+ return existing
1068
+ ? (await tbl.update({ where: pkWhere, data: updateData }))
1069
+ : (await tbl.create({ data: createData }));
1070
+ };
1071
+ return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
1072
+ }
717
1073
  // -------------------------------------------------------------------------
718
1074
  // Aggregates
719
1075
  // -------------------------------------------------------------------------
720
1076
  async count(args = {}) {
721
1077
  return this.withMiddleware('count', (args ?? {}), async () => {
722
1078
  const params = [];
723
- const where = this.buildWhere(args.where, params);
1079
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1080
+ const where = this.buildWhere(resolvedWhere, params);
724
1081
  const filter = where ? ` filter ${where}` : '';
725
1082
  const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
726
1083
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
@@ -731,7 +1088,8 @@ class PowqlInterface {
731
1088
  // One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
732
1089
  const result = {};
733
1090
  const filterParams = [];
734
- const where = this.buildWhere(args.where, filterParams);
1091
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1092
+ const where = this.buildWhere(resolvedWhere, filterParams);
735
1093
  const filter = where ? ` filter ${where}` : '';
736
1094
  const scalar = async (expr) => {
737
1095
  const params = [...filterParams];
@@ -768,7 +1126,8 @@ class PowqlInterface {
768
1126
  async groupBy(args) {
769
1127
  return this.withMiddleware('groupBy', args, async () => {
770
1128
  const params = [];
771
- const where = this.buildWhere(args.where, params);
1129
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1130
+ const where = this.buildWhere(resolvedWhere, params);
772
1131
  const filter = where ? ` filter ${where}` : '';
773
1132
  const groupKeys = args.by.map((f) => this.ref(f));
774
1133
  // Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
package/dist/generate.js CHANGED
@@ -504,6 +504,10 @@ function serializeColumn(col) {
504
504
  `arrayType: '${escSQ(col.arrayType ?? col.pgArrayType)}'`,
505
505
  `pgArrayType: '${escSQ(col.pgArrayType)}'`,
506
506
  ];
507
+ // Emit isGenerated only when set (server-generated serial/identity), so the
508
+ // output stays byte-identical for the common client-default columns.
509
+ if (col.isGenerated)
510
+ parts.push(`isGenerated: true`);
507
511
  if (col.maxLength !== undefined)
508
512
  parts.push(`maxLength: ${col.maxLength}`);
509
513
  return `{ ${parts.join(', ')} }`;
@@ -28,6 +28,7 @@ const SQL_COLUMNS = `
28
28
  data_type,
29
29
  is_nullable,
30
30
  column_default,
31
+ is_identity,
31
32
  ordinal_position,
32
33
  character_maximum_length
33
34
  FROM information_schema.columns
@@ -163,6 +164,11 @@ export async function introspectPostgresCatalog(options) {
163
164
  pgTypeToTs(isArray ? dialectType : baseType, isNullable),
164
165
  nullable: isNullable,
165
166
  hasDefault: row.column_default !== null,
167
+ // Server-generated = a sequence default (serial/BIGSERIAL → nextval(…))
168
+ // or an IDENTITY column. Distinct from a client-side default expression
169
+ // (gen_random_uuid(), now()), which Turbine must still synthesize.
170
+ isGenerated: (typeof row.column_default === 'string' && row.column_default.includes('nextval(')) ||
171
+ row.is_identity === 'YES',
166
172
  isArray,
167
173
  arrayType,
168
174
  pgArrayType: arrayType,
package/dist/powdb.d.ts CHANGED
@@ -150,7 +150,10 @@ export declare function powqlColumnType(col: ColumnMetadata): PowqlType;
150
150
  * Generate PowQL DDL (`type T { … }`) for every table in a schema. Used to
151
151
  * provision a PowDB database from a code-first `defineSchema`/`SchemaMetadata`
152
152
  * (PowDB has no migration runner yet). The primary key column is declared
153
- * `required unique`; non-nullable columns are `required`.
153
+ * `required unique`; non-nullable columns are `required`. A server-generated
154
+ * column ({@link ColumnMetadata.isGenerated}) that maps to PowQL `int` gets the
155
+ * `auto` modifier, so PowDB assigns a monotonic id on insert and Turbine stops
156
+ * synthesizing a client-side value for it.
154
157
  */
155
158
  export declare function powqlSchemaDDL(schema: SchemaMetadata): string[];
156
159
  /**