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
@@ -26,7 +26,10 @@
26
26
  * , the un-awaited delegate calls defer to Turbine's `build*()` methods and
27
27
  * run atomically through the core batch `$transaction([...])` path.
28
28
  * - **Raw SQL**: `$queryRaw` / `$executeRaw` tagged templates (with
29
- * `Prisma.sql`-style nested-fragment flattening) and the `*Unsafe` variants.
29
+ * `Prisma.sql`-style nested-fragment flattening) and the `*Unsafe` variants,
30
+ * on the client AND on the transaction client, where they run on the
31
+ * transaction's own connection so a mixed raw + delegate `$transaction` stays
32
+ * atomic.
30
33
  * - **Result reshaping**: `_count` objects keyed back to Prisma relation names,
31
34
  * and to-one relations surfaced as `object | null`.
32
35
  *
@@ -86,11 +89,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
86
89
  exports.CLIENT_RESERVED_KEYS = exports.COMPAT_DEFERRED = exports.Prisma = void 0;
87
90
  exports.createPrismaCompatClient = createPrismaCompatClient;
88
91
  const errors_js_1 = require("./errors.js");
92
+ const nested_write_js_1 = require("./nested-write.js");
89
93
  const warn_registry_js_1 = require("./query/warn-registry.js");
90
94
  // ---------------------------------------------------------------------------
91
95
  // Prisma.sql-style raw fragments (local, minimal, never imports @prisma/client)
92
96
  // ---------------------------------------------------------------------------
93
- const SQL_FRAGMENT = Symbol.for('turbine.prismaCompat.sqlFragment');
97
+ /**
98
+ * Brand marking an object as a raw-SQL fragment whose `strings` are spliced
99
+ * VERBATIM into the emitted statement (see `flattenTemplate`). Deliberately a
100
+ * module-private `Symbol()` and NOT `Symbol.for(...)`: a registry symbol is
101
+ * reachable by name from anywhere in the process, so any dependency could mint
102
+ * an object that flattens as trusted SQL. With a private symbol the only way to
103
+ * obtain a fragment is to call `Prisma.sql` / `Prisma.join` / `Prisma.raw` from
104
+ * this module.
105
+ *
106
+ * The fragment check is fail-CLOSED: an object that does not carry this exact
107
+ * symbol is bound as a `$N` parameter, never spliced. That is also what makes
108
+ * the (contrived) dual-package case safe rather than dangerous, a fragment
109
+ * built by the ESM copy of this module and executed by the CJS copy binds as a
110
+ * parameter instead of composing.
111
+ */
112
+ const SQL_FRAGMENT = Symbol('turbine.prismaCompat.sqlFragment');
94
113
  function isSqlFragment(x) {
95
114
  return typeof x === 'object' && x !== null && x[SQL_FRAGMENT] === true;
96
115
  }
@@ -1069,6 +1088,40 @@ async function upsertLookupFirst(qi, t) {
1069
1088
  return qi.update({ where: t.where, data: t.update });
1070
1089
  return qi.create({ data: t.create });
1071
1090
  }
