turbine-orm 0.51.0 → 0.52.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 (74) hide show
  1. package/README.md +33 -5
  2. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  3. package/dist/cjs/client.d.ts +106 -2
  4. package/dist/cjs/client.js +111 -5
  5. package/dist/cjs/dialect.d.ts +33 -0
  6. package/dist/cjs/dialect.js +14 -0
  7. package/dist/cjs/engine-config.d.ts +49 -0
  8. package/dist/cjs/engine-config.js +19 -0
  9. package/dist/cjs/index-advisor.js +0 -0
  10. package/dist/cjs/index.d.ts +1 -1
  11. package/dist/cjs/index.js +3 -2
  12. package/dist/cjs/mssql.d.ts +8 -3
  13. package/dist/cjs/mssql.js +22 -3
  14. package/dist/cjs/mysql.d.ts +7 -3
  15. package/dist/cjs/mysql.js +20 -3
  16. package/dist/cjs/nested-write.d.ts +31 -0
  17. package/dist/cjs/nested-write.js +80 -2
  18. package/dist/cjs/powdb-introspect.d.ts +10 -1
  19. package/dist/cjs/powdb-introspect.js +10 -1
  20. package/dist/cjs/powdb.d.ts +116 -6
  21. package/dist/cjs/powdb.js +169 -10
  22. package/dist/cjs/powql.d.ts +161 -1
  23. package/dist/cjs/powql.js +299 -19
  24. package/dist/cjs/prisma-compat.d.ts +54 -8
  25. package/dist/cjs/prisma-compat.js +136 -20
  26. package/dist/cjs/query/batched-loader.d.ts +7 -0
  27. package/dist/cjs/query/batched-loader.js +97 -15
  28. package/dist/cjs/query/builder.d.ts +131 -5
  29. package/dist/cjs/query/builder.js +223 -19
  30. package/dist/cjs/query/compound-unique.js +0 -0
  31. package/dist/cjs/query/index.d.ts +1 -1
  32. package/dist/cjs/query/index.js +2 -1
  33. package/dist/cjs/query/warn-registry.d.ts +10 -0
  34. package/dist/cjs/query/warn-registry.js +10 -0
  35. package/dist/cjs/query/writes.js +115 -7
  36. package/dist/cjs/sqlite.d.ts +10 -4
  37. package/dist/cjs/sqlite.js +18 -4
  38. package/dist/cli/studio-ui.generated.js +1 -1
  39. package/dist/client.d.ts +106 -2
  40. package/dist/client.js +111 -5
  41. package/dist/dialect.d.ts +33 -0
  42. package/dist/dialect.js +14 -0
  43. package/dist/engine-config.d.ts +49 -0
  44. package/dist/engine-config.js +18 -0
  45. package/dist/index-advisor.js +0 -0
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/mssql.d.ts +8 -3
  49. package/dist/mssql.js +22 -3
  50. package/dist/mysql.d.ts +7 -3
  51. package/dist/mysql.js +20 -3
  52. package/dist/nested-write.d.ts +31 -0
  53. package/dist/nested-write.js +79 -2
  54. package/dist/powdb-introspect.d.ts +10 -1
  55. package/dist/powdb-introspect.js +10 -1
  56. package/dist/powdb.d.ts +116 -6
  57. package/dist/powdb.js +167 -9
  58. package/dist/powql.d.ts +161 -1
  59. package/dist/powql.js +299 -19
  60. package/dist/prisma-compat.d.ts +54 -8
  61. package/dist/prisma-compat.js +136 -20
  62. package/dist/query/batched-loader.d.ts +7 -0
  63. package/dist/query/batched-loader.js +98 -16
  64. package/dist/query/builder.d.ts +131 -5
  65. package/dist/query/builder.js +222 -18
  66. package/dist/query/compound-unique.js +0 -0
  67. package/dist/query/index.d.ts +1 -1
  68. package/dist/query/index.js +1 -1
  69. package/dist/query/warn-registry.d.ts +10 -0
  70. package/dist/query/warn-registry.js +10 -0
  71. package/dist/query/writes.js +116 -8
  72. package/dist/sqlite.d.ts +10 -4
  73. package/dist/sqlite.js +19 -5
  74. package/package.json +3 -3
@@ -25,7 +25,10 @@
25
25
  * , the un-awaited delegate calls defer to Turbine's `build*()` methods and
26
26
  * run atomically through the core batch `$transaction([...])` path.
