turbine-orm 0.73.1 → 0.74.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.
@@ -196,7 +196,25 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
196
196
  * makes a core rule arrive on its own, and an engine that reads `take` but
197
197
  * not `skip` pages differently from one that reads both.
198
198
  */
199
+ /**
200
+ * Fill in the deterministic per-relation ordering `stableRelationOrder` asks
201
+ * for, through the shared transform the SQL engines use.
202
+ *
203
+ * PowDB accepted this option and did nothing with it before 0.74.0, so
204
+ * relation rows came back in whatever order the engine produced, from the one
205
+ * option whose entire purpose is that they do not. Same resolution as
206
+ * QueryInterface: a per-query flag wins, else the client-level default.
207
+ */
208
+ private withStableRelationOrder;
199
209
  private normalizeArgs;
210
+ /**
211
+ * `jsonEncoding` selects between PostgreSQL's `json_build_object` and
212
+ * `json_build_array` row encodings for relation subqueries. PowQL emits no
213
+ * JSON row encoding at all, so there is nothing for the option to select and
214
+ * it was simply dropped: the caller asked for a wire format and got silence.
215
+ * Refused for the same reason `forceCustomPlan` is.
216
+ */
217
+ private assertNoJsonEncoding;
200
218
  private assertNoForceCustomPlan;
201
219
  /** See query/compound-unique.ts: one rule and one message across engines. */
202
220
  private assertIdentifiesOneRow;
@@ -883,5 +901,25 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
883
901
  * `{ OR: [{ f: undefined }] }`, compiles to the empty string and is refused,
884
902
  * because emitting a filter-less write would hit every row.
885
903
  */
904
+ /**
905
+ * AND this table's configured global filter (soft-delete / multi-tenancy)
906
+ * onto an ALREADY-COMPILED user filter, pushing its params after the user's.
907
+ *
908
+ * Before 0.74.0 `PowqlInterface` did not read `globalFilters` at all, so a
909
+ * client configured for multi-tenancy applied its tenant predicate on every
910
+ * SQL engine and NONE on PowDB, silently, and `skipGlobalFilters` was
911
+ * accepted and inert. The rule itself is shared
912
+ * ({@link resolveGlobalFilterFrom}) rather than restated here.
913
+ *
914
+ * It takes a COMPILED user filter, and that ordering is the contract, not an
915
+ * implementation detail: the empty-`where` guard must see the USER filter
916
+ * alone, or a configured global filter would silently satisfy it and turn a
917
+ * refused mass mutation into an accepted one. Every write call site therefore
918
+ * calls {@link assertCompiledWhere} first and this second.
919
+ *
920
+ * `alias` is forwarded so the nested/native-join paths qualify the filter's
921
+ * column references the same way they qualify the user's.
922
+ */
923
+ private applyGlobalFilter;
886
924
  private assertCompiledWhere;
887
925
  }
