turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -1134,9 +1134,11 @@ export class QueryInterface {
1134
1134
  }
1135
1135
  /**
1136
1136
  * The field names a `cursor` actually seeks on (its own keys with a defined
1137
- * value), in the canonical sorted order the cursor conditions are built in.
1138
- * Empty for a missing cursor or one whose every value is `undefined` (which
1139
- * emits no seek condition at all, so it does not paginate).
1137
+ * value), in canonical sorted order. This is the diagnostic / implicit-
1138
+ * ordering view; the seek itself orders the fields by their `orderBy`
1139
+ * precedence (see {@link cursorSeekEntries}). Empty for a missing cursor or
1140
+ * one whose every value is `undefined` (which emits no seek condition at
1141
+ * all, so it does not paginate).
1140
1142
  */
1141
1143
  cursorFields(cursor) {
1142
1144
  if (!cursor || typeof cursor !== 'object')
@@ -1150,18 +1152,20 @@ export class QueryInterface {
1150
1152
  * The ascending ordering implied by a `cursor`, or `undefined` when the shape
1151
1153
  * is too ambiguous to order safely.
1152
1154
  *
1153
- * A cursor seek emits `col > $n` per field (`<` when the orderBy says desc),
1154
- * so the ONLY ordering coherent with it is on the cursor's own field: ordering
1155
- * a seek on column X by column Y walks the table in an order the seek does not
1156
- * follow, which skips and repeats rows just as badly as no order at all. That
1157
- * is why this orders on the cursor field rather than blindly on the primary
1158
- * key when the two differ.
1155
+ * A cursor seek compares against the cursor's own field(s) (`col > $n`, `<`
1156
+ * when the orderBy says desc), so the ONLY ordering coherent with it is on
1157
+ * those fields: ordering a seek on column X by column Y walks the table in an
1158
+ * order the seek does not follow, which skips and repeats rows just as badly
1159
+ * as no order at all. That is why this orders on the cursor field rather than
1160
+ * blindly on the primary key when the two differ.
1159
1161
  *
1160
1162
  * Returns `undefined` (warn, inject nothing) for two shapes:
1161
- * - a MULTI-field cursor. `a > $1 AND b > $2` is a conjunction, not a proper
1162
- * composite keyset seek (`(a, b) > ($1, $2)`), so no single ORDER BY makes
1163
- * it correct. Injecting `(a asc, b asc)` would dress a broken seek up as a
1164
- * sound one.
1163
+ * - a MULTI-field cursor. The seek is a proper keyset predicate, but its
1164
+ * column PRECEDENCE comes from the `orderBy` ({@link cursorSeekEntries}),
1165
+ * and with no orderBy there is nothing to derive it from: the cursor
1166
+ * object's key order is canonicalized away, so `(a asc, b asc)` would be a
1167
+ * guess at which key the caller meant to lead. The caller must state the
1168
+ * order, and the unordered-page warning says so.
1165
1169
  * - a field that does not resolve to a real column. Column validation belongs
1166
1170
  * to the normal build path, which raises a precise error; synthesizing an
1167
1171
  * ORDER BY on it here would only change which error the caller sees.
@@ -1179,6 +1183,83 @@ export class QueryInterface {
1179
1183
  }
1180
1184
  return { [field]: 'asc' };
1181
1185
  }