1091
+ /**
1092
+ * The row shapes a translated `createMany` has to insert, as contiguous runs
1093
+ * that each name the same fields (see {@link createManyShapeRuns}).
1094
+ *
1095
+ * Prisma accepts rows of DIFFERENT shapes in one `createMany` and emits a single
1096
+ * INSERT over the UNION of the named columns, binding its own schema-level
1097
+ * `@default` for a field a row omits and `null` for one it has no default for
1098
+ * (verified against @prisma/client 7.9.0 on PostgreSQL 16). Core `createMany`
1099
+ * refuses a mixed batch instead, because it has no per-cell DEFAULT form on
1100
+ * every engine and would otherwise write NULL over the column's default. Runs
1101
+ * bridge the two: one core `createMany` per run, all of them inside one
1102
+ * transaction, so a ported call site keeps working and the `{ count }` it gets
1103
+ * back is the number of rows actually inserted.
1104
+ *
1105
+ * The one place this is not byte-for-byte Prisma is a column whose DEFAULT lives
1106
+ * in the DATABASE and not in the Prisma schema: Prisma binds null there and
1107
+ * loses the default, a run lets the column default apply. Prisma's own answer is
1108
+ * the lossy one, so the divergence only ever adds back a value the caller never
1109
+ * asked to overwrite.
1110
+ */
1111
+ function createManyRunsOf(t) {
1112
+ return (0, nested_write_js_1.createManyShapeRuns)((Array.isArray(t.data) ? t.data : []));
1113
+ }
1114
+ /** One core `createMany` per run, in order, summing Prisma's `{ count }`. */
1115
+ async function createManyByRun(qi, t, runs) {
1116
+ let count = 0;
1117
+ for (const run of runs) {
1118
+ const args = { data: run };
1119
+ if (t.skipDuplicates)
1120
+ args.skipDuplicates = true;
1121
+ count += (await qi.createMany(args)).length;
1122
+ }
1123
+ return { count };
1124
+ }
1072
1125
  function makeDelegate(ctx, mm, getQI, runInTx) {
1073
1126
  const pe = ctx.options.prismaErrorCodes;
1074
1127
  // Build a lazy Prisma-style promise for one delegate call. Crucially, the
@@ -1144,7 +1197,23 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1144
1197
  if (args.skipDuplicates)
1145
1198
  t.skipDuplicates = true;
1146
1199
  return t;
1147
- }, (qi, t) => qi.createMany(t).then((r) => ({ count: r.length })), { build: (qi, t) => qi.buildCreateMany(t), reshape: (raw) => ({ count: raw.length }) }),
1200
+ }, (qi, t) => {
1201
+ // Rows that all name the same fields are one statement, exactly as
1202
+ // before. A mixed batch, which Prisma accepts, becomes one createMany
1203
+ // per contiguous same-shape run inside ONE transaction, so the call
1204
+ // stays all-or-nothing and the count is the total actually inserted.
1205
+ const runs = createManyRunsOf(t);
1206
+ if (runs.length <= 1)
1207
+ return qi.createMany(t).then((r) => ({ count: r.length }));
1208
+ return runInTx((table) => createManyByRun(table(mm.table), t, runs));
1209
+ }, {
1210
+ build: (qi, t) => qi.buildCreateMany(t),
1211
+ reshape: (raw) => ({ count: raw.length }),
1212
+ // A mixed batch is more than one statement, so the array
1213
+ // $transaction([...]) form runs the whole array sequentially in a tx.
1214
+ nested: (t) => createManyRunsOf(t).length > 1,
1215
+ execInTx: (table, t) => createManyByRun(table(mm.table), t, createManyRunsOf(t)),
1216
+ }),
1148
1217
  update: (args) => defer(() => {
1149
1218
  const a = requireWhere(args, 'update');
1150
1219
  return {
@@ -1259,6 +1328,31 @@ function flattenTemplate(strings, values, ph) {
1259
1328
  append(strings, values);
1260
1329
  return { text, params };
1261
1330
  }
1331
+ /**
1332
+ * Build the four Prisma raw methods over ONE executor. Both the client-level and
1333
+ * the transaction-scoped surfaces come from this function, so they cannot drift:
1334
+ * same fragment flattening, same placeholder generation, same return shapes
1335
+ * (`$queryRaw` → rows, `$executeRaw` → affected-row count). The only difference
1336
+ * is which connection the executor runs on.
1337
+ */
1338
+ function makeRawSurface(exec, ph) {
1339
+ return {
1340
+ $queryRaw: async (strings, ...values) => {
1341
+ const { text, params } = flattenTemplate(strings, values, ph);
1342
+ return (await exec(text, params)).rows;
1343
+ },
1344
+ $queryRawUnsafe: async (sql, ...params) => {
1345
+ return (await exec(sql, params)).rows;
1346
+ },
1347
+ $executeRaw: async (strings, ...values) => {
1348
+ const { text, params } = flattenTemplate(strings, values, ph);
1349
+ return (await exec(text, params)).rowCount ?? 0;
1350
+ },
1351
+ $executeRawUnsafe: async (sql, ...params) => {
1352
+ return (await exec(sql, params)).rowCount ?? 0;
1353
+ },
1354
+ };
1355
+ }
1262
1356
  // ---------------------------------------------------------------------------
1263
1357
  // createPrismaCompatClient
1264
1358
  // ---------------------------------------------------------------------------
@@ -1314,6 +1408,11 @@ function junctionModels(ctx, map, tableToModel) {
1314
1408
  if (alias)
1315
1409
  seen.add(alias);
1316
1410
  }
1411
+ // Junction names already registered by an earlier relation. A many-to-many
1412
+ // pair is normally DECLARED ON BOTH SIDES, so the same junction table is
1413
+ // reached twice; the second visit must be a silent no-op, not a collision (it
1414
+ // would be colliding with its own registration).
1415
+ const registered = new Set();
1317
1416
  for (const table of Object.values(ctx.schema.tables)) {
1318
1417
  for (const rel of Object.values(table.relations ?? {})) {
1319
1418
  if (rel.type !== 'manyToMany')
@@ -1321,15 +1420,18 @@ function junctionModels(ctx, map, tableToModel) {
1321
1420
  const name = rel.through?.table;
1322
1421
  if (!name || !ctx.schema.tables[name] || tableToModel.has(name))
1323
1422
  continue;
1423
+ if (registered.has(name))
1424
+ continue;
1324
1425
  if (seen.has(name)) {
1325
1426
  if (process.env.NODE_ENV !== 'production' && (0, warn_registry_js_1.shouldWarnOnce)(JUNCTION_WARN_NS, name)) {
1326
1427
  console.warn(`[turbine] prisma-compat: the many-to-many junction table "${name}" collides with an ` +
1327
1428
  'existing client member of the same name, so no junction accessor was created for it. ' +
1328
- 'Reach its rows through $queryRaw / $executeRaw, or through the owning model relation.');
1429
+ 'Reach its rows through the owning model relation (a nested connect / disconnect / set on the ' +
1430
+ 'related model), or through $queryRaw / $executeRaw.');
1329
1431
  }
1330
1432
  continue;
1331
1433
  }
1332
- seen.add(name);
1434
+ registered.add(name);
1333
1435
  out.push([name, { table: name, accessor: name, fields: {}, relations: {}, compoundUniques: {} }]);
1334
1436
  }
1335
1437
  }
@@ -1392,6 +1494,27 @@ function createPrismaCompatClient(client, map, options = {}) {
1392
1494
  throw decorate((0, errors_js_1.wrapPgError)(err), ctx.options.prismaErrorCodes);
1393
1495
  }
1394
1496
  };
1497
+ /**
1498
+ * The same executor bound to a transaction's OWN connection. A raw statement
1499
+ * that quietly ran on a pool connection would leave the caller unable to tell
1500
+ * that its writes were outside the transaction, so a transaction client that
1501
+ * cannot execute raw SQL refuses rather than falling back to the pool.
1502
+ * `wrapPgError` is idempotent (it returns an already-typed TurbineError
1503
+ * untouched), so the error shape matches the pool path exactly.
1504
+ */
1505
+ const txRunRaw = (tx) => async (text, params) => {
1506
+ if (typeof tx.rawQuery !== 'function') {
1507
+ throw decorate(new errors_js_1.ValidationError('[turbine] prisma-compat: raw SQL inside $transaction needs a transaction client that can execute it ' +
1508
+ '(core TransactionClient.rawQuery). Refusing to run the statement on a pool connection, which would ' +
1509
+ 'silently place it outside the transaction.'), ctx.options.prismaErrorCodes);
1510
+ }
1511
+ try {
1512
+ return await tx.rawQuery(text, params);
1513
+ }
1514
+ catch (err) {
1515
+ throw decorate((0, errors_js_1.wrapPgError)(err), ctx.options.prismaErrorCodes);
1516
+ }
1517
+ };
1395
1518
  const base = {
1396
1519
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1397
1520
  $transaction: ((arg, txOptions) => {
@@ -1440,23 +1563,16 @@ function createPrismaCompatClient(client, map, options = {}) {
1440
1563
  txDelegates[alias] = txDelegates[prismaModel];
1441
1564
  }
1442
1565
  }
1443
- return fn(txDelegates);
1566
+ // Raw SQL on the transaction's own connection. Prisma's tx client
1567
+ // carries these four, and code that mixes `$transaction` with raw SQL is
1568
+ // the common case in a migrated codebase. No model can shadow them: a
1569
+ // Prisma model name cannot start with `$`, and junction accessors skip
1570
+ // every CLIENT_RESERVED_KEYS name.
1571
+ const txClient = { ...txDelegates, ...makeRawSurface(txRunRaw(tx), ph) };
1572
+ return fn(txClient);
1444
1573
  }, txOptions);
1445
1574
  }),