package/dist/cjs/powql.js CHANGED
@@ -430,8 +430,38 @@ class PowqlInterface {
430
430
  * makes a core rule arrive on its own, and an engine that reads `take` but
431
431
  * not `skip` pages differently from one that reads both.
432
432
  */
433
+ /**
434
+ * Fill in the deterministic per-relation ordering `stableRelationOrder` asks
435
+ * for, through the shared transform the SQL engines use.
436
+ *
437
+ * PowDB accepted this option and did nothing with it before 0.74.0, so
438
+ * relation rows came back in whatever order the engine produced, from the one
439
+ * option whose entire purpose is that they do not. Same resolution as
440
+ * QueryInterface: a per-query flag wins, else the client-level default.
441
+ */
442
+ withStableRelationOrder(args) {
443
+ if (!args?.with)
444
+ return args;
445
+ const on = args.stableRelationOrder ?? this.options.stableRelationOrder === true;
446
+ if (!on)
447
+ return args;
448
+ const ordered = (0, relation_names_js_1.applyStableRelationOrderTo)(this.schema, args.with, this.table);
449
+ return ordered === args.with ? args : { ...args, with: ordered };
450
+ }
433
451
  normalizeArgs(args) {
434
- return (0, utils_js_1.normalizePagination)(this.withDeclaredRelationNames(args));
452
+ return (0, utils_js_1.normalizePagination)(this.withStableRelationOrder(this.withDeclaredRelationNames(args)));
453
+ }
454
+ /**
455
+ * `jsonEncoding` selects between PostgreSQL's `json_build_object` and
456
+ * `json_build_array` row encodings for relation subqueries. PowQL emits no
457
+ * JSON row encoding at all, so there is nothing for the option to select and
458
+ * it was simply dropped: the caller asked for a wire format and got silence.
459
+ * Refused for the same reason `forceCustomPlan` is.
460
+ */
461
+ assertNoJsonEncoding(args) {
462
+ if (args?.jsonEncoding === undefined)
463
+ return;
464
+ throw new errors_js_1.UnsupportedFeatureError('jsonEncoding', 'PowDB', 'PowQL emits no JSON row encoding for relations, so there is nothing to select; omit the option');
435
465
  }
436
466
  assertNoForceCustomPlan(args) {
437
467
  if (args?.forceCustomPlan !== true)
@@ -1386,6 +1416,7 @@ class PowqlInterface {
1386
1416
  // -------------------------------------------------------------------------
1387
1417
  async findMany(args = {}) {
1388
1418
  this.assertNoForceCustomPlan(args);
1419
+ this.assertNoJsonEncoding(args);
1389
1420
  args = this.normalizeArgs(args);
1390
1421
  return this.withMiddleware('findMany', args, async () => {
1391
1422
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
@@ -1467,7 +1498,8 @@ class PowqlInterface {
1467
1498
  }
1468
1499
  const nest = nestedPlans.length > 0 || linkPlans.length > 0;
1469
1500
  const alias = nest ? 't0' : undefined;
1470
- const where = this.buildWhere(resolvedWhere, params, alias);
1501
+ let where = this.buildWhere(resolvedWhere, params, alias);
1502
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters, alias);
1471
1503
  // PowQL's `distinct` is row-wide, so these names never reach the emitted
1472
1504
  // statement. They are still caller-supplied names, and a name resolves or
1473
1505
  // throws: reading the array for its LENGTH alone let `distinct: ['nope']`
@@ -1544,6 +1576,7 @@ class PowqlInterface {
1544
1576
  }
1545
1577
  async findUnique(args) {
1546
1578
  this.assertNoForceCustomPlan(args);
1579
+ this.assertNoJsonEncoding(args);
1547
1580
  args = this.normalizeArgs(args);
1548
1581
  // Prisma compound-unique selector → column conjunction (engine parity with
1549
1582
  // the SQL findUnique family; pure metadata, so this is a one-line adoption).
@@ -2638,14 +2671,41 @@ class PowqlInterface {
2638
2671
  }
2639
2672
  const params = [];
2640
2673
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2641
- const where = this.buildWhere(resolvedWhere, params);
2642
- this.assertCompiledWhere(where, false, 'update');
2643
- const setClause = this.buildUpdateAssignments(args.data, params);
2674
+ let where = this.buildWhere(resolvedWhere, params);
2675
+ // `false` here refused an empty where even WITH the opt-in, while every
2676
+ // SQL engine accepted it: verified by probe on buildUpdate/buildDelete.
2677
+ this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'update');
2678
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2679
+ let setClause = this.buildUpdateAssignments(args.data, params);
2680
+ // Optimistic locking, matching the SQL engines exactly: bump the version
2681
+ // column in the SET, add `version = expected` to the filter, and treat
2682
+ // "matched no row" as a lost race rather than a missing row.
2683
+ //
2684
+ // PowqlInterface ignored `optimisticLock` outright before 0.74.0, which is
2685
+ // worse than not supporting it: the update applied, the caller's version
2686
+ // check never happened, and a concurrent writer's change was overwritten
2687
+ // by a caller who believed they held the lock.
2688
+ const lock = args.optimisticLock;
2689
+ if (lock) {
2690
+ const versionCol = (0, powdb_js_1.quotePowqlIdent)(this.column(lock.field).name);
2691
+ setClause += `, ${versionCol} := ${this.ref(lock.field)} + 1`;
2692
+ params.push(lock.expected);
2693
+ const check = `${this.ref(lock.field)} = $${params.length}`;
2694
+ where = where ? `(${where}) and (${check})` : check;
2695
+ }
2644
2696
  // `returning` hands back the post-update row(s); take the first (single-row contract).
2645
2697
  const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
2646
2698
  const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
2647
- if (!row)
2699
+ if (!row) {
2700
+ if (lock) {
2701
+ throw new errors_js_1.OptimisticLockError({
2702
+ table: this.table,
2703
+ versionField: lock.field,
2704
+ expectedVersion: lock.expected,
2705
+ });
2706
+ }
2648
2707
  throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
2708
+ }
2649
2709
  return row;
2650
2710
  });
2651
2711
  }
@@ -2653,8 +2713,9 @@ class PowqlInterface {
2653
2713
  return this.withMiddleware('updateMany', args, async () => {
2654
2714
  const params = [];
2655
2715
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2656
- const where = this.buildWhere(resolvedWhere, params);
2716
+ let where = this.buildWhere(resolvedWhere, params);
2657
2717
  this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'updateMany');
2718
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2658
2719
  const setClause = this.buildUpdateAssignments(args.data, params);
2659
2720
  const filter = where ? ` filter ${where}` : '';
2660
2721
  const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
@@ -2789,8 +2850,9 @@ class PowqlInterface {
2789
2850
  return this.withMiddleware('delete', args, async () => {
2790
2851
  const params = [];
2791
2852
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2792
- const where = this.buildWhere(resolvedWhere, params);
2793
- this.assertCompiledWhere(where, false, 'delete');
2853
+ let where = this.buildWhere(resolvedWhere, params);
2854
+ this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'delete');
2855
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2794
2856
  // `returning` hands back the deleted row(s), no separate pre-image reselect needed.
2795
2857
  const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
2796
2858
  const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
@@ -2803,8 +2865,9 @@ class PowqlInterface {
2803
2865
  return this.withMiddleware('deleteMany', args, async () => {
2804
2866
  const params = [];
2805
2867
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2806
- const where = this.buildWhere(resolvedWhere, params);
2868
+ let where = this.buildWhere(resolvedWhere, params);
2807
2869
  this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'deleteMany');
2870
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2808
2871
  const filter = where ? ` filter ${where}` : '';
2809
2872
  const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
2810
2873
  return { count: rowCount };
@@ -2875,7 +2938,8 @@ class PowqlInterface {
2875
2938
  return this.withMiddleware('count', (args ?? {}), async () => {
2876
2939
  const params = [];
2877
2940
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2878
- const where = this.buildWhere(resolvedWhere, params);
2941
+ let where = this.buildWhere(resolvedWhere, params);
2942
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2879
2943
  const filter = where ? ` filter ${where}` : '';
2880
2944
  const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
2881
2945
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
@@ -2916,7 +2980,8 @@ class PowqlInterface {
2916
2980
  const result = {};
2917
2981
  const filterParams = [];
2918
2982
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2919
- const where = this.buildWhere(resolvedWhere, filterParams);
2983
+ let where = this.buildWhere(resolvedWhere, filterParams);
2984
+ where = this.applyGlobalFilter(where, filterParams, args.skipGlobalFilters);
2920
2985
  const filter = where ? ` filter ${where}` : '';
2921
2986
  const scalar = async (expr) => {
2922
2987
  const params = [...filterParams];
@@ -2978,7 +3043,8 @@ class PowqlInterface {
2978
3043
  const emitNative = Boolean(this.capabilities.nativeRaw);
2979
3044
  const params = [];
2980
3045
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2981
- const where = this.buildWhere(resolvedWhere, params);
3046
+ let where = this.buildWhere(resolvedWhere, params);
3047
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2982
3048
  const filter = where ? ` filter ${where}` : '';
2983
3049
  // Result-key namespace, mirroring the SQL builder's `claimResultKey`
2984
3050
  // (query/builder.ts): a group-key / aggregate output-name collision (with
@@ -3331,6 +3397,36 @@ class PowqlInterface {
3331
3397
  * `{ OR: [{ f: undefined }] }`, compiles to the empty string and is refused,
3332
3398
  * because emitting a filter-less write would hit every row.
3333
3399
  */
3400
+ /**
3401
+ * AND this table's configured global filter (soft-delete / multi-tenancy)
3402
+ * onto an ALREADY-COMPILED user filter, pushing its params after the user's.
3403
+ *
3404
+ * Before 0.74.0 `PowqlInterface` did not read `globalFilters` at all, so a
3405
+ * client configured for multi-tenancy applied its tenant predicate on every
3406
+ * SQL engine and NONE on PowDB, silently, and `skipGlobalFilters` was
3407
+ * accepted and inert. The rule itself is shared
3408
+ * ({@link resolveGlobalFilterFrom}) rather than restated here.
3409
+ *
3410
+ * It takes a COMPILED user filter, and that ordering is the contract, not an
3411
+ * implementation detail: the empty-`where` guard must see the USER filter
3412
+ * alone, or a configured global filter would silently satisfy it and turn a
3413
+ * refused mass mutation into an accepted one. Every write call site therefore
3414
+ * calls {@link assertCompiledWhere} first and this second.
3415
+ *
3416
+ * `alias` is forwarded so the nested/native-join paths qualify the filter's
3417
+ * column references the same way they qualify the user's.
3418
+ */
3419
+ applyGlobalFilter(compiledUserWhere, params, skip, alias) {
3420
+ const gf = (0, where_js_1.resolveGlobalFilterFrom)(this.options.globalFilters, this.table, (0, types_js_1.resolveSkipGlobalFilters)(skip));
3421
+ if (!gf)
3422
+ return compiledUserWhere;
3423
+ const gfWhere = this.buildWhere(gf, params, alias);
3424
+ if (!gfWhere)
3425
+ return compiledUserWhere;
3426
+ if (!compiledUserWhere)
3427
+ return gfWhere;
3428
+ return `(${compiledUserWhere}) and (${gfWhere})`;
3429
+ }
3334
3430
  assertCompiledWhere(compiledWhere, allow, action) {
3335
3431
  if (allow === true)
3336
3432
  return;
@@ -444,14 +444,8 @@ function unlockNestedWriteTx(tx) {
444
444
  * orderBy is never overwritten while an empty `{}` / `[]` still gets the
445
445
  * synthesized PK order.
446
446
  */
447
- function isEmptyOrderBy(orderBy) {
448
- if (Array.isArray(orderBy))
449
- return orderBy.length === 0;
450
- if (orderBy && typeof orderBy === 'object') {
451
- return Object.values(orderBy).every((v) => v === undefined);
452
- }
453
- return orderBy === undefined || orderBy === null;
454
- }
447
+ // Moved to ./filters.ts in 0.74.0 so PowqlInterface consumes the SAME
448
+ // predicate rather than a second copy of it. See applyStableRelationOrderTo.
455
449
  /** The two accepted {@link JsonEncoding} values, frozen so the check is total. */
456
450
  const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
457
451
  /**
@@ -1155,46 +1149,10 @@ class QueryInterface {
1155
1149
  * nothing stable to order by, so both are left untouched.
1156
1150
  */
1157
1151
  applyStableRelationOrder(withClause, table, depth = 0) {
1158
- if (depth >= 10)
1159
- return withClause; // parity with the build depth cap
1160
- const meta = this.schema.tables[table];
1161
- if (!meta)
1162
- return withClause;
1163
- let out;
1164
- for (const [relName, spec] of Object.entries(withClause)) {
1165
- if (relName === '_count' || !spec)
1166
- continue; // `_count` is a count, not a row load
1167
- const rel = (0, utils_js_1.resolveRelationDef)(meta.relations, relName);
1168
- if (!rel)
1169
- continue; // unknown relation, let the build path surface E005
1170
- const options = spec === true ? {} : spec;
1171
- // Recurse first so a nested change alone still clones this level.
1172
- const nestedWith = options.with;
1173
- const newNested = nestedWith ? this.applyStableRelationOrder(nestedWith, rel.to, depth + 1) : undefined;
1174
- const nestedChanged = newNested !== undefined && newNested !== nestedWith;
1175
- const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
1176
- const hasOrder = options.orderBy !== undefined && !isEmptyOrderBy(options.orderBy);
1177
- let synthOrder;
1178
- if (isToMany && !hasOrder) {
1179
- const targetMeta = this.schema.tables[rel.to];
1180
- const pk = targetMeta?.primaryKey ?? [];
1181
- if (targetMeta && pk.length > 0) {
1182
- const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
1183
- synthOrder =
1184
- pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
1185
- }
1186
- }
1187
- if (!synthOrder && !nestedChanged)
1188
- continue; // nothing to change, keep the ref
1189
- out ??= { ...withClause };
1190
- const clonedSpec = { ...options };
1191
- if (synthOrder)
1192
- clonedSpec.orderBy = synthOrder;
1193
- if (nestedChanged)
1194
- clonedSpec.with = newNested;
1195
- out[relName] = clonedSpec;
1196
- }
1197
- return out ?? withClause;
1152
+ // The transform itself lives in ./relation-names.ts as of 0.74.0, so PowDB
1153
+ // runs the SAME rule instead of a second copy that accepted the option and
1154
+ // did nothing.
1155
+ return (0, relation_names_js_1.applyStableRelationOrderTo)(this.schema, withClause, table, depth);
1198
1156
  }
1199
1157
  // -------------------------------------------------------------------------
1200
1158
  // Deterministic pagination (unordered LIMIT/OFFSET)
@@ -1281,7 +1239,7 @@ class QueryInterface {
1281
1239
  return false;
1282
1240
  if (args.distinct !== undefined)
1283
1241
  return false;
1284
- if (!isEmptyOrderBy(args.orderBy))
1242
+ if (!(0, filters_js_1.isEmptyOrderBy)(args.orderBy))
1285
1243
  return false;
1286
1244
  if (args.limit !== undefined || args.offset !== undefined)
1287
1245
  return true;
@@ -2672,7 +2630,7 @@ class QueryInterface {
2672
2630
  // array conditionally end up passing `[]`. Normalized here, before the
2673
2631
  // implicit-ordering and fingerprinting steps, so every downstream path sees
2674
2632
  // one shape.
2675
- if (args?.orderBy !== undefined && isEmptyOrderBy(args.orderBy)) {
2633
+ if (args?.orderBy !== undefined && (0, filters_js_1.isEmptyOrderBy)(args.orderBy)) {
2676
2634
  args = { ...args, orderBy: undefined };
2677
2635
  }
2678
2636
  // Drop `orderBy` terms that sort by an expression an earlier term already
@@ -387,3 +387,11 @@ export declare function normalizeOrderBy(value: OrderDirection | OrderBySpec): {
387
387
  dir: 'ASC' | 'DESC';
388
388
  nulls?: 'first' | 'last';
389
389
  };
390
+ /**
391
+ * Is this `orderBy` effectively absent?
392
+ *
393
+ * `{}` and `[]` and `{ id: undefined }` all mean "no ordering asked for". Used
394
+ * by the stable-relation-order transform so an explicit (non-empty) orderBy is
395
+ * never overwritten while an empty one still gets the synthesized PK order.
396
+ */
397
+ export declare function isEmptyOrderBy(orderBy: unknown): boolean;
@@ -33,6 +33,7 @@ exports.orderKeyIdentity = orderKeyIdentity;
33
33
  exports.dedupeOrderEntries = dedupeOrderEntries;
34
34
  exports.dedupeColumnList = dedupeColumnList;
35
35
  exports.normalizeOrderBy = normalizeOrderBy;
36
+ exports.isEmptyOrderBy = isEmptyOrderBy;
36
37
  const errors_js_1 = require("../errors.js");
37
38
  const types_js_1 = require("./types.js");
38
39
  const utils_js_1 = require("./utils.js");
@@ -701,3 +702,18 @@ function normalizeOrderBy(value) {
701
702
  }
702
703
  return { dir: String(value).toLowerCase() === 'desc' ? 'DESC' : 'ASC' };
703
704
  }
705
+ /**
706
+ * Is this `orderBy` effectively absent?
707
+ *
708
+ * `{}` and `[]` and `{ id: undefined }` all mean "no ordering asked for". Used
709
+ * by the stable-relation-order transform so an explicit (non-empty) orderBy is
710
+ * never overwritten while an empty one still gets the synthesized PK order.
711
+ */
712
+ function isEmptyOrderBy(orderBy) {
713
+ if (Array.isArray(orderBy))
714
+ return orderBy.length === 0;
715
+ if (orderBy && typeof orderBy === 'object') {
716
+ return Object.values(orderBy).every((v) => v === undefined);
717
+ }
718
+ return orderBy === undefined || orderBy === null;
719
+ }
@@ -50,3 +50,21 @@ import type { WithClause } from './types.js';
50
50
  * Returns the input by reference when no key changed.
51
51
  */
52
52
  export declare function normalizeWithClause(schema: SchemaMetadata, table: string, withClause: WithClause | undefined, depth?: number): WithClause | undefined;
53
+ /**
54
+ * Fill in a deterministic `orderBy` for every to-many relation in a `with`
55
+ * clause that did not ask for one, using the target table's primary key.
56
+ *
57
+ * THE single authority for `stableRelationOrder`, on every engine. It was a
58
+ * private method on `QueryInterface` until 0.74.0, which meant PowDB accepted
59
+ * the option and did nothing with it: relation rows came back in whatever order
60
+ * the engine produced, from the option whose entire purpose is that they do
61
+ * not. Restating the rule in `powql.ts` would have been the 0.64 projection
62
+ * resolver again, so it moved here instead, into the same "normalize the whole
63
+ * `with` tree once, before anything reads it" slot as
64
+ * {@link normalizeWithClause}.
65
+ *
66
+ * Returns the input BY REFERENCE when nothing changed, so a query that already
67
+ * orders every relation allocates nothing and its SQL/fingerprint stay
68
+ * byte-identical.
69
+ */
70
+ export declare function applyStableRelationOrderTo(schema: SchemaMetadata, withClause: WithClause, table: string, depth?: number): WithClause;
@@ -44,6 +44,8 @@
44
44
  */
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.normalizeWithClause = normalizeWithClause;
47
+ exports.applyStableRelationOrderTo = applyStableRelationOrderTo;
48
+ const filters_js_1 = require("./filters.js");
47
49
  const utils_js_1 = require("./utils.js");
48
50
  /** Depth cap mirroring the builder's own, so a cyclic `with` cannot spin here. */
49
51
  const MAX_DEPTH = 12;
@@ -118,3 +120,61 @@ function normalizeCount(meta, count) {
118
120
  * already a documented single authority; wrapping them here would add a second
119
121
  * name for the same rule without removing a caller.
120
122
  */
123
+ /**
124
+ * Fill in a deterministic `orderBy` for every to-many relation in a `with`
125
+ * clause that did not ask for one, using the target table's primary key.
126
+ *
127
+ * THE single authority for `stableRelationOrder`, on every engine. It was a
128
+ * private method on `QueryInterface` until 0.74.0, which meant PowDB accepted
129
+ * the option and did nothing with it: relation rows came back in whatever order
130
+ * the engine produced, from the option whose entire purpose is that they do
131
+ * not. Restating the rule in `powql.ts` would have been the 0.64 projection
132
+ * resolver again, so it moved here instead, into the same "normalize the whole
133
+ * `with` tree once, before anything reads it" slot as
134
+ * {@link normalizeWithClause}.
135
+ *
136
+ * Returns the input BY REFERENCE when nothing changed, so a query that already
137
+ * orders every relation allocates nothing and its SQL/fingerprint stay
138
+ * byte-identical.
139
+ */
140
+ function applyStableRelationOrderTo(schema, withClause, table, depth = 0) {
141
+ if (depth >= 10)
142
+ return withClause; // parity with the build depth cap
143
+ const meta = schema.tables[table];
144
+ if (!meta)
145
+ return withClause;
146
+ let out;
147
+ for (const [relName, spec] of Object.entries(withClause)) {
148
+ if (relName === '_count' || !spec)
149
+ continue; // `_count` is a count, not a row load
150
+ const rel = (0, utils_js_1.resolveRelationDef)(meta.relations, relName);
151
+ if (!rel)
152
+ continue; // unknown relation, let the build path surface E005
153
+ const options = spec === true ? {} : spec;
154
+ // Recurse first so a nested change alone still clones this level.
155
+ const nestedWith = options.with;
156
+ const newNested = nestedWith ? applyStableRelationOrderTo(schema, nestedWith, rel.to, depth + 1) : undefined;
157
+ const nestedChanged = newNested !== undefined && newNested !== nestedWith;
158
+ const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
159
+ const hasOrder = options.orderBy !== undefined && !(0, filters_js_1.isEmptyOrderBy)(options.orderBy);
160
+ let synthOrder;
161
+ if (isToMany && !hasOrder) {
162
+ const targetMeta = schema.tables[rel.to];
163
+ const pk = targetMeta?.primaryKey ?? [];
164
+ if (targetMeta && pk.length > 0) {
165
+ const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
166
+ synthOrder = pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
167
+ }
168
+ }
169
+ if (!synthOrder && !nestedChanged)
170
+ continue; // nothing to change, keep the ref
171
+ out ??= { ...withClause };
172
+ const clonedSpec = { ...options };
173
+ if (synthOrder)
174
+ clonedSpec.orderBy = synthOrder;
175
+ if (nestedChanged)
176
+ clonedSpec.with = newNested;
177
+ out[relName] = clonedSpec;
178
+ }
179
+ return out ?? withClause;
180
+ }
@@ -285,6 +285,20 @@ export declare function buildWhere<T extends object>(qi: BuilderCtx, where: Wher
285
285
  sql: string;
286
286
  params: unknown[];
287
287
  };
288
+ /**
289
+ * THE global-filter resolution rule, with no dependency on a `BuilderCtx`.
290
+ *
291
+ * Split out from {@link resolveGlobalFilter} so `PowqlInterface` can consume
292
+ * the SAME rule rather than a transcription of it. It is a parallel
293
+ * implementation of the public surface, and this repository's history is
294
+ * unambiguous that a rule written twice is how two engines come to disagree
295
+ * (the 0.64 projection resolver, the 0.73 findUnique guard). Every clause here
296
+ * is load-bearing on both engines: a function filter is evaluated PER BUILD so
297
+ * per-request tenancy works, an all-undefined filter is treated as absent so it
298
+ * cannot emit a dangling clause, and `skip` is honoured in both its whole-query
299
+ * and per-table forms.
300
+ */
301
+ export declare function resolveGlobalFilterFrom(filters: GlobalFilters | undefined, table: string, skip: ResolvedSkipGlobalFilters | undefined): Record<string, unknown> | null;
288
302
  /**
289
303
  * Resolve the configured global filter for `table`, evaluating a function
290
304
  * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
@@ -24,6 +24,7 @@ exports.collectJsonFilterParams = collectJsonFilterParams;
24
24
  exports.collectArrayFilterParams = collectArrayFilterParams;
25
25
  exports.collectVectorFilterParams = collectVectorFilterParams;
26
26
  exports.buildWhere = buildWhere;
27
+ exports.resolveGlobalFilterFrom = resolveGlobalFilterFrom;
27
28
  exports.resolveGlobalFilter = resolveGlobalFilter;
28
29
  exports.mergeGlobalFilter = mergeGlobalFilter;
29
30
  exports.targetGlobalFilterAlias = targetGlobalFilterAlias;
@@ -436,12 +437,19 @@ function buildWhere(qi, where) {
436
437
  return { sql: ` WHERE ${clause}`, params };
437
438
  }
438
439
  /**
439
- * Resolve the configured global filter for `table`, evaluating a function
440
- * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
441
- * no filter applies, the query opted out, or the filter is empty.
440
+ * THE global-filter resolution rule, with no dependency on a `BuilderCtx`.
441
+ *
442
+ * Split out from {@link resolveGlobalFilter} so `PowqlInterface` can consume
443
+ * the SAME rule rather than a transcription of it. It is a parallel
444
+ * implementation of the public surface, and this repository's history is
445
+ * unambiguous that a rule written twice is how two engines come to disagree
446
+ * (the 0.64 projection resolver, the 0.73 findUnique guard). Every clause here
447
+ * is load-bearing on both engines: a function filter is evaluated PER BUILD so
448
+ * per-request tenancy works, an all-undefined filter is treated as absent so it
449
+ * cannot emit a dangling clause, and `skip` is honoured in both its whole-query
450
+ * and per-table forms.
442
451
  */
443
- function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
444
- const filters = qi.globalFilters;
452
+ function resolveGlobalFilterFrom(filters, table, skip) {
445
453
  if (!filters)
446
454
  return null;
447
455
  if (skip === true)
@@ -461,6 +469,14 @@ function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
461
469
  return null;
462
470
  return obj;
463
471
  }
472
+ /**
473
+ * Resolve the configured global filter for `table`, evaluating a function
474
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
475
+ * no filter applies, the query opted out, or the filter is empty.
476
+ */
477
+ function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
478
+ return resolveGlobalFilterFrom(qi.globalFilters, table, skip);
479
+ }
464
480
  /**
465
481
  * AND-merge this table's resolved global filter into a user `where`. Either
466
482
  * side may be absent. When no filter applies the user where is returned by
package/dist/powql.d.ts CHANGED
@@ -196,7 +196,25 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
196
196
  * makes a core rule arrive on its own, and an engine that reads `take` but
197
197
  * not `skip` pages differently from one that reads both.
198
198
  */
199
+ /**
200
+ * Fill in the deterministic per-relation ordering `stableRelationOrder` asks
201
+ * for, through the shared transform the SQL engines use.
202
+ *
203
+ * PowDB accepted this option and did nothing with it before 0.74.0, so
204
+ * relation rows came back in whatever order the engine produced, from the one
205
+ * option whose entire purpose is that they do not. Same resolution as
206
+ * QueryInterface: a per-query flag wins, else the client-level default.
207
+ */
208
+ private withStableRelationOrder;
199
209
  private normalizeArgs;
210
+ /**
211
+ * `jsonEncoding` selects between PostgreSQL's `json_build_object` and
212
+ * `json_build_array` row encodings for relation subqueries. PowQL emits no
213
+ * JSON row encoding at all, so there is nothing for the option to select and
214
+ * it was simply dropped: the caller asked for a wire format and got silence.
215
+ * Refused for the same reason `forceCustomPlan` is.
216
+ */
217
+ private assertNoJsonEncoding;
200
218
  private assertNoForceCustomPlan;
201
219
  /** See query/compound-unique.ts: one rule and one message across engines. */
202
220
  private assertIdentifiesOneRow;
@@ -883,5 +901,25 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
883
901
  * `{ OR: [{ f: undefined }] }`, compiles to the empty string and is refused,
884
902
  * because emitting a filter-less write would hit every row.
885
903
  */
904
+ /**
905
+ * AND this table's configured global filter (soft-delete / multi-tenancy)
906
+ * onto an ALREADY-COMPILED user filter, pushing its params after the user's.
907
+ *
908
+ * Before 0.74.0 `PowqlInterface` did not read `globalFilters` at all, so a
909
+ * client configured for multi-tenancy applied its tenant predicate on every
910
+ * SQL engine and NONE on PowDB, silently, and `skipGlobalFilters` was
911
+ * accepted and inert. The rule itself is shared
912
+ * ({@link resolveGlobalFilterFrom}) rather than restated here.
913
+ *
914
+ * It takes a COMPILED user filter, and that ordering is the contract, not an
915
+ * implementation detail: the empty-`where` guard must see the USER filter
916
+ * alone, or a configured global filter would silently satisfy it and turn a
917
+ * refused mass mutation into an accepted one. Every write call site therefore
918
+ * calls {@link assertCompiledWhere} first and this second.
919
+ *
920
+ * `alias` is forwarded so the nested/native-join paths qualify the filter's
921
+ * column references the same way they qualify the user's.
922
+ */
923
+ private applyGlobalFilter;
886
924
  private assertCompiledWhere;
887
925
  }
package/dist/powql.js CHANGED
@@ -35,20 +35,20 @@
35
35
  * @module
36
36
  */
37
37
  import { randomUUID } from 'node:crypto';
38
- import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
38
+ import { NotFoundError, OptimisticLockError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError, } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
40
  import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isPowdbDatetimeColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlDotted, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
41
  import { assertAggregatePiiOptIn } from './query/aggregates.js';
42
42
  import { assertWhereIdentifiesOneRow, expandCompoundUniqueWhere } from './query/compound-unique.js';
43
43
  import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
44
44
  import { warnUnknownQueryOptions } from './query/option-surface.js';
45
- import { normalizeWithClause } from './query/relation-names.js';
45
+ import { applyStableRelationOrderTo, normalizeWithClause } from './query/relation-names.js';
46
46
  // The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
47
47
  // are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
48
48
  // engines, so a spread request body cannot turn either on here either.
49
- import { assertDirectionToken, resolveUnsafeFlag, UNSAFE } from './query/types.js';
49
+ import { assertDirectionToken, resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './query/types.js';
50
50
  import { escapeLike, normalizePagination, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
51
- import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
51
+ import { assertJsonFilterKeys, jsonStringEntries, resolveGlobalFilterFrom } from './query/where.js';
52
52
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
53
53
  /**
54
54
  * Max parent keys per relation-loader `in (…)` query. A `with` over a large
@@ -394,8 +394,38 @@ export class PowqlInterface {
394
394
  * makes a core rule arrive on its own, and an engine that reads `take` but
395
395
  * not `skip` pages differently from one that reads both.
396
396
  */
397
+ /**
398
+ * Fill in the deterministic per-relation ordering `stableRelationOrder` asks
399
+ * for, through the shared transform the SQL engines use.
400
+ *
401
+ * PowDB accepted this option and did nothing with it before 0.74.0, so
402
+ * relation rows came back in whatever order the engine produced, from the one
403
+ * option whose entire purpose is that they do not. Same resolution as
404
+ * QueryInterface: a per-query flag wins, else the client-level default.
405
+ */
406
+ withStableRelationOrder(args) {
407
+ if (!args?.with)
408
+ return args;
409
+ const on = args.stableRelationOrder ?? this.options.stableRelationOrder === true;
410
+ if (!on)
411
+ return args;
412
+ const ordered = applyStableRelationOrderTo(this.schema, args.with, this.table);
413
+ return ordered === args.with ? args : { ...args, with: ordered };
414
+ }
397
415
  normalizeArgs(args) {
398
- return normalizePagination(this.withDeclaredRelationNames(args));
416
+ return normalizePagination(this.withStableRelationOrder(this.withDeclaredRelationNames(args)));
417
+ }
418
+ /**
419
+ * `jsonEncoding` selects between PostgreSQL's `json_build_object` and
420
+ * `json_build_array` row encodings for relation subqueries. PowQL emits no
421
+ * JSON row encoding at all, so there is nothing for the option to select and
422
+ * it was simply dropped: the caller asked for a wire format and got silence.
423
+ * Refused for the same reason `forceCustomPlan` is.
424
+ */
425
+ assertNoJsonEncoding(args) {
426
+ if (args?.jsonEncoding === undefined)
427
+ return;
428
+ throw new UnsupportedFeatureError('jsonEncoding', 'PowDB', 'PowQL emits no JSON row encoding for relations, so there is nothing to select; omit the option');
399
429
  }
400
430
  assertNoForceCustomPlan(args) {
401
431
  if (args?.forceCustomPlan !== true)
@@ -1350,6 +1380,7 @@ export class PowqlInterface {
1350
1380
  // -------------------------------------------------------------------------
1351
1381
  async findMany(args = {}) {
1352
1382
  this.assertNoForceCustomPlan(args);
1383
+ this.assertNoJsonEncoding(args);
1353
1384
  args = this.normalizeArgs(args);
1354
1385
  return this.withMiddleware('findMany', args, async () => {
1355
1386
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
@@ -1431,7 +1462,8 @@ export class PowqlInterface {
1431
1462
  }
1432
1463
  const nest = nestedPlans.length > 0 || linkPlans.length > 0;
1433
1464
  const alias = nest ? 't0' : undefined;
1434
- const where = this.buildWhere(resolvedWhere, params, alias);
1465
+ let where = this.buildWhere(resolvedWhere, params, alias);
1466
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters, alias);
1435
1467
  // PowQL's `distinct` is row-wide, so these names never reach the emitted
1436
1468
  // statement. They are still caller-supplied names, and a name resolves or
1437
1469
  // throws: reading the array for its LENGTH alone let `distinct: ['nope']`
@@ -1508,6 +1540,7 @@ export class PowqlInterface {
1508
1540
  }
1509
1541
  async findUnique(args) {
1510
1542
  this.assertNoForceCustomPlan(args);
1543
+ this.assertNoJsonEncoding(args);
1511
1544
  args = this.normalizeArgs(args);
1512
1545
  // Prisma compound-unique selector → column conjunction (engine parity with
1513
1546
  // the SQL findUnique family; pure metadata, so this is a one-line adoption).
@@ -2602,14 +2635,41 @@ export class PowqlInterface {
2602
2635
  }
2603
2636
  const params = [];
2604
2637
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2605
- const where = this.buildWhere(resolvedWhere, params);
2606
- this.assertCompiledWhere(where, false, 'update');
2607
- const setClause = this.buildUpdateAssignments(args.data, params);
2638
+ let where = this.buildWhere(resolvedWhere, params);
2639
+ // `false` here refused an empty where even WITH the opt-in, while every
2640
+ // SQL engine accepted it: verified by probe on buildUpdate/buildDelete.
2641
+ this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'update');
2642
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2643
+ let setClause = this.buildUpdateAssignments(args.data, params);
2644
+ // Optimistic locking, matching the SQL engines exactly: bump the version
2645
+ // column in the SET, add `version = expected` to the filter, and treat
2646
+ // "matched no row" as a lost race rather than a missing row.
2647
+ //
2648
+ // PowqlInterface ignored `optimisticLock` outright before 0.74.0, which is
2649
+ // worse than not supporting it: the update applied, the caller's version
2650
+ // check never happened, and a concurrent writer's change was overwritten
2651
+ // by a caller who believed they held the lock.
2652
+ const lock = args.optimisticLock;
2653
+ if (lock) {
2654
+ const versionCol = quotePowqlIdent(this.column(lock.field).name);
2655
+ setClause += `, ${versionCol} := ${this.ref(lock.field)} + 1`;
2656
+ params.push(lock.expected);
2657
+ const check = `${this.ref(lock.field)} = $${params.length}`;
2658
+ where = where ? `(${where}) and (${check})` : check;
2659
+ }
2608
2660
  // `returning` hands back the post-update row(s); take the first (single-row contract).
2609
2661
  const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
2610
2662
  const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
2611
- if (!row)
2663
+ if (!row) {
2664
+ if (lock) {
2665
+ throw new OptimisticLockError({
2666
+ table: this.table,
2667
+ versionField: lock.field,
2668
+ expectedVersion: lock.expected,
2669
+ });
2670
+ }
2612
2671
  throw new NotFoundError({ table: this.table, where: args.where });
2672
+ }
2613
2673
  return row;
2614
2674
  });
2615
2675
  }
@@ -2617,8 +2677,9 @@ export class PowqlInterface {
2617
2677
  return this.withMiddleware('updateMany', args, async () => {
2618
2678
  const params = [];
2619
2679
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2620
- const where = this.buildWhere(resolvedWhere, params);
2680
+ let where = this.buildWhere(resolvedWhere, params);
2621
2681
  this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'updateMany');
2682
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2622
2683
  const setClause = this.buildUpdateAssignments(args.data, params);
2623
2684
  const filter = where ? ` filter ${where}` : '';
2624
2685
  const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
@@ -2753,8 +2814,9 @@ export class PowqlInterface {
2753
2814
  return this.withMiddleware('delete', args, async () => {
2754
2815
  const params = [];
2755
2816
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2756
- const where = this.buildWhere(resolvedWhere, params);
2757
- this.assertCompiledWhere(where, false, 'delete');
2817
+ let where = this.buildWhere(resolvedWhere, params);
2818
+ this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'delete');
2819
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2758
2820
  // `returning` hands back the deleted row(s), no separate pre-image reselect needed.
2759
2821
  const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
2760
2822
  const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
@@ -2767,8 +2829,9 @@ export class PowqlInterface {
2767
2829
  return this.withMiddleware('deleteMany', args, async () => {
2768
2830
  const params = [];
2769
2831
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2770
- const where = this.buildWhere(resolvedWhere, params);
2832
+ let where = this.buildWhere(resolvedWhere, params);
2771
2833
  this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'deleteMany');
2834
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2772
2835
  const filter = where ? ` filter ${where}` : '';
2773
2836
  const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
2774
2837
  return { count: rowCount };
@@ -2839,7 +2902,8 @@ export class PowqlInterface {
2839
2902
  return this.withMiddleware('count', (args ?? {}), async () => {
2840
2903
  const params = [];
2841
2904
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2842
- const where = this.buildWhere(resolvedWhere, params);
2905
+ let where = this.buildWhere(resolvedWhere, params);
2906
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2843
2907
  const filter = where ? ` filter ${where}` : '';
2844
2908
  const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
2845
2909
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
@@ -2880,7 +2944,8 @@ export class PowqlInterface {
2880
2944
  const result = {};
2881
2945
  const filterParams = [];
2882
2946
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2883
- const where = this.buildWhere(resolvedWhere, filterParams);
2947
+ let where = this.buildWhere(resolvedWhere, filterParams);
2948
+ where = this.applyGlobalFilter(where, filterParams, args.skipGlobalFilters);
2884
2949
  const filter = where ? ` filter ${where}` : '';
2885
2950
  const scalar = async (expr) => {
2886
2951
  const params = [...filterParams];
@@ -2942,7 +3007,8 @@ export class PowqlInterface {
2942
3007
  const emitNative = Boolean(this.capabilities.nativeRaw);
2943
3008
  const params = [];
2944
3009
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2945
- const where = this.buildWhere(resolvedWhere, params);
3010
+ let where = this.buildWhere(resolvedWhere, params);
3011
+ where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2946
3012
  const filter = where ? ` filter ${where}` : '';
2947
3013
  // Result-key namespace, mirroring the SQL builder's `claimResultKey`
2948
3014
  // (query/builder.ts): a group-key / aggregate output-name collision (with
@@ -3295,6 +3361,36 @@ export class PowqlInterface {
3295
3361
  * `{ OR: [{ f: undefined }] }`, compiles to the empty string and is refused,
3296
3362
  * because emitting a filter-less write would hit every row.
3297
3363
  */
3364
+ /**
3365
+ * AND this table's configured global filter (soft-delete / multi-tenancy)
3366
+ * onto an ALREADY-COMPILED user filter, pushing its params after the user's.
3367
+ *
3368
+ * Before 0.74.0 `PowqlInterface` did not read `globalFilters` at all, so a
3369
+ * client configured for multi-tenancy applied its tenant predicate on every
3370
+ * SQL engine and NONE on PowDB, silently, and `skipGlobalFilters` was
3371
+ * accepted and inert. The rule itself is shared
3372
+ * ({@link resolveGlobalFilterFrom}) rather than restated here.
3373
+ *
3374
+ * It takes a COMPILED user filter, and that ordering is the contract, not an
3375
+ * implementation detail: the empty-`where` guard must see the USER filter
3376
+ * alone, or a configured global filter would silently satisfy it and turn a
3377
+ * refused mass mutation into an accepted one. Every write call site therefore
3378
+ * calls {@link assertCompiledWhere} first and this second.
3379
+ *
3380
+ * `alias` is forwarded so the nested/native-join paths qualify the filter's
3381
+ * column references the same way they qualify the user's.
3382
+ */
3383
+ applyGlobalFilter(compiledUserWhere, params, skip, alias) {
3384
+ const gf = resolveGlobalFilterFrom(this.options.globalFilters, this.table, resolveSkipGlobalFilters(skip));
3385
+ if (!gf)
3386
+ return compiledUserWhere;
3387
+ const gfWhere = this.buildWhere(gf, params, alias);
3388
+ if (!gfWhere)
3389
+ return compiledUserWhere;
3390
+ if (!compiledUserWhere)
3391
+ return gfWhere;
3392
+ return `(${compiledUserWhere}) and (${gfWhere})`;
3393
+ }
3298
3394
  assertCompiledWhere(compiledWhere, allow, action) {
3299
3395
  if (allow === true)
3300
3396
  return;
@@ -18,9 +18,9 @@ import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import * as aggMod from './aggregates.js';
19
19
  import { assertProjectionShape, defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
20
20
  import { assertWhereIdentifiesOneRow, expandCompoundUniqueWhere } from './compound-unique.js';
21
- import { dedupeColumnList, dedupeOrderEntries, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
21
+ import { dedupeColumnList, dedupeOrderEntries, isEmptyOrderBy, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
22
  import { warnUnknownQueryOptions } from './option-surface.js';
23
- import { normalizeWithClause } from './relation-names.js';
23
+ import { applyStableRelationOrderTo, normalizeWithClause } from './relation-names.js';
24
24
  import * as relationsMod from './relations.js';
25
25
  import { resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './types.js';
26
26
  import { isTemporalInfinity, LRUCache, normalizePagination, ownLookup, parseDbDate, resolveColumnName, resolveRelation, resolveRelationDef, sqlToPreparedName, unknownFieldMessage, warnRedundantSortTerm, } from './utils.js';
@@ -406,14 +406,8 @@ export function unlockNestedWriteTx(tx) {
406
406
  * orderBy is never overwritten while an empty `{}` / `[]` still gets the
407
407
  * synthesized PK order.
408
408
  */
409
- function isEmptyOrderBy(orderBy) {
410
- if (Array.isArray(orderBy))
411
- return orderBy.length === 0;
412
- if (orderBy && typeof orderBy === 'object') {
413
- return Object.values(orderBy).every((v) => v === undefined);
414
- }
415
- return orderBy === undefined || orderBy === null;
416
- }
409
+ // Moved to ./filters.ts in 0.74.0 so PowqlInterface consumes the SAME
410
+ // predicate rather than a second copy of it. See applyStableRelationOrderTo.
417
411
  /** The two accepted {@link JsonEncoding} values, frozen so the check is total. */
418
412
  const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
419
413
  /**
@@ -1117,46 +1111,10 @@ export class QueryInterface {
1117
1111
  * nothing stable to order by, so both are left untouched.
1118
1112
  */
1119
1113
  applyStableRelationOrder(withClause, table, depth = 0) {
1120
- if (depth >= 10)
1121
- return withClause; // parity with the build depth cap
1122
- const meta = this.schema.tables[table];
1123
- if (!meta)
1124
- return withClause;
1125
- let out;
1126
- for (const [relName, spec] of Object.entries(withClause)) {
1127
- if (relName === '_count' || !spec)
1128
- continue; // `_count` is a count, not a row load
1129
- const rel = resolveRelationDef(meta.relations, relName);
1130
- if (!rel)
1131
- continue; // unknown relation, let the build path surface E005
1132
- const options = spec === true ? {} : spec;
1133
- // Recurse first so a nested change alone still clones this level.
1134
- const nestedWith = options.with;
1135
- const newNested = nestedWith ? this.applyStableRelationOrder(nestedWith, rel.to, depth + 1) : undefined;
1136
- const nestedChanged = newNested !== undefined && newNested !== nestedWith;
1137
- const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
1138
- const hasOrder = options.orderBy !== undefined && !isEmptyOrderBy(options.orderBy);
1139
- let synthOrder;
1140
- if (isToMany && !hasOrder) {
1141
- const targetMeta = this.schema.tables[rel.to];
1142
- const pk = targetMeta?.primaryKey ?? [];
1143
- if (targetMeta && pk.length > 0) {
1144
- const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
1145
- synthOrder =
1146
- pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
1147
- }
1148
- }
1149
- if (!synthOrder && !nestedChanged)
1150
- continue; // nothing to change, keep the ref
1151
- out ??= { ...withClause };
1152
- const clonedSpec = { ...options };
1153
- if (synthOrder)
1154
- clonedSpec.orderBy = synthOrder;
1155
- if (nestedChanged)
1156
- clonedSpec.with = newNested;
1157
- out[relName] = clonedSpec;
1158
- }
1159
- return out ?? withClause;
1114
+ // The transform itself lives in ./relation-names.ts as of 0.74.0, so PowDB
1115
+ // runs the SAME rule instead of a second copy that accepted the option and
1116
+ // did nothing.
1117
+ return applyStableRelationOrderTo(this.schema, withClause, table, depth);
1160
1118
  }
1161
1119
  // -------------------------------------------------------------------------
1162
1120
  // Deterministic pagination (unordered LIMIT/OFFSET)
@@ -387,3 +387,11 @@ export declare function normalizeOrderBy(value: OrderDirection | OrderBySpec): {
387
387
  dir: 'ASC' | 'DESC';
388
388
  nulls?: 'first' | 'last';
389
389
  };
390
+ /**
391
+ * Is this `orderBy` effectively absent?
392
+ *
393
+ * `{}` and `[]` and `{ id: undefined }` all mean "no ordering asked for". Used
394
+ * by the stable-relation-order transform so an explicit (non-empty) orderBy is
395
+ * never overwritten while an empty one still gets the synthesized PK order.
396
+ */
397
+ export declare function isEmptyOrderBy(orderBy: unknown): boolean;
@@ -673,3 +673,18 @@ export function normalizeOrderBy(value) {
673
673
  }
674
674
  return { dir: String(value).toLowerCase() === 'desc' ? 'DESC' : 'ASC' };
675
675
  }
676
+ /**
677
+ * Is this `orderBy` effectively absent?
678
+ *
679
+ * `{}` and `[]` and `{ id: undefined }` all mean "no ordering asked for". Used
680
+ * by the stable-relation-order transform so an explicit (non-empty) orderBy is
681
+ * never overwritten while an empty one still gets the synthesized PK order.
682
+ */
683
+ export function isEmptyOrderBy(orderBy) {
684
+ if (Array.isArray(orderBy))
685
+ return orderBy.length === 0;
686
+ if (orderBy && typeof orderBy === 'object') {
687
+ return Object.values(orderBy).every((v) => v === undefined);
688
+ }
689
+ return orderBy === undefined || orderBy === null;
690
+ }
@@ -50,3 +50,21 @@ import type { WithClause } from './types.js';
50
50
  * Returns the input by reference when no key changed.
51
51
  */
52
52
  export declare function normalizeWithClause(schema: SchemaMetadata, table: string, withClause: WithClause | undefined, depth?: number): WithClause | undefined;
53
+ /**
54
+ * Fill in a deterministic `orderBy` for every to-many relation in a `with`
55
+ * clause that did not ask for one, using the target table's primary key.
56
+ *
57
+ * THE single authority for `stableRelationOrder`, on every engine. It was a
58
+ * private method on `QueryInterface` until 0.74.0, which meant PowDB accepted
59
+ * the option and did nothing with it: relation rows came back in whatever order
60
+ * the engine produced, from the option whose entire purpose is that they do
61
+ * not. Restating the rule in `powql.ts` would have been the 0.64 projection
62
+ * resolver again, so it moved here instead, into the same "normalize the whole
63
+ * `with` tree once, before anything reads it" slot as
64
+ * {@link normalizeWithClause}.
65
+ *
66
+ * Returns the input BY REFERENCE when nothing changed, so a query that already
67
+ * orders every relation allocates nothing and its SQL/fingerprint stay
68
+ * byte-identical.
69
+ */
70
+ export declare function applyStableRelationOrderTo(schema: SchemaMetadata, withClause: WithClause, table: string, depth?: number): WithClause;
@@ -41,7 +41,8 @@
41
41
  * available relations; rejecting it here would move that error away from its
42
42
  * context and change which error type callers see.
43
43
  */
44
- import { resolveRelation } from './utils.js';
44
+ import { isEmptyOrderBy } from './filters.js';
45
+ import { resolveRelation, resolveRelationDef } from './utils.js';
45
46
  /** Depth cap mirroring the builder's own, so a cyclic `with` cannot spin here. */
46
47
  const MAX_DEPTH = 12;
47
48
  /**
@@ -115,3 +116,61 @@ function normalizeCount(meta, count) {
115
116
  * already a documented single authority; wrapping them here would add a second
116
117
  * name for the same rule without removing a caller.
117
118
  */
119
+ /**
120
+ * Fill in a deterministic `orderBy` for every to-many relation in a `with`
121
+ * clause that did not ask for one, using the target table's primary key.
122
+ *
123
+ * THE single authority for `stableRelationOrder`, on every engine. It was a
124
+ * private method on `QueryInterface` until 0.74.0, which meant PowDB accepted
125
+ * the option and did nothing with it: relation rows came back in whatever order
126
+ * the engine produced, from the option whose entire purpose is that they do
127
+ * not. Restating the rule in `powql.ts` would have been the 0.64 projection
128
+ * resolver again, so it moved here instead, into the same "normalize the whole
129
+ * `with` tree once, before anything reads it" slot as
130
+ * {@link normalizeWithClause}.
131
+ *
132
+ * Returns the input BY REFERENCE when nothing changed, so a query that already
133
+ * orders every relation allocates nothing and its SQL/fingerprint stay
134
+ * byte-identical.
135
+ */
136
+ export function applyStableRelationOrderTo(schema, withClause, table, depth = 0) {
137
+ if (depth >= 10)
138
+ return withClause; // parity with the build depth cap
139
+ const meta = schema.tables[table];
140
+ if (!meta)
141
+ return withClause;
142
+ let out;
143
+ for (const [relName, spec] of Object.entries(withClause)) {
144
+ if (relName === '_count' || !spec)
145
+ continue; // `_count` is a count, not a row load
146
+ const rel = resolveRelationDef(meta.relations, relName);
147
+ if (!rel)
148
+ continue; // unknown relation, let the build path surface E005
149
+ const options = spec === true ? {} : spec;
150
+ // Recurse first so a nested change alone still clones this level.
151
+ const nestedWith = options.with;
152
+ const newNested = nestedWith ? applyStableRelationOrderTo(schema, nestedWith, rel.to, depth + 1) : undefined;
153
+ const nestedChanged = newNested !== undefined && newNested !== nestedWith;
154
+ const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
155
+ const hasOrder = options.orderBy !== undefined && !isEmptyOrderBy(options.orderBy);
156
+ let synthOrder;
157
+ if (isToMany && !hasOrder) {
158
+ const targetMeta = schema.tables[rel.to];
159
+ const pk = targetMeta?.primaryKey ?? [];
160
+ if (targetMeta && pk.length > 0) {
161
+ const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
162
+ synthOrder = pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
163
+ }
164
+ }
165
+ if (!synthOrder && !nestedChanged)
166
+ continue; // nothing to change, keep the ref
167
+ out ??= { ...withClause };
168
+ const clonedSpec = { ...options };
169
+ if (synthOrder)
170
+ clonedSpec.orderBy = synthOrder;
171
+ if (nestedChanged)
172
+ clonedSpec.with = newNested;
173
+ out[relName] = clonedSpec;
174
+ }
175
+ return out ?? withClause;
176
+ }
@@ -285,6 +285,20 @@ export declare function buildWhere<T extends object>(qi: BuilderCtx, where: Wher
285
285
  sql: string;
286
286
  params: unknown[];
287
287
  };
288
+ /**
289
+ * THE global-filter resolution rule, with no dependency on a `BuilderCtx`.
290
+ *
291
+ * Split out from {@link resolveGlobalFilter} so `PowqlInterface` can consume
292
+ * the SAME rule rather than a transcription of it. It is a parallel
293
+ * implementation of the public surface, and this repository's history is
294
+ * unambiguous that a rule written twice is how two engines come to disagree
295
+ * (the 0.64 projection resolver, the 0.73 findUnique guard). Every clause here
296
+ * is load-bearing on both engines: a function filter is evaluated PER BUILD so
297
+ * per-request tenancy works, an all-undefined filter is treated as absent so it
298
+ * cannot emit a dangling clause, and `skip` is honoured in both its whole-query
299
+ * and per-table forms.
300
+ */
301
+ export declare function resolveGlobalFilterFrom(filters: GlobalFilters | undefined, table: string, skip: ResolvedSkipGlobalFilters | undefined): Record<string, unknown> | null;
288
302
  /**
289
303
  * Resolve the configured global filter for `table`, evaluating a function
290
304
  * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
@@ -369,12 +369,19 @@ export function buildWhere(qi, where) {
369
369
  return { sql: ` WHERE ${clause}`, params };
370
370
  }
371
371
  /**
372
- * Resolve the configured global filter for `table`, evaluating a function
373
- * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
374
- * no filter applies, the query opted out, or the filter is empty.
372
+ * THE global-filter resolution rule, with no dependency on a `BuilderCtx`.
373
+ *
374
+ * Split out from {@link resolveGlobalFilter} so `PowqlInterface` can consume
375
+ * the SAME rule rather than a transcription of it. It is a parallel
376
+ * implementation of the public surface, and this repository's history is
377
+ * unambiguous that a rule written twice is how two engines come to disagree
378
+ * (the 0.64 projection resolver, the 0.73 findUnique guard). Every clause here
379
+ * is load-bearing on both engines: a function filter is evaluated PER BUILD so
380
+ * per-request tenancy works, an all-undefined filter is treated as absent so it
381
+ * cannot emit a dangling clause, and `skip` is honoured in both its whole-query
382
+ * and per-table forms.
375
383
  */
376
- export function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
377
- const filters = qi.globalFilters;
384
+ export function resolveGlobalFilterFrom(filters, table, skip) {
378
385
  if (!filters)
379
386
  return null;
380
387
  if (skip === true)
@@ -394,6 +401,14 @@ export function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
394
401
  return null;
395
402
  return obj;
396
403
  }
404
+ /**
405
+ * Resolve the configured global filter for `table`, evaluating a function
406
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
407
+ * no filter applies, the query opted out, or the filter is empty.
408
+ */
409
+ export function resolveGlobalFilter(qi, table, skip = qi.currentSkip) {
410
+ return resolveGlobalFilterFrom(qi.globalFilters, table, skip);
411
+ }
397
412
  /**
398
413
  * AND-merge this table's resolved global filter into a user `where`. Either
399
414
  * side may be absent. When no filter applies the user where is returned by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.73.1",
3
+ "version": "0.74.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": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",