27
27
  * - **Raw SQL**: `$queryRaw` / `$executeRaw` tagged templates (with
28
- * `Prisma.sql`-style nested-fragment flattening) and the `*Unsafe` variants.
28
+ * `Prisma.sql`-style nested-fragment flattening) and the `*Unsafe` variants,
29
+ * on the client AND on the transaction client, where they run on the
30
+ * transaction's own connection so a mixed raw + delegate `$transaction` stays
31
+ * atomic.
29
32
  * - **Result reshaping**: `_count` objects keyed back to Prisma relation names,
30
33
  * and to-one relations surfaced as `object | null`.
31
34
  *
@@ -82,11 +85,27 @@
82
85
  * ```
83
86
  */
84
87
  import { TurbineError, TurbineErrorCode, UnsupportedFeatureError, ValidationError, wrapPgError } from './errors.js';
88
+ import { createManyShapeRuns } from './nested-write.js';
85
89
  import { shouldWarnOnce } from './query/warn-registry.js';
86
90
  // ---------------------------------------------------------------------------
87
91
  // Prisma.sql-style raw fragments (local, minimal, never imports @prisma/client)
88
92
  // ---------------------------------------------------------------------------
89
- const SQL_FRAGMENT = Symbol.for('turbine.prismaCompat.sqlFragment');
93
+ /**
94
+ * Brand marking an object as a raw-SQL fragment whose `strings` are spliced
95
+ * VERBATIM into the emitted statement (see `flattenTemplate`). Deliberately a
96
+ * module-private `Symbol()` and NOT `Symbol.for(...)`: a registry symbol is
97
+ * reachable by name from anywhere in the process, so any dependency could mint
98
+ * an object that flattens as trusted SQL. With a private symbol the only way to
99
+ * obtain a fragment is to call `Prisma.sql` / `Prisma.join` / `Prisma.raw` from
100
+ * this module.
101
+ *
102
+ * The fragment check is fail-CLOSED: an object that does not carry this exact
103
+ * symbol is bound as a `$N` parameter, never spliced. That is also what makes
104
+ * the (contrived) dual-package case safe rather than dangerous, a fragment
105
+ * built by the ESM copy of this module and executed by the CJS copy binds as a
106
+ * parameter instead of composing.
107
+ */
108
+ const SQL_FRAGMENT = Symbol('turbine.prismaCompat.sqlFragment');
90
109
  function isSqlFragment(x) {
91
110
  return typeof x === 'object' && x !== null && x[SQL_FRAGMENT] === true;
92
111
  }
@@ -1065,6 +1084,40 @@ async function upsertLookupFirst(qi, t) {
1065
1084
  return qi.update({ where: t.where, data: t.update });
1066
1085
  return qi.create({ data: t.create });
1067
1086
  }
1087
+ /**
1088
+ * The row shapes a translated `createMany` has to insert, as contiguous runs
1089
+ * that each name the same fields (see {@link createManyShapeRuns}).
1090
+ *
1091
+ * Prisma accepts rows of DIFFERENT shapes in one `createMany` and emits a single
1092
+ * INSERT over the UNION of the named columns, binding its own schema-level
1093
+ * `@default` for a field a row omits and `null` for one it has no default for
1094
+ * (verified against @prisma/client 7.9.0 on PostgreSQL 16). Core `createMany`
1095
+ * refuses a mixed batch instead, because it has no per-cell DEFAULT form on
1096
+ * every engine and would otherwise write NULL over the column's default. Runs
1097
+ * bridge the two: one core `createMany` per run, all of them inside one
1098
+ * transaction, so a ported call site keeps working and the `{ count }` it gets
1099
+ * back is the number of rows actually inserted.
1100
+ *
1101
+ * The one place this is not byte-for-byte Prisma is a column whose DEFAULT lives
1102
+ * in the DATABASE and not in the Prisma schema: Prisma binds null there and
1103
+ * loses the default, a run lets the column default apply. Prisma's own answer is
1104
+ * the lossy one, so the divergence only ever adds back a value the caller never
1105
+ * asked to overwrite.
1106
+ */
1107
+ function createManyRunsOf(t) {
1108
+ return createManyShapeRuns((Array.isArray(t.data) ? t.data : []));
1109
+ }
1110
+ /** One core `createMany` per run, in order, summing Prisma's `{ count }`. */
1111
+ async function createManyByRun(qi, t, runs) {
1112
+ let count = 0;
1113
+ for (const run of runs) {
1114
+ const args = { data: run };
1115
+ if (t.skipDuplicates)
1116
+ args.skipDuplicates = true;
1117
+ count += (await qi.createMany(args)).length;
1118
+ }
1119
+ return { count };
1120
+ }
1068
1121
  function makeDelegate(ctx, mm, getQI, runInTx) {
1069
1122
  const pe = ctx.options.prismaErrorCodes;
1070
1123
  // Build a lazy Prisma-style promise for one delegate call. Crucially, the
@@ -1140,7 +1193,23 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1140
1193
  if (args.skipDuplicates)
1141
1194
  t.skipDuplicates = true;
1142
1195
  return t;
1143
- }, (qi, t) => qi.createMany(t).then((r) => ({ count: r.length })), { build: (qi, t) => qi.buildCreateMany(t), reshape: (raw) => ({ count: raw.length }) }),
1196
+ }, (qi, t) => {
1197
+ // Rows that all name the same fields are one statement, exactly as
1198
+ // before. A mixed batch, which Prisma accepts, becomes one createMany
1199
+ // per contiguous same-shape run inside ONE transaction, so the call
1200
+ // stays all-or-nothing and the count is the total actually inserted.
1201
+ const runs = createManyRunsOf(t);
1202
+ if (runs.length <= 1)
1203
+ return qi.createMany(t).then((r) => ({ count: r.length }));
1204
+ return runInTx((table) => createManyByRun(table(mm.table), t, runs));
1205
+ }, {
1206
+ build: (qi, t) => qi.buildCreateMany(t),
1207
+ reshape: (raw) => ({ count: raw.length }),
1208
+ // A mixed batch is more than one statement, so the array
1209
+ // $transaction([...]) form runs the whole array sequentially in a tx.
1210
+ nested: (t) => createManyRunsOf(t).length > 1,
1211
+ execInTx: (table, t) => createManyByRun(table(mm.table), t, createManyRunsOf(t)),
1212
+ }),
1144
1213
  update: (args) => defer(() => {
1145
1214
  const a = requireWhere(args, 'update');
1146
1215
  return {
@@ -1255,6 +1324,31 @@ function flattenTemplate(strings, values, ph) {
1255
1324
  append(strings, values);
1256
1325
  return { text, params };
1257
1326
  }
1327
+ /**
1328
+ * Build the four Prisma raw methods over ONE executor. Both the client-level and
1329
+ * the transaction-scoped surfaces come from this function, so they cannot drift:
1330
+ * same fragment flattening, same placeholder generation, same return shapes
1331
+ * (`$queryRaw` → rows, `$executeRaw` → affected-row count). The only difference
1332
+ * is which connection the executor runs on.
1333
+ */
1334
+ function makeRawSurface(exec, ph) {
1335
+ return {
1336
+ $queryRaw: async (strings, ...values) => {
1337
+ const { text, params } = flattenTemplate(strings, values, ph);
1338
+ return (await exec(text, params)).rows;
1339
+ },
1340
+ $queryRawUnsafe: async (sql, ...params) => {
1341
+ return (await exec(sql, params)).rows;
1342
+ },
1343
+ $executeRaw: async (strings, ...values) => {
1344
+ const { text, params } = flattenTemplate(strings, values, ph);
1345
+ return (await exec(text, params)).rowCount ?? 0;
1346
+ },
1347
+ $executeRawUnsafe: async (sql, ...params) => {
1348
+ return (await exec(sql, params)).rowCount ?? 0;
1349
+ },
1350
+ };
1351
+ }
1258
1352
  // ---------------------------------------------------------------------------
1259
1353
  // createPrismaCompatClient
1260
1354
  // ---------------------------------------------------------------------------
@@ -1310,6 +1404,11 @@ function junctionModels(ctx, map, tableToModel) {
1310
1404
  if (alias)
1311
1405
  seen.add(alias);
1312
1406
  }
1407
+ // Junction names already registered by an earlier relation. A many-to-many
1408
+ // pair is normally DECLARED ON BOTH SIDES, so the same junction table is
1409
+ // reached twice; the second visit must be a silent no-op, not a collision (it
1410
+ // would be colliding with its own registration).
1411
+ const registered = new Set();
1313
1412
  for (const table of Object.values(ctx.schema.tables)) {
1314
1413
  for (const rel of Object.values(table.relations ?? {})) {
1315
1414
  if (rel.type !== 'manyToMany')
@@ -1317,15 +1416,18 @@ function junctionModels(ctx, map, tableToModel) {
1317
1416
  const name = rel.through?.table;
1318
1417
  if (!name || !ctx.schema.tables[name] || tableToModel.has(name))
1319
1418
  continue;
1419
+ if (registered.has(name))
1420
+ continue;
1320
1421
  if (seen.has(name)) {
1321
1422
  if (process.env.NODE_ENV !== 'production' && shouldWarnOnce(JUNCTION_WARN_NS, name)) {
1322
1423
  console.warn(`[turbine] prisma-compat: the many-to-many junction table "${name}" collides with an ` +
1323
1424
  'existing client member of the same name, so no junction accessor was created for it. ' +
1324
- 'Reach its rows through $queryRaw / $executeRaw, or through the owning model relation.');
1425
+ 'Reach its rows through the owning model relation (a nested connect / disconnect / set on the ' +
1426
+ 'related model), or through $queryRaw / $executeRaw.');
1325
1427
  }
1326
1428
  continue;
1327
1429
  }
1328
- seen.add(name);
1430
+ registered.add(name);
1329
1431
  out.push([name, { table: name, accessor: name, fields: {}, relations: {}, compoundUniques: {} }]);
1330
1432
  }
1331
1433
  }
@@ -1388,6 +1490,27 @@ export function createPrismaCompatClient(client, map, options = {}) {
1388
1490
  throw decorate(wrapPgError(err), ctx.options.prismaErrorCodes);
1389
1491
  }
1390
1492
  };
1493
+ /**
1494
+ * The same executor bound to a transaction's OWN connection. A raw statement
1495
+ * that quietly ran on a pool connection would leave the caller unable to tell
1496
+ * that its writes were outside the transaction, so a transaction client that
1497
+ * cannot execute raw SQL refuses rather than falling back to the pool.
1498
+ * `wrapPgError` is idempotent (it returns an already-typed TurbineError
1499
+ * untouched), so the error shape matches the pool path exactly.
1500
+ */
1501
+ const txRunRaw = (tx) => async (text, params) => {
1502
+ if (typeof tx.rawQuery !== 'function') {
1503
+ throw decorate(new ValidationError('[turbine] prisma-compat: raw SQL inside $transaction needs a transaction client that can execute it ' +
1504
+ '(core TransactionClient.rawQuery). Refusing to run the statement on a pool connection, which would ' +
1505
+ 'silently place it outside the transaction.'), ctx.options.prismaErrorCodes);
1506
+ }
1507
+ try {
1508
+ return await tx.rawQuery(text, params);
1509
+ }
1510
+ catch (err) {
1511
+ throw decorate(wrapPgError(err), ctx.options.prismaErrorCodes);
1512
+ }
1513
+ };
1391
1514
  const base = {
1392
1515
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1393
1516
  $transaction: ((arg, txOptions) => {
@@ -1436,23 +1559,16 @@ export function createPrismaCompatClient(client, map, options = {}) {
1436
1559
  txDelegates[alias] = txDelegates[prismaModel];
1437
1560
  }
1438
1561
  }
1439
- return fn(txDelegates);
1562
+ // Raw SQL on the transaction's own connection. Prisma's tx client
1563
+ // carries these four, and code that mixes `$transaction` with raw SQL is
1564
+ // the common case in a migrated codebase. No model can shadow them: a
1565
+ // Prisma model name cannot start with `$`, and junction accessors skip
1566
+ // every CLIENT_RESERVED_KEYS name.
1567
+ const txClient = { ...txDelegates, ...makeRawSurface(txRunRaw(tx), ph) };
1568
+ return fn(txClient);
1440
1569
  }, txOptions);
1441
1570
  }),
1442
- $queryRaw: async (strings, ...values) => {
1443
- const { text, params } = flattenTemplate(strings, values, ph);
1444
- return (await runRaw(text, params)).rows;
1445
- },
1446
- $queryRawUnsafe: async (sql, ...params) => {
1447
- return (await runRaw(sql, params)).rows;
1448
- },
1449
- $executeRaw: async (strings, ...values) => {
1450
- const { text, params } = flattenTemplate(strings, values, ph);
1451
- return (await runRaw(text, params)).rowCount ?? 0;
1452
- },
1453
- $executeRawUnsafe: async (sql, ...params) => {
1454
- return (await runRaw(sql, params)).rowCount ?? 0;
1455
- },
1571
+ ...makeRawSurface(runRaw, ph),
1456
1572
  $connect: async () => { },
1457
1573
  $disconnect: async () => { },
1458
1574
  };
@@ -36,6 +36,13 @@
36
36
  * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
37
37
  * camelCase keys and Date coercion, because the child rows are parsed by the
38
38
  * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
39
+ * - **Identical KEY ORDER.** Object key order is observable output: callers
40
+ * `JSON.stringify` results into HTTP bodies, ETags and cache keys. The
41
+ * follow-up queries run concurrently, so the completion order of two sibling
42
+ * relations is a race; every relation key (and every `_count` entry) is
43
+ * therefore SEEDED up front in the order the join plan emits it, and the
44
+ * concurrent loads only overwrite already-existing keys. See
45
+ * {@link seedRelationKeys}.
39
46
  * - **Stitch keys never leak.** To stitch, the follow-up query must select the
40
47
  * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
41
48
  * loader adds those columns for the query and strips them from the returned
@@ -36,6 +36,13 @@
36
36
  * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
37
37
  * camelCase keys and Date coercion, because the child rows are parsed by the
38
38
  * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
39
+ * - **Identical KEY ORDER.** Object key order is observable output: callers
40
+ * `JSON.stringify` results into HTTP bodies, ETags and cache keys. The
41
+ * follow-up queries run concurrently, so the completion order of two sibling
42
+ * relations is a race; every relation key (and every `_count` entry) is
43
+ * therefore SEEDED up front in the order the join plan emits it, and the
44
+ * concurrent loads only overwrite already-existing keys. See
45
+ * {@link seedRelationKeys}.
39
46
  * - **Stitch keys never leak.** To stitch, the follow-up query must select the
40
47
  * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
41
48
  * loader adds those columns for the query and strips them from the returned
@@ -48,7 +55,7 @@
48
55
  */
49
56
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
50
57
  import { normalizeKeyColumns } from '../schema.js';
51
- import { isRelationPickOrderBy } from './filters.js';
58
+ import { isRelationPickOrderBy, sortedEntries } from './filters.js';
52
59
  import { ownLookup } from './utils.js';
53
60
  /**
54
61
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
@@ -268,30 +275,96 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
268
275
  rejectNestedPickOrder(withClause);
269
276
  if (parents.length === 0)
270
277
  return;
271
- // Sibling relations are independent (each writes only its own parent[relName]
272
- // and reads only parent keys), so load them concurrently, on a pool that's
273
- // real parallelism, inside a transaction pg queues them on the one connection.
274
- const loads = [];
275
- for (const [relName, spec] of Object.entries(withClause)) {
276
- if (!spec)
277
- continue;
278
- // Reserved `_count` key, one grouped COUNT(*) follow-up per counted relation.
279
- if (relName === '_count') {
280
- loads.push(loadCounts(ctx, parents, spec));
278
+ // Resolve the relations to load in the SAME order the join plan emits their
279
+ // columns (`sortedEntries` in buildSelectWithRelations), with the reserved
280
+ // `_count` key last.
281
+ const resolved = [];
282
+ for (const [relName, spec] of sortedEntries(withClause)) {
283
+ if (!spec || relName === '_count')
281
284
  continue;
282
- }
283
285
  const rel = ownLookup(ctx.parentMeta.relations, relName);
284
286
  if (!rel) {
285
287
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
286
288
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
287
289
  }
288
- const options = spec === true ? {} : spec;
290
+ resolved.push({ relName, rel, options: spec === true ? {} : spec });
291
+ }
292
+ // A falsy `_count` opts out, exactly like a falsy relation spec.
293
+ const countSpec = withClause._count;
294
+ const hasCount = Boolean(countSpec);
295
+ // Fix key order BEFORE anything is awaited: the loads below all write their
296
+ // key on completion, and completion order is a race between concurrent
297
+ // statements.
298
+ seedRelationKeys(parents, ctx.parentMeta, resolved, hasCount);
299
+ // Sibling relations are independent (each writes only its own parent[relName]
300
+ // and reads only parent keys), so load them concurrently, on a pool that's
301
+ // real parallelism, inside a transaction pg queues them on the one connection.
302
+ const loads = [];
303
+ for (const { relName, rel, options } of resolved) {
289
304
  loads.push(rel.type === 'manyToMany'
290
305
  ? loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path)
291
306
  : loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path));
292
307
  }
308
+ // Reserved `_count` key, one grouped COUNT(*) follow-up per counted relation.
309
+ if (hasCount)
310
+ loads.push(loadCounts(ctx, parents, countSpec));
293
311
  await Promise.all(loads);
294
312
  }
313
+ /**
314
+ * Give every relation key its final POSITION on each parent row before the
315
+ * concurrent follow-up queries start, so the stitched object serializes to the
316
+ * same bytes on every run and matches the join strategy.
317
+ *
318
+ * The reference order is the join plan's SELECT list: base columns, then the
319
+ * relation columns in sorted `with` order, then the `_count__<rel>` scalars
320
+ * (which `parseNestedRow` folds into a `_count` object appended last). A key
321
+ * that is already present is re-inserted rather than seeded: under the `'auto'`
322
+ * split some relations arrive resolved by the join plan and the rest are loaded
323
+ * here, and only a re-insert can interleave the two sets into one canonical
324
+ * order. Base (non-relation) columns are never touched.
325
+ */
326
+ function seedRelationKeys(parents, parentMeta, resolved, hasCount) {
327
+ // Placeholder per relation: the value an empty load produces, so a seeded key
328
+ // is never a shape the caller could not otherwise see.
329
+ const seeds = new Map();
330
+ for (const { relName, rel } of resolved) {
331
+ seeds.set(relName, rel.type === 'belongsTo' || rel.type === 'hasOne' ? 'one' : 'many');
332
+ }
333
+ // Relations the join plan already resolved onto these rows (the `'auto'`
334
+ // split's residual join). Rows all come from one query, so one row's shape
335
+ // answers for the batch.
336
+ const sample = parents[0];
337
+ if (sample) {
338
+ for (const relName of Object.keys(parentMeta.relations)) {
339
+ // Already resolved by the join plan: it needs a position, never a
340
+ // placeholder, so it is re-inserted rather than overwritten.
341
+ if (!seeds.has(relName) && Object.hasOwn(sample, relName))
342
+ seeds.set(relName, 'present');
343
+ }
344
+ }
345
+ const ordered = [...seeds.keys()].sort();
346
+ const countPresent = hasCount || (sample !== undefined && Object.hasOwn(sample, '_count'));
347
+ if (ordered.length === 0 && !countPresent)
348
+ return;
349
+ for (const parent of parents) {
350
+ for (const relName of ordered) {
351
+ if (Object.hasOwn(parent, relName)) {
352
+ // Re-insert so this key sits in canonical order, value untouched.
353
+ const existing = parent[relName];
354
+ delete parent[relName];
355
+ parent[relName] = existing;
356
+ }
357
+ else {
358
+ parent[relName] = seeds.get(relName) === 'many' ? [] : null;
359
+ }
360
+ }
361
+ if (!countPresent)
362
+ continue;
363
+ const existingCount = Object.hasOwn(parent, '_count') ? parent._count : undefined;
364
+ delete parent._count;
365
+ parent._count = existingCount ?? {};
366
+ }
367
+ }
295
368
  /**
296
369
  * hasMany / hasOne / belongsTo: one follow-up `SELECT ... WHERE childKey = ANY($1)`
297
370
  * (chunked), grouped by the correlation key and attached (array vs single-or-null).
@@ -474,15 +547,24 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
474
547
  * Load correlated `_count` values for the counted relations. One grouped
475
548
  * follow-up per relation (`SELECT key, COUNT(*) … WHERE key = ANY($1) GROUP BY