1446
- $queryRaw: async (strings, ...values) => {
1447
- const { text, params } = flattenTemplate(strings, values, ph);
1448
- return (await runRaw(text, params)).rows;
1449
- },
1450
- $queryRawUnsafe: async (sql, ...params) => {
1451
- return (await runRaw(sql, params)).rows;
1452
- },
1453
- $executeRaw: async (strings, ...values) => {
1454
- const { text, params } = flattenTemplate(strings, values, ph);
1455
- return (await runRaw(text, params)).rowCount ?? 0;
1456
- },
1457
- $executeRawUnsafe: async (sql, ...params) => {
1458
- return (await runRaw(sql, params)).rowCount ?? 0;
1459
- },
1575
+ ...makeRawSurface(runRaw, ph),
1460
1576
  $connect: async () => { },
1461
1577
  $disconnect: async () => { },
1462
1578
  };
@@ -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
@@ -37,6 +37,13 @@
37
37
  * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
38
38
  * camelCase keys and Date coercion, because the child rows are parsed by the
39
39
  * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
40
+ * - **Identical KEY ORDER.** Object key order is observable output: callers
41
+ * `JSON.stringify` results into HTTP bodies, ETags and cache keys. The
42
+ * follow-up queries run concurrently, so the completion order of two sibling
43
+ * relations is a race; every relation key (and every `_count` entry) is
44
+ * therefore SEEDED up front in the order the join plan emits it, and the
45
+ * concurrent loads only overwrite already-existing keys. See
46
+ * {@link seedRelationKeys}.
40
47
  * - **Stitch keys never leak.** To stitch, the follow-up query must select the