1186
+ /**
1187
+ * The fields a `cursor` seeks on, in KEYSET order with their seek direction.
1188
+ * THE single authority the cache fingerprint (`cur=`), the SQL build and the
1189
+ * cache-hit param collect all consume, so the three cannot disagree about
1190
+ * which value binds to which `$n`.
1191
+ *
1192
+ * Keyset order is the `orderBy` precedence: the predicate for
1193
+ * `orderBy: [{ viewCount }, { id }]` must test `view_count` first and `id`
1194
+ * only within equal `view_count`, whatever order the caller wrote the cursor
1195
+ * object in (its keys are canonicalized for the cache anyway). A cursor field
1196
+ * the orderBy does not name trails the named ones in sorted key order and
1197
+ * seeks ascending, the same default a single-field cursor with no orderBy has
1198
+ * always had.
1199
+ *
1200
+ * Directions are indexed by the RESOLVED COLUMN, never the caller's key: both
1201
+ * `cursor` and `orderBy` take either spelling, so a cursor written
1202
+ * `{ created_at }` against `orderBy: { createdAt: 'desc' }` used to miss the
1203
+ * lookup, default to ascending, and seek the wrong side of the page. The
1204
+ * `{ sort, nulls }` spec form is normalized for the same reason. A relation /
1205
+ * JSON-path / vector orderBy key resolves to no column and is skipped.
1206
+ *
1207
+ * An unknown cursor field throws the same E003 here that the build path
1208
+ * threw before this helper existed; the fingerprint simply meets it first.
1209
+ */
1210
+ cursorSeekEntries(cursor, orderBy) {
1211
+ const orderPos = new Map();
1212
+ const orderDesc = new Map();
1213
+ for (const [ok, od] of orderByEntries(orderBy)) {
1214
+ const ocol = resolveColumnName(this.tableMeta, ok);
1215
+ if (ocol === undefined)
1216
+ continue;
1217
+ if (!orderPos.has(ocol))
1218
+ orderPos.set(ocol, orderPos.size);
1219
+ // Last wins, matching object-key semantics (duplicates are refused
1220
+ // upstream anyway, so in practice there is exactly one).
1221
+ orderDesc.set(ocol, isOrderBySpec(od) ? od.sort === 'desc' : od === 'desc');
1222
+ }
1223
+ const entries = sortedEntries(cursor)
1224
+ .filter(([, v]) => v !== undefined)
1225
+ .map(([k, value]) => {
1226
+ const column = this.toColumn(k);
1227
+ return { column, value, desc: orderDesc.get(column) === true };
1228
+ });
1229
+ // Stable sort over the canonical (sorted-key) list: orderBy precedence
1230
+ // first, unnamed fields after it in their sorted order.
1231
+ return entries.sort((a, b) => (orderPos.get(a.column) ?? Number.MAX_SAFE_INTEGER) - (orderPos.get(b.column) ?? Number.MAX_SAFE_INTEGER));
1232
+ }
1233
+ /**
1234
+ * The keyset predicate for cursor terms in keyset order.
1235
+ *
1236
+ * One field is the plain `col > $1` (byte-identical to every single-field
1237
+ * cursor ever emitted). Two or more expand to
1238
+ * `(c1 > $1 OR (c1 = $1 AND c2 > $2) OR (c1 = $1 AND c2 = $2 AND c3 > $3))`,
1239
+ * with `<` for a `desc` field: the row-value form `(c1, c2) > ($1, $2)` is
1240
+ * shorter, but it cannot express a MIXED direction at all and SQL Server has
1241
+ * no row-value comparison, so the expanded form is the one path every engine
1242
+ * and every direction set share. Each value is bound ONCE and its `$n` is
1243
+ * referenced by every branch that needs it (legal on every supported
1244
+ * placeholder syntax), which is what keeps the param count equal to the field
1245
+ * count on both the build and the collect side.
1246
+ *
1247
+ * The former `c1 > $1 AND c2 > $2` was a conjunction, not a seek: it skipped
1248
+ * every row whose leading key EQUALED the cursor's, so a table with few
1249
+ * distinct leading values lost most of its rows across a page walk, silently.
1250
+ */
1251
+ keysetPredicate(terms) {
1252
+ const compare = (t) => `${t.column} ${t.desc ? '<' : '>'} ${t.placeholder}`;
1253
+ if (terms.length === 1)
1254
+ return compare(terms[0]);
1255
+ const branches = terms.map((term, i) => {
1256
+ if (i === 0)
1257
+ return compare(term);
1258
+ const prefix = terms.slice(0, i).map((t) => `${t.column} = ${t.placeholder}`);
1259
+ return `(${[...prefix, compare(term)].join(' AND ')})`;
1260
+ });
1261
+ return `(${branches.join(' OR ')})`;
1262
+ }
1182
1263
  /**
1183
1264
  * Whether a findMany paginates (`limit` / `take` / `offset` / `cursor`) but
1184
1265
  * declares no ordering, which makes the returned page NON-DETERMINISTIC:
@@ -1663,17 +1744,27 @@ export class QueryInterface {
1663
1744
  // Query-level opt-in threaded onto every follow-up child `buildFindMany`,
1664
1745
  // so a batched load excludes/includes PII exactly as the join strategy.
1665
1746
  includePii,
1666
- tableGlobalFilter: (table, alias, precedingParams) => {
1747
+ // `ref` is the follow-up query's FROM-item AS THE SQL REFERS TO IT: the
1748
+ // quoted child table for a hasMany `_count`, the bare junction alias `t`
1749
+ // for the m2m one. Compiled through the rendered-reference scope, which
1750
+ // uses it verbatim for the column qualifier and for a nested relation
1751
+ // filter's correlation parent alike. It went through the BARE-alias scope
1752
+ // before, which quotes what it is given, so a `globalFilters` entry that
1753
+ // was itself a relation filter emitted `"""posts"""` into the EXISTS
1754
+ // body and the count failed 42P01. Reproduced live; the plain batched
1755
+ // relation fetch was never affected, because that one goes through the
1756
+ // child's own buildFindMany and merges the filter unqualified.
1757
+ tableGlobalFilter: (table, ref, precedingParams) => {
1667
1758
  const gf = this.resolveGlobalFilter(table, resolvedSkip);
1668
1759
  if (!gf)
1669
1760
  return null;
1670
1761
  const meta = this.schema.tables[table];
1671
1762
  if (!meta)
1672
1763
  return null;
1673
- // Seed the param array with `precedingParams` placeholders so
1674
- // buildAliasWhere numbers the gf params after the already-bound ones.
1764
+ // Seed the param array with `precedingParams` placeholders so the
1765
+ // builder numbers the gf params after the already-bound ones.
1675
1766
  const seeded = new Array(precedingParams).fill(undefined);
1676
- const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
1767
+ const clause = whereMod.buildRenderedRefWhere(this.ctx, table, meta, ref, gf, seeded);
1677
1768
  if (!clause)
1678
1769
  return null;
1679
1770
  return { clause, params: seeded.slice(precedingParams) };
@@ -2697,10 +2788,12 @@ export class QueryInterface {
2697
2788
  })
2698
2789
  .join(',')
2699
2790
  : '';
2791
+ // Keyset order AND per-field direction: a multi-field cursor's predicate
2792
+ // is one OR-branch per field in orderBy precedence, and `<` / `>` are SQL
2793
+ // text, so `asc` and `desc` (or two precedences) can never share a template.
2700
2794
  const cursorFp = args?.cursor
2701
- ? Object.keys(args.cursor)
2702
- .filter((k) => args.cursor[k] !== undefined)
2703
- .sort()
2795
+ ? this.cursorSeekEntries(args.cursor, args.orderBy)
2796
+ .map((e) => `${e.column}:${e.desc ? 'd' : 'a'}`)
2704
2797
  .join(',')
2705
2798
  : '';
2706
2799
  // distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
@@ -2810,39 +2903,16 @@ export class QueryInterface {
2810
2903
  // where → cursor order (the collect path mirrors this exactly).
2811
2904
  let tail = freshWhereSql;
2812
2905
  if (args?.cursor) {
2813
- // Sorted (canonical) order, MUST match cursorFp and the cache-hit collect below.
2814
- const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
2815
- if (cursorEntries.length > 0) {
2816
- // Resolve the seek direction per cursor field from the flattened
2817
- // orderBy entries (last wins, matching object-key semantics), so both
2818
- // the object and array orderBy forms drive the cursor comparison.
2819
- //
2820
- // Indexed by the RESOLVED COLUMN, never the caller's key: both
2821
- // `cursor` and `orderBy` take either spelling, so a cursor written
2822
- // `{ created_at }` against `orderBy: { createdAt: 'desc' }` missed
2823
- // this lookup, defaulted to ascending, and emitted `created_at > $n`
2824
- // under `ORDER BY created_at DESC` — the wrong page, silently. Same
2825
- // failure the `{ sort, nulls }` normalization below prevents, reached
2826
- // through the spelling instead of the value shape. A relation /
2827
- // JSON-path / vector key resolves to no column and is skipped.
2828
- const orderDirByColumn = new Map();
2829
- for (const [ok, od] of orderByEntries(args.orderBy)) {
2830
- const ocol = resolveColumnName(this.tableMeta, ok);
2831
- if (ocol !== undefined)
2832
- orderDirByColumn.set(ocol, od);
2833
- }
2834
- const cursorConditions = cursorEntries.map(([k, v]) => {
2835
- const rawCol = this.toColumn(k);
2836
- const col = this.q(rawCol);
2837
- // orderBy values can be the { sort, nulls } spec form: normalize
2838
- // before comparing, or a desc spec would seek the ascending side.
2839
- const dir = orderDirByColumn.get(rawCol);
2840
- const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
2841
- const op = desc ? '<' : '>';
2842
- freshParams.push(v);
2843
- return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
2906
+ // Keyset order (orderBy precedence), MUST match cursorFp and the
2907
+ // cache-hit collect below: both go through cursorSeekEntries.
2908
+ const seek = this.cursorSeekEntries(args.cursor, args.orderBy);
2909
+ if (seek.length > 0) {
2910
+ const terms = seek.map((e) => {
2911
+ freshParams.push(e.value);
2912
+ return { column: `${qt}.${this.q(e.column)}`, placeholder: this.p(freshParams.length), desc: e.desc };
2844
2913
  });
2845
- tail += freshWhereSql ? ` AND ${cursorConditions.join(' AND ')}` : ` WHERE ${cursorConditions.join(' AND ')}`;
2914
+ const predicate = this.keysetPredicate(terms);
2915
+ tail += freshWhereSql ? ` AND ${predicate}` : ` WHERE ${predicate}`;
2846
2916
  }
2847
2917
  }
2848
2918
  // ORDER BY is built AFTER the cursor pushes (param order
@@ -2861,10 +2931,40 @@ export class QueryInterface {
2861
2931
  throw new ValidationError('`distinct` cannot be combined with vector distance ordering.');
2862
2932
  }
2863
2933
  const userOrder = this.buildOrderBy(args.orderBy, freshParams);
2864
- const inner = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${tail} ORDER BY ${distinctCols
2934
+ // The outer level re-orders by the user's columns, which must therefore
2935
+ // exist in the derived table. A NARROWED projection (`select`, `omit`,
2936
+ // or the PII rule, which narrows with no user projection at all) left
2937
+ // them out, so the statement failed with 42703. Every order column the
2938
+ // projection omits is projected into the INNER query under its own
2939
+ // name, and the outer SELECT then lists the projected columns
2940
+ // explicitly instead of `*`, so the extra column orders the result and
2941
+ // never reaches a row (a PII column never even crosses the wire). The
2942
+ // full projection (`columnsList === null`, `"t".*`) and a projection
2943
+ // that already covers the order keeps the `SELECT *` wrapper byte for
2944
+ // byte. The user's order is rendered ONCE (params pushed once, as the
2945
+ // collect path mirrors) and its text reused at both levels.
2946
+ let innerSelect = selectClause;
2947
+ let outerSelect = '*';
2948
+ if (columnsList) {
2949
+ const projected = new Set(columnsList);
2950
+ const extras = [];
2951
+ for (const [key] of orderByEntries(args.orderBy)) {
2952
+ // Relation and vector ordering are refused above, so every key
2953
+ // names a column of this table (a JSON-path entry's key is its
2954
+ // column); buildOrderBy has already validated it.
2955
+ const col = this.toColumn(key);
2956
+ if (!projected.has(col) && !extras.includes(col))
2957
+ extras.push(col);
2958
+ }
2959
+ if (extras.length > 0) {
2960
+ innerSelect = `${selectClause}, ${extras.map((c) => `${qt}.${this.q(c)}`).join(', ')}`;
2961
+ outerSelect = this.distinctOuterSelectList(columnsList, args.with).join(', ');
2962
+ }
2963
+ }
2964
+ const inner = `SELECT ${distinctPrefix}${innerSelect} FROM ${qt}${tail} ORDER BY ${distinctCols
2865
2965
  .map((c) => `${c} ASC`)
2866
2966
  .join(', ')}, ${userOrder}`;
2867
- sql = `SELECT * FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
2967
+ sql = `SELECT ${outerSelect} FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
2868
2968
  }
2869
2969
  else {
2870
2970
  // Pass freshParams so vector KNN ordering binds its `$n::vector` query
@@ -2899,11 +2999,11 @@ export class QueryInterface {
2899
2999
  if (args?.with) {
2900
3000
  this.collectWithParams(args.with, params, undefined, flattenPlan);
2901
3001
  }
2902
- // 3. Cursor params, sorted (canonical) order, matching cursorFp and the build path.
3002
+ // 3. Cursor params, keyset order, matching cursorFp and the build path
3003
+ // (each value binds ONCE; the expanded predicate re-references its `$n`).
2903
3004
  if (args?.cursor) {
2904
- const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
2905
- for (const [, v] of cursorEntries) {
2906
- params.push(v);
3005
+ for (const e of this.cursorSeekEntries(args.cursor, args.orderBy)) {
3006
+ params.push(e.value);
2907
3007
  }
2908
3008
  }
2909
3009
  // 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
@@ -3682,6 +3782,36 @@ export class QueryInterface {
3682
3782
  toSqlColumn(field) {
3683
3783
  return this.q(this.toColumn(field));
3684
3784
  }
3785
+ /**
3786
+ * The explicit outer SELECT list of the `distinct` + `orderBy` wrapper, used
3787
+ * only when the inner derived table had to carry ORDER BY columns the
3788
+ * projection leaves out (see the distinct branch of {@link buildFindMany}):
3789
+ * the projected base columns, then one column per `with` relation, then one
3790
+ * per counted relation. These are the aliases `buildSelectWithRelations`
3791
+ * (relations.ts) gives the same columns, `AS "<relName>"` per entry in sorted
3792
+ * order and `AS "_count__<rel>"` per counted relation, over the SAME
3793
+ * `resolveCountRelations` it uses, so the two lists name the same columns.
3794
+ * `flatten` never reaches here (the strategy is gated off under `distinct`),
3795
+ * so no prefixed scalar aliases exist to enumerate. A drift between the two
3796
+ * would fail LOUDLY, as a 42703 on the outer list, never as a silently wrong
3797
+ * row, and the live distinct test pins the `with` + `_count` shape.
3798
+ */
3799
+ distinctOuterSelectList(columnsList, withClause) {
3800
+ const list = columnsList.map((c) => this.q(c));
3801
+ if (!withClause)
3802
+ return list;
3803
+ for (const [relName] of sortedEntries(withClause)) {
3804
+ if (relName === '_count')
3805
+ continue;
3806
+ list.push(this.q(relName));
3807
+ }
3808
+ const countSpec = withClause._count;
3809
+ if (countSpec !== undefined) {
3810
+ for (const rel of resolveCountRelations(this.tableMeta, countSpec))
3811
+ list.push(this.q(`_count__${rel.name}`));
3812
+ }
3813
+ return list;
3814
+ }
3685
3815
  // =========================================================================