476
549
  * key`), attached onto each parent's `_count` object (0 when a parent has no
477
- * matching rows), byte-identical to the join strategy's `_count` output.
550
+ * matching rows), byte-identical to the join strategy's `_count` output,
551
+ * INCLUDING key order: the join plan emits its `_count__<rel>` columns in
552
+ * `resolveCountRelations` order, so every key is seeded here in that same order
553
+ * before the concurrent counts run and can only be overwritten in place.
478
554
  */
479
555
  async function loadCounts(ctx, parents, countSpec) {
480
556
  const rels = resolveCountRelations(ctx.parentMeta, countSpec);
481
- // Initialise every parent's `_count` up-front so the concurrent per-relation
482
- // loads below (each writing its own key) never race on the object creation.
557
+ // Initialise every parent's `_count` up-front, and seed every counted
558
+ // relation's key in `resolveCountRelations` order, so the concurrent
559
+ // per-relation loads below only ever overwrite a key that already exists.
560
+ // Without the seeded keys, insertion order is whichever COUNT statement
561
+ // finishes first and the same query serializes differently run to run.
483
562
  for (const parent of parents) {
484
563
  if (parent._count === undefined)
485
564
  parent._count = {};
565
+ const counts = parent._count;
566
+ for (const rel of rels)
567
+ counts[rel.name] = 0;
486
568
  }
487
569
  await Promise.all(rels.map((rel) => loadOneCount(ctx, parents, rel)));
488
570
  }
@@ -78,6 +78,82 @@ export declare const AUTO_TO_ONE_JOIN_MAX_ROWS: number;
78
78
  */
79
79
  export declare const AUTO_TO_ONE_JOIN_ROWS_MIN = 100;
80
80
  export declare const AUTO_TO_ONE_JOIN_ROWS_MAX = 100000;
81
+ /**
82
+ * WHY THIS IS A CONFIGURED LATENCY AND NOT A MEASURED ONE.
83
+ *
84
+ * The obvious next step from the formula above is to have the client measure
85
+ * its own round-trip time and derive the threshold at runtime. That was built
86
+ * and benchmarked, and it is NOT what ships, for a reason worth recording so it
87
+ * is not re-litigated blind:
88
+ *
89
+ * Every query's wall time is `roundTrip + serverWork`, and nothing in a
90
+ * duration distinguishes the two. An all-time MINIMUM reads a lucky packet
91
+ * (1.489ms on a link whose real per-statement cost was 2.862ms) and lands the
92
+ * threshold at half the true break-even. A MEDIAN over recent durations is
93
+ * accurate when the workload is cheap queries, but the workload being planned
94
+ * for here is precisely the expensive one: in the verification sweep the ring
95
+ * filled with 10-17ms relation queries, the estimate inflated, and `'auto'`
96
+ * held an 8,000-row query on the join plan, 1.30x slower than the better plan,
97
+ * WORSE than the fixed constant it replaced. Capping the median against a
98
+ * multiple of the floor mitigates it but turns the whole thing into a pair of
99
+ * magic numbers tuned against two synthetic links, which is the same mistake as
100
+ * a socket-tuned row count wearing a different hat.
101
+ *
102
+ * Round-trip time is a deployment fact, not a runtime discovery: it is fixed by
103
+ * where the app runs relative to the database, the operator knows it (or gets
104
+ * it from one `ping`), and it does not change between queries. So it is
105
+ * configuration. That also keeps plan selection deterministic, which matters
106
+ * for a library whose documented guarantee is that the strategy changes the
107
+ * plan and never the result.
108
+ */
109
+ /**
110
+ * The smallest plan-time parent-row bound at which `'auto'` moves a relation
111
+ * `_count` on a PROVEN-UNINDEXED probe to the grouped follow-up. Deliberately
112
+ * 2, i.e. "everything except a parent set provably bounded at one row".
113
+ *
114
+ * `_count` does NOT share the to-one break-even formula above, because its two
115
+ * plans do not differ by a small per-row penalty. Writing S for one scan of the
116
+ * child table and RTT for a round trip:
117
+ *
118
+ * inline(N) = N x S (a correlated COUNT(*) per parent row; see
119
+ * buildRelationCountExpr in relations.ts, the
120
+ * inline form is NOT a grouped scan)
121
+ * batched(N) = S + RTT (one `COUNT(*) ... GROUP BY fk` follow-up)
122
+ *
123
+ * so the crossover sits at `N = 1 + RTT/S` and, decisively, the two regrets are
124
+ * not comparable in kind:
125
+ *
126
+ * - choosing batched when inline would have won costs at most RTT, once, and
127
+ * ONLY at N = 1 (at N = 1 the difference is exactly RTT, and it shrinks to
128
+ * zero immediately after);
129
+ * - choosing inline when batched would have won costs (N - 1) x S, which is
130
+ * unbounded in the parent count.
131
+ *
132
+ * Measured on an UNINDEXED FK (PostgreSQL 16, 200K-row child table, 10K-row
133
+ * parent table, median of 11 interleaved reps per point, loopback;
134
+ * benchmarks/bench-count-strategy.ts):
135
+ *
136
+ * parents 1 2 3 5 20 100 1000 10000
137
+ * inline 4.3ms 8.3ms 12.3ms 20.1ms 79.2ms 417.5ms 3.06s 31.06s
138
+ * batched 5.2ms 4.8ms 4.8ms 5.2ms 6.1ms 11.5ms 9.9ms 28.4ms
139
+ * winner inline batched batched batched batched batched batched batched
140
+ * ratio 1.22x 1.73x 2.54x 3.84x 13.05x 36.42x 310.92x 1093.35x
141
+ *
142
+ * Inline wins exactly one cell, by 0.9ms, then loses the next by 1.73x and the
143
+ * last by 1093x. A skewed child distribution (half the rows on ten parents)
144
+ * moves nothing: same crossover at 2, same 1179x at 10,000. So the useful
145
+ * threshold is not a tunable row count, it is the one row where inline provably
146
+ * cannot lose. There is deliberately no config knob: the entire regret this rule
147
+ * can produce is one round trip, which is less than any knob would be worth, and
148
+ * `relationLoadStrategy: 'join'` already forces the single-statement plan.
149
+ *
150
+ * This applies ONLY to a probe the introspected index metadata PROVES unindexed.
151
+ * An INDEXED `_count` stays inline at every size measured (inline wins 1.30x to
152
+ * 2.06x from 1 to 10,000 parents, because the per-parent subquery collapses to
153
+ * an index-only scan costing ~0.001ms), and the partition below never demotes
154
+ * it.
155
+ */
156
+ export declare const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
81
157
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, } from './deferred.js';
82
158
  import type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './deferred.js';
83
159
  export declare class QueryInterface<T extends object, R extends object = {}> {
@@ -225,6 +301,46 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
225
301
  */
226
302
  private readonly ctx;
227
303
  constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
304
+ /**
305
+ * Dev-only, once per table: the columns whose database type is absent from
306
+ * BOTH the column entry and the table-level type maps, the residual case
307
+ * after the two-source resolution above.
308
+ *
309
+ * The set is deliberately every untyped column, not the `dateColumns`
310
+ * members. An unresolved type is precisely the state in which Turbine cannot
311
+ * say WHICH kind of column it is, so restricting the scan to `dateColumns`
312
+ * got it wrong in both directions: that set carries `timestamptz` (whose
313
+ * bind was never affected, since binding the `Date` is the correct thing to
314
+ * do for it) and omits `time` / `timetz` entirely (deliberately, see
315
+ * `timeOfDayKind` in schema.ts), which is the one kind that fails LOUDLY
316
+ * rather than silently. The message therefore names the columns and states
317
+ * what each kind does, rather than asserting a kind it cannot know.
318
+ *
319
+ * What is actually at stake per kind, all of it `coerceWriteValue` returning
320
+ * the bound `Date` by identity for want of a type:
321
+ * - zone-less `date` / `timestamp`: the driver serializes with the
322
+ * PROCESS's offset, so the column stores local calendar fields. Nothing
323
+ * surfaces at runtime, and a turbine-only round trip reads the same value
324
+ * back (the read path shifts by the same offset), so only an outside
325
+ * reader sees the drift.
326
+ * - `time` / `timetz`: the driver serializes a full ISO timestamp, which
327
+ * Postgres rejects with `22007 invalid input syntax for type time`.
328
+ * - `timestamptz` and every non-temporal type: unaffected.
329
+ *
330
+ * PostgreSQL only: the UTC bind rewrite is Postgres-gated (see
331
+ * `utcDateTimeWrites` in writes.ts), so on the other engines a missing type
332
+ * changes nothing about how a `Date` is bound. Suppressed under
333
+ * `NODE_ENV=production` like the other dev diagnostics and deduped through
334
+ * the shared registry, so a hot table logs one line for the process.
335
+ *
336
+ * Cannot throw on odd metadata: it walks `tableMeta.columns`, the array the
337
+ * constructor loop above has already iterated (and that client.ts validates
338
+ * as an array), never `dateColumns`, which is a `Set` in every first-party
339
+ * metadata path but arrives as a plain object from JSON-round-tripped
340
+ * metadata. A dev-only diagnostic that crashes a shape production would serve
341
+ * is worse than the bug it reports.
342
+ */
343
+ private warnUntypedColumns;
228
344
  /** Quote an identifier through the active SQL dialect. */
229
345
  private q;
230
346
  /** Return the active dialect's placeholder for a 1-indexed parameter position. */
@@ -427,6 +543,16 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
427
543
  * `findFirst` pass `false` explicitly (their parent set is one row).
428
544
  */
429
545
  private autoParentSetLarge;
546
+ /**
547
+ * The plan-time UPPER BOUND on the parent-row count, or `undefined` when the
548
+ * query is unbounded. This is the raw number behind
549
+ * {@link autoParentSetLarge}; the `_count` rule needs the number itself
550
+ * because its threshold ({@link AUTO_COUNT_BATCH_MIN_PARENT_ROWS}) is two
551
+ * rows rather than the to-one break-even. `findUnique` / `findFirst` pass `1`
552
+ * directly: their parent set is one row as a matter of the statement's shape,
553
+ * not an estimate.
554
+ */
555
+ private autoParentBound;
430
556
  /**
431
557
  * The parent-row count at which `'auto'` stops preferring the single-statement
432
558
  * join for a to-one relation.
@@ -465,11 +591,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
465
591
  *
466
592
  * Everything else (indexed to-many, composite-key, unknown) stays in `joinWith`
467
593
  * (byte-identical join). The reserved `_count` key falls back on rule 1 only,
468
- * and only for a large parent set: an inline `_count` is one correlated
469
- * `COUNT(*)` per parent row, so the grouped follow-up wins exactly when there
470
- * are many parents, while for a handful of parents the extra round-trip costs
471
- * more than the repeated (small) scans. Also returns the engaged relations for
472
- * the dev note.
594
+ * and on its OWN size rule: an inline `_count` is one correlated `COUNT(*)`
595
+ * per parent row over an unindexed child table, so the grouped follow-up wins
596
+ * from {@link AUTO_COUNT_BATCH_MIN_PARENT_ROWS} parent rows upward and inline
597
+ * is preferred only when the parent set is provably bounded below that. Also
598
+ * returns the engaged relations for the dev note.
473
599
  */
474
600
  private partitionWithForAuto;
475
601
  /**