41
48
  * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
42
49
  * loader adds those columns for the query and strips them from the returned
@@ -277,30 +284,96 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
277
284
  rejectNestedPickOrder(withClause);
278
285
  if (parents.length === 0)
279
286
  return;
280
- // Sibling relations are independent (each writes only its own parent[relName]
281
- // and reads only parent keys), so load them concurrently, on a pool that's
282
- // real parallelism, inside a transaction pg queues them on the one connection.
283
- const loads = [];
284
- for (const [relName, spec] of Object.entries(withClause)) {
285
- if (!spec)
286
- continue;
287
- // Reserved `_count` key, one grouped COUNT(*) follow-up per counted relation.
288
- if (relName === '_count') {
289
- loads.push(loadCounts(ctx, parents, spec));
287
+ // Resolve the relations to load in the SAME order the join plan emits their
288
+ // columns (`sortedEntries` in buildSelectWithRelations), with the reserved
289
+ // `_count` key last.
290
+ const resolved = [];
291
+ for (const [relName, spec] of (0, filters_js_1.sortedEntries)(withClause)) {
292
+ if (!spec || relName === '_count')
290
293
  continue;
291
- }
292
294
  const rel = (0, utils_js_1.ownLookup)(ctx.parentMeta.relations, relName);
293
295
  if (!rel) {
294
296
  throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
295
297
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
296
298
  }
297
- const options = spec === true ? {} : spec;
299
+ resolved.push({ relName, rel, options: spec === true ? {} : spec });
300
+ }
301
+ // A falsy `_count` opts out, exactly like a falsy relation spec.
302
+ const countSpec = withClause._count;
303
+ const hasCount = Boolean(countSpec);
304
+ // Fix key order BEFORE anything is awaited: the loads below all write their
305
+ // key on completion, and completion order is a race between concurrent
306
+ // statements.
307
+ seedRelationKeys(parents, ctx.parentMeta, resolved, hasCount);
308
+ // Sibling relations are independent (each writes only its own parent[relName]
309
+ // and reads only parent keys), so load them concurrently, on a pool that's
310
+ // real parallelism, inside a transaction pg queues them on the one connection.
311
+ const loads = [];
312
+ for (const { relName, rel, options } of resolved) {
298
313
  loads.push(rel.type === 'manyToMany'
299
314
  ? loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path)
300
315
  : loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path));
301
316
  }
317
+ // Reserved `_count` key, one grouped COUNT(*) follow-up per counted relation.
318
+ if (hasCount)
319
+ loads.push(loadCounts(ctx, parents, countSpec));
302
320
  await Promise.all(loads);
303
321
  }