3686
3816
  // Fingerprinting, value-invariant shape keys for SQL cache lookup
3687
3817
  // =========================================================================
@@ -77,7 +77,70 @@ export declare function expandCompoundUniqueWhere(meta: TableMetadata, where: Re
77
77
  * person cannot take.
78
78
  */
79
79
  export declare function assertWhereIdentifiesOneRow(meta: TableMetadata, table: string, where: Record<string, unknown> | undefined): void;
80
+ /**
81
+ * The same refusal for the single-row WRITE methods, `update` and `delete`, and
82
+ * for `upsert`, whose `where` carries the same one-row contract by a different
83
+ * mechanism.
84
+ *
85
+ * `update` / `delete` emit `... WHERE <predicate> RETURNING *` and hand back
86
+ * `rows[0]`, so a `where` that matches many rows mutates EVERY one of them and
87
+ * reports one: the `findUnique` hazard (an arbitrary one of many) with a write
88
+ * attached. `upsert`'s `where` is not a predicate at all, it IS the conflict
89
+ * target, so a non-unique one has no single row to update on conflict and the
90
+ * emitted `ON CONFLICT (...)` names columns no unique index backs. Same rule,
91
+ * same sources of uniqueness, same null policy as
92
+ * {@link assertWhereIdentifiesOneRow}; only the sentence differs, because the
93
+ * consequence and the fix differ per operation. A reader who meant one row
94
+ * names a key; a reader who meant every matching row has `updateMany` /
95
+ * `deleteMany`, which report `{ count }` and never pretend to have touched one
96
+ * row.
97
+ *
98
+ * Called on the CALLER's where, before any global filter is merged in (a
99
+ * tenancy filter narrows, it does not identify). `update` / `delete` skip it
100
+ * under `allowFullTableScan: UNSAFE`, which already says "every row" in so many
101
+ * words, and their message names that hatch: a predicate CAN identify one row
102
+ * through a constraint this schema's metadata does not carry (a stale generated
103
+ * `metadata.ts`, a `defineSchema` that declares fewer uniques than the database
104
+ * has), and a rule with no way past it turns that into an unreachable method.
105
+ * `upsert` has no such option and its message therefore offers none.
106
+ */
107
+ export declare function assertMutationWhereIdentifiesOneRow(meta: TableMetadata, table: string, where: Record<string, unknown> | undefined, operation: 'update' | 'delete' | 'upsert'): void;
80
108
  export declare function uniqueKeyNames(meta: TableMetadata): string[][];
109
+ /**
110
+ * Brands a predicate Turbine wrote ENTIRELY itself to address the one row a
111
+ * DECLARED to-one relation points at, so the single-row write rule accepts it.
112
+ *
113
+ * A nested `disconnect: true` / `delete: true` on a `hasOne`, and a nested
114
+ * `update` on a `belongsTo` (whose argument is `{ data }`, with no `where` at
115
+ * all), give the caller no selector to write. The engine builds the predicate
116
+ * from the relation's own correlation key, and the thing that makes it one row
117
+ * is the relation's declared CARDINALITY, which is not in `primaryKey` /
118
+ * `uniqueColumns` / `indexes` and so is invisible to
119
+ * {@link whereIdentifiesOneRow}. Without this the rule refused those writes
120
+ * with "Name a unique key", advice the caller cannot take because they never
121
+ * wrote a `where`, on every schema whose `hasOne` FK is not ALSO declared
122
+ * unique: the `defineSchema` code-first path, the PowDB path, and any FK backed
123
+ * only by a partial unique index.
124
+ *
125
+ * Turbine believes a declared `hasOne` everywhere else (it reads the relation
126
+ * as an object rather than an array, and emits `LIMIT 1` for it), so believing
127
+ * it here is consistency, not a hole. The brand is applied at the one place the
128
+ * predicate is synthesized (`scopeWhereToParent` / the belongsTo correlation in
129
+ * nested-write.ts) and never to anything a caller supplied.
130
+ *
131
+ * A distinct Symbol rather than a second meaning for `markInternalCombinator`:
132
+ * that brand exists to keep a fixed-arity wrapper on a NAMED prepared
133
+ * statement, and folding "identifies one row" into it would mean any future
134
+ * fixed-arity wrapper silently switched this rule off. `Symbol.for` for the
135
+ * ESM/CJS cross-copy identity reason `INTERNAL_COMBINATOR` documents, and a
136
+ * SYMBOL so the `Object.keys` every where walker enumerates never sees it and
137
+ * no emitted SQL can change.
138
+ */
139
+ export declare const INTERNAL_ROW_SELECTOR: unique symbol;
140
+ /** Tag `where` as an engine-written single-row selector, and return it. */
141
+ export declare function markInternalRowSelector<T extends object>(where: T): T;
142
+ /** Did Turbine itself write this predicate to address one declared related row? */
143
+ export declare function isInternalRowSelector(where: unknown): boolean;
81
144
  /**
82
145
  * True when `where` pins every column of at least one unique key to a single
83
146
  * value, so the row it names is the row it gets.
@@ -91,5 +154,18 @@ export declare function uniqueKeyNames(meta: TableMetadata): string[][];
91
154
  * A NULL is not an identity. `WHERE email IS NULL` matches every row whose
92
155
  * email is null, which a UNIQUE constraint permits any number of, so a null
93
156
  * value satisfies no key here even on a unique column.
157
+ *
158
+ * The one combinator it does read through is Turbine's OWN: a nested write
159
+ * scopes a child selector to its parent by merging the parent correlation in,
160
+ * and when the two name the same column the merge is `{ AND: [selector,
161
+ * correlation] }` BRANDED via `markInternalCombinator` (query/utils.ts). That
162
+ * brand is a Symbol no request body can produce, the arity is fixed at two,
163
+ * and each branch only narrows the other, so the wrapper identifies a row
164
+ * exactly when one of its branches does. A caller-written `AND` is not read.
165
+ *
166
+ * The other thing it reads is {@link markInternalRowSelector}: a predicate the
167
+ * ENGINE wrote in full, for which there is no caller `where` this rule could be
168
+ * about. See that function for why the declaration, not the metadata, is the
169
+ * uniqueness source there.
94
170
  */
95
171
  export declare function whereIdentifiesOneRow(meta: TableMetadata, where: Record<string, unknown>): boolean;
@@ -40,7 +40,7 @@
40
40
  */
41
41
  import { ValidationError } from '../errors.js';
42
42
  import { isArrayFilter, isJsonFilter, isVectorFilter, isWhereOperator } from './filters.js';
43
- import { ownLookup, resolveColumnName, resolveRelationDef } from './utils.js';
43
+ import { isInternalCombinator, ownLookup, resolveColumnName, resolveRelationDef } from './utils.js';
44
44
  const syntheticKeyCache = new WeakMap();
45
45
  /**
46
46
  * Build (once per table) the map from a synthetic selector name to the ordered
@@ -239,6 +239,70 @@ export function assertWhereIdentifiesOneRow(meta, table, where) {
239
239
  throw new ValidationError(`findUnique on "${table}" refused: the \`where\` clause does not identify a single row, ` +
240
240
  `so this would return an arbitrary one of the rows that match. ${advice}`);
241
241
  }
242
+ /**
243
+ * The escape hatch sentence, on `update` / `delete` only.
244
+ *
245
+ * Stated with its cost rather than as an option to reach for: the same flag
246
+ * turns off the empty-`where` guard, so a predicate that is merely NARROW today
247
+ * and becomes EMPTY tomorrow (every value `undefined` on a request that omitted
248
+ * them) writes the whole table instead of being refused.
249
+ */
250
+ const UNSAFE_ESCAPE = 'If this predicate really does identify one row through a constraint this schema does not describe, ' +
251
+ '`allowFullTableScan: UNSAFE` writes it anyway, at the cost of the empty-`where` guard as well: an ' +
252
+ 'all-`undefined` `where` then matches every row instead of being refused.';
253
+ /**
254
+ * The same refusal for the single-row WRITE methods, `update` and `delete`, and
255
+ * for `upsert`, whose `where` carries the same one-row contract by a different
256
+ * mechanism.
257
+ *
258
+ * `update` / `delete` emit `... WHERE <predicate> RETURNING *` and hand back
259
+ * `rows[0]`, so a `where` that matches many rows mutates EVERY one of them and
260
+ * reports one: the `findUnique` hazard (an arbitrary one of many) with a write
261
+ * attached. `upsert`'s `where` is not a predicate at all, it IS the conflict
262
+ * target, so a non-unique one has no single row to update on conflict and the
263
+ * emitted `ON CONFLICT (...)` names columns no unique index backs. Same rule,
264
+ * same sources of uniqueness, same null policy as
265
+ * {@link assertWhereIdentifiesOneRow}; only the sentence differs, because the
266
+ * consequence and the fix differ per operation. A reader who meant one row
267
+ * names a key; a reader who meant every matching row has `updateMany` /
268
+ * `deleteMany`, which report `{ count }` and never pretend to have touched one
269
+ * row.
270
+ *
271
+ * Called on the CALLER's where, before any global filter is merged in (a
272
+ * tenancy filter narrows, it does not identify). `update` / `delete` skip it
273
+ * under `allowFullTableScan: UNSAFE`, which already says "every row" in so many
274
+ * words, and their message names that hatch: a predicate CAN identify one row
275
+ * through a constraint this schema's metadata does not carry (a stale generated
276
+ * `metadata.ts`, a `defineSchema` that declares fewer uniques than the database
277
+ * has), and a rule with no way past it turns that into an unreachable method.
278
+ * `upsert` has no such option and its message therefore offers none.
279
+ */
280
+ export function assertMutationWhereIdentifiesOneRow(meta, table, where, operation) {
281
+ if (whereIdentifiesOneRow(meta, where ?? {}))
282
+ return;
283
+ const keys = describeUniqueKeys(meta);
284
+ if (operation === 'upsert') {
285
+ const advice = keys.length > 0
286
+ ? `Name a unique key (${keys.join(', ')}).`
287
+ : `Table "${table}" declares no primary key and no unique constraint, so no \`where\` can identify one row ` +
288
+ 'here and no upsert is possible on it: insert with `create`, or change matching rows with `updateMany`.';
289
+ throw new ValidationError(`upsert on "${table}" refused: the \`where\` clause does not identify a single row. An upsert's \`where\` IS ` +
290
+ 'its conflict target, so there is no one row to update on conflict, and the `ON CONFLICT` this would emit ' +
291
+ `names columns no unique constraint backs. ${advice}`);
292
+ }
293
+ const many = operation === 'update' ? 'updateMany' : 'deleteMany';
294
+ const advice = keys.length > 0
295
+ ? `Name a unique key (${keys.join(', ')}), or use \`${many}\` if you meant "every row matching a filter".`
296
+ : `Table "${table}" declares no primary key and no unique constraint, so no \`where\` can identify one row ` +
297
+ `here. Use \`${many}\`, which reports how many rows it touched.`;
298
+ throw new ValidationError(`${operation} on "${table}" refused: the \`where\` clause does not identify a single row, ` +
299
+ `so this would ${operation} every row that matches and return only one of them. ${advice} ${UNSAFE_ESCAPE}`);
300
+ }
301
+ /** Each unique key of `meta` rendered in field spelling, for an error message. */
302
+ function describeUniqueKeys(meta) {
303
+ const field = (c) => meta.reverseColumnMap[c] ?? c;
304
+ return uniqueKeyNames(meta).map((cols) => cols.length === 1 ? `\`${field(cols[0])}\`` : `\`{ ${cols.map(field).join(', ')} }\``);
305
+ }
242
306
  export function uniqueKeyNames(meta) {
243
307
  return dedupeColumnSets(uniqueColumnSets(meta));
244
308
  }
@@ -268,6 +332,49 @@ function uniqueColumnSets(meta) {
268
332
  }
269
333
  return sets;
270
334
  }
335
+ // ---------------------------------------------------------------------------
336
+ // Engine-addressed row selectors
337
+ // ---------------------------------------------------------------------------
338
+ /**
339
+ * Brands a predicate Turbine wrote ENTIRELY itself to address the one row a
340
+ * DECLARED to-one relation points at, so the single-row write rule accepts it.
341
+ *
342
+ * A nested `disconnect: true` / `delete: true` on a `hasOne`, and a nested
343
+ * `update` on a `belongsTo` (whose argument is `{ data }`, with no `where` at
344
+ * all), give the caller no selector to write. The engine builds the predicate
345
+ * from the relation's own correlation key, and the thing that makes it one row
346
+ * is the relation's declared CARDINALITY, which is not in `primaryKey` /
347
+ * `uniqueColumns` / `indexes` and so is invisible to
348
+ * {@link whereIdentifiesOneRow}. Without this the rule refused those writes
349
+ * with "Name a unique key", advice the caller cannot take because they never
350
+ * wrote a `where`, on every schema whose `hasOne` FK is not ALSO declared
351
+ * unique: the `defineSchema` code-first path, the PowDB path, and any FK backed
352
+ * only by a partial unique index.
353
+ *
354
+ * Turbine believes a declared `hasOne` everywhere else (it reads the relation
355
+ * as an object rather than an array, and emits `LIMIT 1` for it), so believing
356
+ * it here is consistency, not a hole. The brand is applied at the one place the
357
+ * predicate is synthesized (`scopeWhereToParent` / the belongsTo correlation in
358
+ * nested-write.ts) and never to anything a caller supplied.
359
+ *
360
+ * A distinct Symbol rather than a second meaning for `markInternalCombinator`:
361
+ * that brand exists to keep a fixed-arity wrapper on a NAMED prepared
362
+ * statement, and folding "identifies one row" into it would mean any future
363
+ * fixed-arity wrapper silently switched this rule off. `Symbol.for` for the
364
+ * ESM/CJS cross-copy identity reason `INTERNAL_COMBINATOR` documents, and a
365
+ * SYMBOL so the `Object.keys` every where walker enumerates never sees it and
366
+ * no emitted SQL can change.
367
+ */
368
+ export const INTERNAL_ROW_SELECTOR = Symbol.for('turbine.internalRowSelector');
369
+ /** Tag `where` as an engine-written single-row selector, and return it. */
370
+ export function markInternalRowSelector(where) {
371
+ Object.defineProperty(where, INTERNAL_ROW_SELECTOR, { value: true, enumerable: false, configurable: true });
372
+ return where;
373
+ }
374
+ /** Did Turbine itself write this predicate to address one declared related row? */
375
+ export function isInternalRowSelector(where) {
376
+ return (typeof where === 'object' && where !== null && where[INTERNAL_ROW_SELECTOR] === true);
377
+ }
271
378
  /**
272
379
  * True when `where` pins every column of at least one unique key to a single
273
380
  * value, so the row it names is the row it gets.
@@ -281,8 +388,26 @@ function uniqueColumnSets(meta) {
281
388
  * A NULL is not an identity. `WHERE email IS NULL` matches every row whose
282
389
  * email is null, which a UNIQUE constraint permits any number of, so a null
283
390
  * value satisfies no key here even on a unique column.
391
+ *
392
+ * The one combinator it does read through is Turbine's OWN: a nested write
393
+ * scopes a child selector to its parent by merging the parent correlation in,
394
+ * and when the two name the same column the merge is `{ AND: [selector,
395
+ * correlation] }` BRANDED via `markInternalCombinator` (query/utils.ts). That
396
+ * brand is a Symbol no request body can produce, the arity is fixed at two,
397
+ * and each branch only narrows the other, so the wrapper identifies a row
398
+ * exactly when one of its branches does. A caller-written `AND` is not read.
399
+ *
400
+ * The other thing it reads is {@link markInternalRowSelector}: a predicate the
401
+ * ENGINE wrote in full, for which there is no caller `where` this rule could be
402
+ * about. See that function for why the declaration, not the metadata, is the
403
+ * uniqueness source there.
284
404
  */
285
405
  export function whereIdentifiesOneRow(meta, where) {
406
+ if (isInternalRowSelector(where))
407
+ return true;
408
+ if (isInternalCombinator(where) && Array.isArray(where.AND)) {
409
+ return where.AND.some((branch) => typeof branch === 'object' && branch !== null && whereIdentifiesOneRow(meta, branch));
410
+ }
286
411
  const pinned = new Set();
287
412
  for (const [key, value] of Object.entries(where)) {
288
413
  if (!isPinnedToOneValue(value))
@@ -5,7 +5,7 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, GroupByResult, HavingClause, JsonEncoding, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, ResolvedSkipGlobalFilters, SelectResult, SkipGlobalFilters, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithOrderByObject, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, GroupByResult, HavingClause, JsonEncoding, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderBySpec, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationOrderBy, RelationOrderByChain, RelationPickBy, RelationPickOrderBy, ResolvedSkipGlobalFilters, SelectResult, SkipGlobalFilters, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithOrderByObject, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { OptionKind, OptionTable } from './option-surface.js';