322
+ /**
323
+ * Give every relation key its final POSITION on each parent row before the
324
+ * concurrent follow-up queries start, so the stitched object serializes to the
325
+ * same bytes on every run and matches the join strategy.
326
+ *
327
+ * The reference order is the join plan's SELECT list: base columns, then the
328
+ * relation columns in sorted `with` order, then the `_count__<rel>` scalars
329
+ * (which `parseNestedRow` folds into a `_count` object appended last). A key
330
+ * that is already present is re-inserted rather than seeded: under the `'auto'`
331
+ * split some relations arrive resolved by the join plan and the rest are loaded
332
+ * here, and only a re-insert can interleave the two sets into one canonical
333
+ * order. Base (non-relation) columns are never touched.
334
+ */
335
+ function seedRelationKeys(parents, parentMeta, resolved, hasCount) {
336
+ // Placeholder per relation: the value an empty load produces, so a seeded key
337
+ // is never a shape the caller could not otherwise see.
338
+ const seeds = new Map();
339
+ for (const { relName, rel } of resolved) {
340
+ seeds.set(relName, rel.type === 'belongsTo' || rel.type === 'hasOne' ? 'one' : 'many');
341
+ }
342
+ // Relations the join plan already resolved onto these rows (the `'auto'`
343
+ // split's residual join). Rows all come from one query, so one row's shape
344
+ // answers for the batch.
345
+ const sample = parents[0];
346
+ if (sample) {
347
+ for (const relName of Object.keys(parentMeta.relations)) {
348
+ // Already resolved by the join plan: it needs a position, never a
349
+ // placeholder, so it is re-inserted rather than overwritten.
350
+ if (!seeds.has(relName) && Object.hasOwn(sample, relName))
351
+ seeds.set(relName, 'present');
352
+ }
353
+ }
354
+ const ordered = [...seeds.keys()].sort();
355
+ const countPresent = hasCount || (sample !== undefined && Object.hasOwn(sample, '_count'));
356
+ if (ordered.length === 0 && !countPresent)
357
+ return;
358
+ for (const parent of parents) {
359
+ for (const relName of ordered) {
360
+ if (Object.hasOwn(parent, relName)) {
361
+ // Re-insert so this key sits in canonical order, value untouched.
362
+ const existing = parent[relName];
363
+ delete parent[relName];
364
+ parent[relName] = existing;
365
+ }
366
+ else {
367
+ parent[relName] = seeds.get(relName) === 'many' ? [] : null;
368
+ }
369
+ }
370
+ if (!countPresent)
371
+ continue;
372
+ const existingCount = Object.hasOwn(parent, '_count') ? parent._count : undefined;
373
+ delete parent._count;
374
+ parent._count = existingCount ?? {};
375
+ }
376
+ }
304
377
  /**
305
378
  * hasMany / hasOne / belongsTo: one follow-up `SELECT ... WHERE childKey = ANY($1)`
306
379
  * (chunked), grouped by the correlation key and attached (array vs single-or-null).
@@ -483,15 +556,24 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
483
556
  * Load correlated `_count` values for the counted relations. One grouped
484
557
  * follow-up per relation (`SELECT key, COUNT(*) … WHERE key = ANY($1) GROUP BY
485
558
  * key`), attached onto each parent's `_count` object (0 when a parent has no
486
- * matching rows), byte-identical to the join strategy's `_count` output.
559
+ * matching rows), byte-identical to the join strategy's `_count` output,
560
+ * INCLUDING key order: the join plan emits its `_count__<rel>` columns in
561
+ * `resolveCountRelations` order, so every key is seeded here in that same order
562
+ * before the concurrent counts run and can only be overwritten in place.
487
563
  */
488
564
  async function loadCounts(ctx, parents, countSpec) {
489
565
  const rels = resolveCountRelations(ctx.parentMeta, countSpec);
490
- // Initialise every parent's `_count` up-front so the concurrent per-relation
491
- // loads below (each writing its own key) never race on the object creation.
566
+ // Initialise every parent's `_count` up-front, and seed every counted
567
+ // relation's key in `resolveCountRelations` order, so the concurrent
568
+ // per-relation loads below only ever overwrite a key that already exists.
569
+ // Without the seeded keys, insertion order is whichever COUNT statement
570
+ // finishes first and the same query serializes differently run to run.
492
571
  for (const parent of parents) {
493
572
  if (parent._count === undefined)
494
573
  parent._count = {};
574
+ const counts = parent._count;
575
+ for (const rel of rels)
576
+ counts[rel.name] = 0;
495
577
  }
496
578
  await Promise.all(rels.map((rel) => loadOneCount(ctx, parents, rel)));
497
579
  }
@@ -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
  /**