turbine-orm 0.24.0 → 0.26.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.
@@ -12,6 +12,7 @@ exports.escapeLike = escapeLike;
12
12
  exports.fnv1a64Hex = fnv1a64Hex;
13
13
  exports.sqlToPreparedName = sqlToPreparedName;
14
14
  exports.buildCorrelation = buildCorrelation;
15
+ exports.parseDbDate = parseDbDate;
15
16
  // ---------------------------------------------------------------------------
16
17
  // Identifier quoting — prevents SQL injection via table/column names
17
18
  // ---------------------------------------------------------------------------
@@ -139,3 +140,38 @@ function buildCorrelation(leftRef, leftColumns, rightRef, rightColumns) {
139
140
  .map((col, i) => `${leftRef}.${quoteIdent(col)} = ${rightRef}.${quoteIdent(rightCols[i])}`)
140
141
  .join(' AND ');
141
142
  }
143
+ /**
144
+ * Matches an explicit timezone suffix on a date-time string: a trailing `Z`
145
+ * or a `±HH`, `±HHMM`, `±HH:MM` offset.
146
+ */
147
+ const TZ_SUFFIX_RE = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/;
148
+ /**
149
+ * Parse a database date-time string deterministically.
150
+ *
151
+ * Postgres `timestamp` (without time zone) values arrive with no offset —
152
+ * both from the driver and from `json_agg`/`json_build_object` subquery JSON
153
+ * (`2026-07-07T17:15:41.896`). JavaScript's `new Date()` interprets such
154
+ * strings in the SERVER'S LOCAL TIME ZONE, so the same row parses to a
155
+ * different instant depending on where the code runs. The universal ORM
156
+ * convention (Prisma, Rails, Django) is to treat offset-less timestamps as
157
+ * UTC — that is also the only interpretation that round-trips: Postgres
158
+ * stores exactly the wall-clock fields you sent.
159
+ *
160
+ * Strings that carry an explicit offset (`timestamptz` output) are parsed
161
+ * as-is.
162
+ */
163
+ function parseDbDate(value) {
164
+ // Date-only values (`2026-07-07`, from `date` columns in json_agg output)
165
+ // have no time to zone-pin — and their `-07` tail must not be read as an
166
+ // offset. JS parses bare ISO dates as UTC midnight already.
167
+ if (!value.includes(':'))
168
+ return new Date(value);
169
+ if (TZ_SUFFIX_RE.test(value)) {
170
+ // JS Date can't parse colon-less (`-0430`) or bare-hour (`+02`) offsets —
171
+ // normalize both to `±HH:MM`. Postgres emits the bare-hour form for
172
+ // whole-hour zones in some text outputs.
173
+ return new Date(value.replace(/([+-]\d{2})(\d{2})$/, '$1:$2').replace(/([+-]\d{2})$/, '$1:00'));
174
+ }
175
+ // normalize `YYYY-MM-DD HH:MM:SS` (driver form) to ISO before pinning UTC
176
+ return new Date(`${value.replace(' ', 'T')}Z`);
177
+ }
@@ -89,12 +89,29 @@ const client_js_1 = require("./client.js");
89
89
  * manage its own `pg.Pool`. The caller retains ownership of the pool's
90
90
  * lifecycle — `db.disconnect()` is a no-op.
91
91
  *
92
+ * ## Typed table accessors
93
+ *
94
+ * By default `turbineHttp` returns the base {@link TurbineClient}, so you
95
+ * reach tables through `db.table('users')`. To get the *generated*, fully
96
+ * typed accessors (`db.users.findMany()`) — identical to what the TCP-path
97
+ * `turbine()` factory gives you — pass your generated client type as the
98
+ * `TClient` type argument. The runtime object is the same; the generated
99
+ * subclass only adds `declare readonly` accessor typings, and the base
100
+ * constructor already creates those accessors at runtime for every table in
101
+ * the schema, so the assertion is sound (not a lie about the shape).
102
+ *
103
+ * This closes the "identical typed code across transports" gap: the edge
104
+ * client is now as typed as the direct one, with no `as` casts at the call
105
+ * site.
106
+ *
107
+ * @typeParam TClient - The generated `TurbineClient` subclass (from
108
+ * `./generated/turbine`). Defaults to the base client for back-compat.
92
109
  * @param pool - Any pg-compatible pool (Neon, Vercel Postgres, etc.)
93
110
  * @param schema - Introspected or hand-written schema metadata
94
111
  * @param options - Optional logging / defaultLimit / warnOnUnlimited
95
- * @returns A TurbineClient instance
112
+ * @returns A TurbineClient instance (typed as `TClient`)
96
113
  *
97
- * @example
114
+ * @example Untyped (back-compat) — reach tables via `db.table(...)`
98
115
  * ```ts
99
116
  * import { Pool } from '@neondatabase/serverless';
100
117
  * import { turbineHttp } from 'turbine-orm/serverless';
@@ -102,10 +119,25 @@ const client_js_1 = require("./client.js");
102
119
  *
103
120
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
104
121
  * const db = turbineHttp(pool, SCHEMA);
105
- *
106
122
  * const users = await db.table('users').findMany({ limit: 10 });
107
123
  * ```
124
+ *
125
+ * @example Typed — generated accessors, identical to the TCP client
126
+ * ```ts
127
+ * import { Pool } from '@neondatabase/serverless';
128
+ * import { turbineHttp } from 'turbine-orm/serverless';
129
+ * import type { TurbineClient } from './generated/turbine';
130
+ * import { SCHEMA } from './generated/turbine/metadata.js';
131
+ *
132
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
133
+ * const db = turbineHttp<TurbineClient>(pool, SCHEMA);
134
+ * const users = await db.users.findMany({ limit: 10 }); // fully typed, no cast
135
+ * ```
108
136
  */
109
137
  function turbineHttp(pool, schema, options = {}) {
138
+ // The generated subclass only layers `declare readonly` accessor typings
139
+ // over the base client; the base constructor materializes those same
140
+ // accessors at runtime (Object.defineProperty per schema table). So the
141
+ // returned instance genuinely has TClient's shape — the assertion is safe.
110
142
  return new client_js_1.TurbineClient({ pool, ...options }, schema);
111
143
  }
@@ -12,6 +12,7 @@
12
12
  * turbine migrate status — Show migration status
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
+ * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
15
16
  * turbine studio — Launch local read-only web UI
16
17
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
17
18
  *
package/dist/cli/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * turbine migrate status — Show migration status
13
13
  * turbine seed — Run seed file
14
14
  * turbine status — Show schema summary
15
+ * turbine doctor — Check relations for missing FK indexes (--fix emits migration)
15
16
  * turbine studio — Launch local read-only web UI
16
17
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
17
18
  *
@@ -24,6 +25,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writ
24
25
  import { dirname, relative, resolve } from 'node:path';
25
26
  import { pathToFileURL } from 'node:url';
26
27
  import { generate } from '../generate.js';
28
+ import { findMissingRelationIndexes } from '../index-advisor.js';
27
29
  import { introspect } from '../introspect.js';
28
30
  import { schemaDiff, schemaPush } from '../schema-sql.js';
29
31
  import { configTemplate, findConfigFile, loadConfig, looksLikeSchemaFilePath, resolveConfig } from './config.js';
@@ -88,6 +90,9 @@ function parseArgs() {
88
90
  case '--allow-empty':
89
91
  result.allowEmpty = true;
90
92
  break;
93
+ case '--fix':
94
+ result.fix = true;
95
+ break;
91
96
  case '--force':
92
97
  case '-f':
93
98
  result.force = true;
@@ -974,6 +979,80 @@ async function cmdStatus(_args, config) {
974
979
  }
975
980
  }
976
981
  // ---------------------------------------------------------------------------
982
+ // Command: doctor — relation/index health check
983
+ // ---------------------------------------------------------------------------
984
+ async function cmdDoctor(args, config) {
985
+ banner();
986
+ const url = requireUrl(config);
987
+ label('Database', redactUrl(url));
988
+ label('Schema', config.schema);
989
+ newline();
990
+ const spinner = new Spinner('Introspecting database').start();
991
+ const schema = await introspect({
992
+ connectionString: url,
993
+ schema: config.schema,
994
+ include: config.include.length ? config.include : undefined,
995
+ exclude: config.exclude.length ? config.exclude : undefined,
996
+ });
997
+ const missing = findMissingRelationIndexes(schema);
998
+ if (missing.length === 0) {
999
+ spinner.succeed('Every relation probe is backed by an index');
1000
+ newline();
1001
+ return;
1002
+ }
1003
+ spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
1004
+ warn(`Found ${bold(String(missing.length))} unindexed relation probe(s)`);
1005
+ newline();
1006
+ // Row counts put the findings in severity order: a missing index on a 300-row
1007
+ // table is noise; on a 300K-row table it is the whole page load.
1008
+ const rowCounts = new Map();
1009
+ {
1010
+ const { Pool } = (await import('pg')).default;
1011
+ const pool = new Pool({ connectionString: url, max: 1 });
1012
+ try {
1013
+ const tables = [...new Set(missing.map((m) => m.table))];
1014
+ const res = await pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
1015
+ FROM pg_class c
1016
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1017
+ WHERE n.nspname = $1 AND c.relname = ANY($2)`, [config.schema, tables]);
1018
+ for (const row of res.rows)
1019
+ rowCounts.set(row.relname, Math.max(0, Number(row.reltuples)));
1020
+ }
1021
+ finally {
1022
+ await pool.end();
1023
+ }
1024
+ }
1025
+ missing.sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
1026
+ console.log(` ${dim('Turbine loads relations as correlated subqueries — the child table is probed')}`);
1027
+ console.log(` ${dim('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
1028
+ newline();
1029
+ for (const m of missing) {
1030
+ const rows = rowCounts.get(m.table);
1031
+ const rowsLabel = rows !== undefined ? `~${rows.toLocaleString()} rows` : 'row count unknown';
1032
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(m.table))} ${dim(`(${m.columns.join(', ')})`)} ${gray(rowsLabel)}`);
1033
+ for (const p of m.probes) {
1034
+ console.log(` ${dim(symbols.tee)} probed by ${p.from}.${blue(p.relation)} ${dim(`(${p.type})`)}`);
1035
+ }
1036
+ console.log(` ${dim(symbols.teeEnd)} ${green(m.createSql)}`);
1037
+ newline();
1038
+ }
1039
+ if (args.fix) {
1040
+ const up = missing.map((m) => m.createSql).join('\n');
1041
+ const down = missing.map((m) => m.dropSql).join('\n');
1042
+ const file = createMigration(config.migrationsDir, 'add_relation_fk_indexes', { up, down });
1043
+ success(`Created migration: ${bold(file.filename)}`);
1044
+ newline();
1045
+ console.log(` ${dim('Review it, then apply with:')} ${cyan('npx turbine migrate up')}`);
1046
+ console.log(` ${dim('Large, hot tables: consider running the statements manually with')} ${cyan('CREATE INDEX CONCURRENTLY')}`);
1047
+ console.log(` ${dim('(cannot run inside a transaction, so it is not emitted in the migration).')}`);
1048
+ newline();
1049
+ }
1050
+ else {
1051
+ console.log(` ${dim('Generate a fix migration with:')} ${cyan('npx turbine doctor --fix')}`);
1052
+ newline();
1053
+ }
1054
+ }
1055
+ // ---------------------------------------------------------------------------
977
1056
  // Command: studio — local read-only web UI
978
1057
  // ---------------------------------------------------------------------------
979
1058
  async function cmdStudio(args, config) {
@@ -1252,6 +1331,7 @@ function showHelp() {
1252
1331
  console.log(` ${dim('status')} Show applied/pending migrations`);
1253
1332
  console.log(` ${cyan('seed')} Run seed file`);
1254
1333
  console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1334
+ console.log(` ${cyan('doctor')} Check relations for missing FK indexes ${dim('(--fix emits migration)')}`);
1255
1335
  console.log(` ${cyan('studio')} Launch local read-only web UI`);
1256
1336
  console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
1257
1337
  newline();
@@ -1404,6 +1484,9 @@ async function main() {
1404
1484
  case 'info':
1405
1485
  await cmdStatus(args, config);
1406
1486
  break;
1487
+ case 'doctor':
1488
+ await cmdDoctor(args, config);
1489
+ break;
1407
1490
  case 'studio':
1408
1491
  await cmdStudio(args, config);
1409
1492
  break;
package/dist/client.d.ts CHANGED
@@ -26,7 +26,7 @@ import { type Dialect } from './dialect.js';
26
26
  import { type ErrorMessageMode } from './errors.js';
27
27
  import { type ObserveConfig, type ObserveHandle } from './observe.js';
28
28
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
29
- import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
29
+ import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy } from './query/index.js';
30
30
  import { type NotificationHandler, type Subscription } from './realtime.js';
31
31
  import type { SchemaMetadata } from './schema.js';
32
32
  import { TypedSqlQuery } from './typed-sql.js';
@@ -140,6 +140,44 @@ export interface TurbineConfig {
140
140
  defaultLimit?: number;
141
141
  /** Log a warning when findMany() is called without a limit (default: false) */
142
142
  warnOnUnlimited?: boolean;
143
+ /**
144
+ * Interpret Postgres `timestamp` (without time zone) values as UTC — both
145
+ * at the driver level (OID 1114 type parser, registered only when Turbine
146
+ * owns the pool) and when coercing nested-relation JSON dates. This is the
147
+ * Prisma/Rails/Django convention and makes results independent of the
148
+ * server's local time zone. Default: `true`. Set `false` for the legacy
149
+ * local-time interpretation.
150
+ */
151
+ utcTimestamps?: boolean;
152
+ /**
153
+ * Default strategy for resolving `with`-clause relations, applied to every
154
+ * `findMany`/`findUnique`/`findFirst` unless overridden per query.
155
+ *
156
+ * - `'join'` (default) — one SQL statement using correlated
157
+ * `json_agg(json_build_object(...))` subqueries.
158
+ * - `'batched'` — run the base query, then one flat follow-up query per
159
+ * relation (`WHERE fk = ANY($1)`), stitching children client-side. Wins
160
+ * when child FK columns are unindexed or result sets are large.
161
+ *
162
+ * Precedence: per-query `relationLoadStrategy` arg > this config > `'join'`.
163
+ */
164
+ relationLoadStrategy?: RelationLoadStrategy;
165
+ /**
166
+ * How nested-relation subqueries encode each row's JSON.
167
+ *
168
+ * - `'object'` (default) — `json_agg(json_build_object('key', v, …))`. Every
169
+ * key name is repeated in every nested object of every row.
170
+ * - `'positional'` — `json_agg(json_build_array(v, …))`. Turbine knows the
171
+ * column order at build time, so it emits a key-less array and maps
172
+ * positions back to keys client-side. Same information, a fraction of the
173
+ * bytes on wide/deeply-nested `with` trees. Parsed output is byte-identical
174
+ * to `'object'`.
175
+ *
176
+ * Postgres-only in v1: setting `'positional'` on a non-Postgres engine throws
177
+ * `UnsupportedFeatureError` (E017) when a `with` clause is present. Default:
178
+ * `'object'` (today's behavior, byte-unchanged).
179
+ */
180
+ jsonEncoding?: 'object' | 'positional';
143
181
  /**
144
182
  * Controls how `NotFoundError` (and other where-aware errors) format their
145
183
  * messages.
@@ -263,6 +301,7 @@ export declare class TurbineClient {
263
301
  /** The schema metadata this client was built from */
264
302
  readonly schema: SchemaMetadata;
265
303
  private static int8ParserRegistered;
304
+ private static utcTimestampParserRegistered;
266
305
  private readonly logging;
267
306
  /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
268
307
  private readonly dialect;
package/dist/client.js CHANGED
@@ -197,6 +197,7 @@ export class TurbineClient {
197
197
  /** The schema metadata this client was built from */
198
198
  schema;
199
199
  static int8ParserRegistered = false;
200
+ static utcTimestampParserRegistered = false;
200
201
  logging;
201
202
  /** Active SQL dialect — owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
202
203
  dialect;
@@ -244,6 +245,16 @@ export class TurbineClient {
244
245
  });
245
246
  TurbineClient.int8ParserRegistered = true;
246
247
  }
248
+ // Parse `timestamp` (OID 1114) as UTC instead of server-local time. The
249
+ // pg driver's default hands back a Date built in the process's local zone,
250
+ // so the same row yields a different instant per deployment region. The
251
+ // ORM convention (Prisma, Rails, Django) — and the only interpretation
252
+ // that round-trips what Postgres stores — is UTC. Same ownership rule as
253
+ // the int8 parser: never mutate parser state on external pools.
254
+ if (!config.pool && config.utcTimestamps !== false && !TurbineClient.utcTimestampParserRegistered) {
255
+ pg.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
256
+ TurbineClient.utcTimestampParserRegistered = true;
257
+ }
247
258
  this.logging = config.logging ?? false;
248
259
  this.dialect = config.dialect ?? postgresDialect;
249
260
  this.schema = schema;
@@ -253,6 +264,9 @@ export class TurbineClient {
253
264
  this.queryOptions = {
254
265
  defaultLimit: config.defaultLimit,
255
266
  warnOnUnlimited: config.warnOnUnlimited,
267
+ utcTimestamps: config.utcTimestamps,
268
+ relationLoadStrategy: config.relationLoadStrategy,
269
+ jsonEncoding: config.jsonEncoding,
256
270
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
257
271
  sqlCache: config.sqlCache ?? true,
258
272
  dialect: config.dialect,
package/dist/dialect.d.ts CHANGED
@@ -229,6 +229,17 @@ export interface Dialect {
229
229
  readonly nullJsonLiteral: string;
230
230
  /** Build a JSON object expression from output keys and SQL expressions. */
231
231
  buildJsonObject(pairs: [key: string, expr: string][]): string;
232
+ /**
233
+ * Build a positional JSON ARRAY expression from ordered SQL expressions —
234
+ * the key-less counterpart to {@link buildJsonObject} used by the opt-in
235
+ * `jsonEncoding: 'positional'` mode. Emitting `json_build_array(v1, v2, …)`
236
+ * instead of `json_build_object('k1', v1, …)` drops every repeated key name
237
+ * from every nested object of every row (the decode side maps positions back
238
+ * to keys via a build-time shape descriptor). Postgres-only in v1 — other
239
+ * engines never reach this because the builder gates positional encoding to
240
+ * `dialect.name === 'postgresql'`, so the method is optional on the contract.
241
+ */
242
+ buildJsonArray?(exprs: string[]): string;
232
243
  /** Build a JSON array aggregation expression with a dialect-specific empty-array fallback. */
233
244
  buildJsonArrayAgg(jsonObjectExpr: string, orderBy?: string): string;
234
245
  /**
package/dist/dialect.js CHANGED
@@ -32,8 +32,32 @@ export const postgresDialect = {
32
32
  },
33
33
  buildJsonObject(pairs) {
34
34
  const args = pairs.map(([key, expr]) => `'${this.escapeStringLiteral(key)}', ${expr}`);
35
+ // Postgres caps function calls at 100 arguments (= 50 key/value pairs).
36
+ // Wide tables (or wide select+relation trees) exceed that, so chunk into
37
+ // multiple jsonb_build_object calls merged with `||`, cast back to json.
38
+ if (pairs.length > 50) {
39
+ const chunks = [];
40
+ for (let i = 0; i < args.length; i += 50) {
41
+ chunks.push(`jsonb_build_object(${args.slice(i, i + 50).join(', ')})`);
42
+ }
43
+ return `(${chunks.join(' || ')})::json`;
44
+ }
35
45
  return `json_build_object(${args.join(', ')})`;
36
46
  },
47
+ buildJsonArray(exprs) {
48
+ // Mirror buildJsonObject's chunking at the SAME 50-element threshold: for
49
+ // wide rows, concatenate 50-element jsonb_build_array calls with `||` (which
50
+ // concatenates jsonb arrays) and cast back to json. `jsonb ||` preserves
51
+ // element order, so positions map back to keys unchanged after decode.
52
+ if (exprs.length > 50) {
53
+ const chunks = [];
54
+ for (let i = 0; i < exprs.length; i += 50) {
55
+ chunks.push(`jsonb_build_array(${exprs.slice(i, i + 50).join(', ')})`);
56
+ }
57
+ return `(${chunks.join(' || ')})::json`;
58
+ }
59
+ return `json_build_array(${exprs.join(', ')})`;
60
+ },
37
61
  buildJsonArrayAgg(jsonObjectExpr, orderBy) {
38
62
  const suffix = orderBy ? ` ${orderBy}` : '';
39
63
  return `COALESCE(json_agg(${jsonObjectExpr}${suffix}), ${this.emptyJsonArrayLiteral})`;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Index advisor — finds relation probes that lack index support.
3
+ *
4
+ * Turbine loads `with` relations as correlated subqueries: for every parent row,
5
+ * the child table is probed by its FK column(s) (`child.fk = parent.pk`). Relation
6
+ * filters (`some`/`none`/`every`, `is`/`isNot`) probe the same columns. This
7
+ * strategy outperforms batched loading (`WHERE fk IN (ids)`) when the probed
8
+ * column is indexed — but with NO index, each probe is a full table scan and the
9
+ * cost multiplies by the parent rowcount, while batched loading would pay the
10
+ * scan only once. A missing FK index that is invisible under a batched-loader
11
+ * ORM becomes pathological under a correlated one.
12
+ *
13
+ * This module derives every column set Turbine will probe from SchemaMetadata's
14
+ * relations and checks each against the table's known indexes (and primary key).
15
+ * Consumed by `turbine doctor` (CLI report + fix migration) and by the dev-mode
16
+ * runtime warning in query/builder.ts.
17
+ */
18
+ import { type SchemaMetadata, type TableMetadata } from './schema.js';
19
+ export interface RelationProbe {
20
+ /** Table on which the relation is declared */
21
+ from: string;
22
+ /** Relation field name */
23
+ relation: string;
24
+ /** Relation type */
25
+ type: 'hasMany' | 'hasOne' | 'belongsTo' | 'manyToMany';
26
+ }
27
+ export interface MissingRelationIndex {
28
+ /** Table that gets probed per parent row */
29
+ table: string;
30
+ /** Probed column(s) — equality lookups, so a covering index must LEAD with one of them */
31
+ columns: string[];
32
+ /** Every relation that generates this probe */
33
+ probes: RelationProbe[];
34
+ /** Suggested index name (matches the --fix migration) */
35
+ indexName: string;
36
+ /** CREATE INDEX statement for the fix migration */
37
+ createSql: string;
38
+ /** DROP INDEX statement for the fix migration's DOWN section */
39
+ dropSql: string;
40
+ }
41
+ /**
42
+ * Whether an equality probe on `columns` is served by the table's indexes.
43
+ *
44
+ * All probe columns are equality predicates, so any index whose FIRST column is
45
+ * one of the probed columns gives the planner an index path (a btree can't use
46
+ * a column that isn't in its leading prefix). This is deliberately the loose
47
+ * direction: it never flags a table that has *any* usable index for the probe,
48
+ * at the cost of not demanding the ideal multi-column index. The primary key
49
+ * counts as an index.
50
+ */
51
+ export declare function isProbeIndexed(meta: TableMetadata, columns: string[]): boolean;
52
+ /**
53
+ * Scan every relation in the schema and return the probes with no index support,
54
+ * deduplicated by (table, column set) with all contributing relations attached.
55
+ *
56
+ * Only meaningful when the metadata carries index information (i.e. it came from
57
+ * introspection or a generated client). Callers that may hold index-less metadata
58
+ * should gate on {@link schemaHasIndexInfo} to avoid blanket false positives.
59
+ */
60
+ export declare function findMissingRelationIndexes(schema: SchemaMetadata): MissingRelationIndex[];
61
+ /** True when at least one table in the schema carries index metadata. */
62
+ export declare function schemaHasIndexInfo(schema: SchemaMetadata): boolean;
63
+ /**
64
+ * Single-relation check for the dev-mode runtime warning: the (table, columns)
65
+ * this relation probes and whether that probe is unindexed. Returns null when
66
+ * indexed, unknown, or when the schema carries no index info at all (metadata
67
+ * built without introspection — warning would be a blanket false positive).
68
+ */
69
+ export declare function missingIndexForRelation(schema: SchemaMetadata, relDef: {
70
+ name: string;
71
+ type: RelationProbe['type'];
72
+ to: string;
73
+ foreignKey: string | string[];
74
+ referenceKey: string | string[];
75
+ through?: {
76
+ table: string;
77
+ sourceKey: string | string[];
78
+ };
79
+ }): {
80
+ table: string;
81
+ columns: string[];
82
+ createSql: string;
83
+ } | null;
Binary file
package/dist/mssql.js CHANGED
@@ -766,7 +766,9 @@ function buildForJsonSubquery(dialect, ctx) {
766
766
  return buildForJsonManyToMany(dialect, ctx, { colSelect, buildNested, buildPaging, hasLimit });
767
767
  }
768
768
  const isToOne = relDef.type === 'belongsTo' || relDef.type === 'hasOne';
769
- const correlation = isToOne
769
+ // Correlation direction is about WHERE THE FK LIVES, not cardinality:
770
+ // belongsTo has it on the source; hasMany AND hasOne have it on the target.
771
+ const correlation = relDef.type === 'belongsTo'
770
772
  ? dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
771
773
  : dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
772
774
  // ----- to-one (belongsTo / hasOne): single object, no paging --------------
@@ -0,0 +1,120 @@
1
+ /**
2
+ * turbine-orm — Batched relation loader (the `relationLoadStrategy: 'batched'` path)
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Turbine's default `with`-clause strategy resolves nested relations in ONE SQL
7
+ * statement using correlated `json_agg(json_build_object(...))` subqueries — one
8
+ * probe per parent row (see `buildRelationSubquery` in builder.ts). That is the
9
+ * right default: a single round-trip, and when the child FK columns are indexed
10
+ * each probe is an index seek. But it degrades in two situations:
11
+ *
12
+ * 1. **Missing FK index** — a correlated probe per parent row becomes
13
+ * N-parents × full-table-scan. A batched-loader ORM pays that missing index
14
+ * only ONCE (a single `WHERE fk = ANY($1)` seq-scan), which is why schemas
15
+ * migrated from those ORMs often lack the index the json_agg path needs.
16
+ * 2. **Huge unpaginated result sets** — the JSON wire format
17
+ * (`json_build_object` per row, re-serialized inside `json_agg`) is heavy to
18
+ * encode/decode compared with flat rows.
19
+ *
20
+ * This module implements the alternative, opt-in strategy: run the base query
21
+ * WITHOUT relation subqueries, collect the parent keys, then issue ONE flat
22
+ * follow-up query per relation (`SELECT ... FROM child WHERE fk = ANY($1)`),
23
+ * and stitch the children onto the parents in memory. D relation levels cost D
24
+ * extra round-trips instead of one, but each is a single indexed lookup over a
25
+ * key set, and rows come back flat.
26
+ *
27
+ * ## Design constraints (see CLAUDE.md)
28
+ *
29
+ * - **Same executor / connection path.** Every follow-up query runs through the
30
+ * caller's own executor ({@link RelationLoadContext.exec}) and child query
31
+ * interfaces built on the caller's pool. Inside a `$transaction` that pool is
32
+ * the pinned-connection `txPool`, so batched loads join the transaction — no
33
+ * separate pool checkout per query.
34
+ * - **Identical output shape.** The stitched result is byte-for-byte the same
35
+ * shape the join strategy produces: relation arrays for hasMany/manyToMany
36
+ * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
37
+ * camelCase keys and Date coercion — because the child rows are parsed by the
38
+ * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
39
+ * - **Stitch keys never leak.** To stitch, the follow-up query must select the
40
+ * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
41
+ * loader adds those columns for the query and strips them from the returned
42
+ * entities afterwards ({@link includeKeysForBatching}).
43
+ *
44
+ * PowDB (powql.ts) has its own batched loaders for the same reasons — this is the
45
+ * clean Postgres/SQL implementation, deliberately NOT shared with PowQL.
46
+ *
47
+ * @module
48
+ */
49
+ import type pg from 'pg';
50
+ import { type SchemaMetadata, type TableMetadata } from '../schema.js';
51
+ import type { ReselectExecutor } from './builder.js';
52
+ import type { WithClause } from './types.js';
53
+ /**
54
+ * A DeferredQuery, minimally typed for what the loader consumes. Kept local to
55
+ * avoid a value import of builder.ts (which imports this module).
56
+ */
57
+ interface Deferred {
58
+ sql: string;
59
+ params: unknown[];
60
+ preparedName?: string;
61
+ transform: (result: pg.QueryResult) => unknown;
62
+ }
63
+ /**
64
+ * The read surface the loader needs from a child QueryInterface: build (but do
65
+ * not execute) a flat findMany. The loader runs the built SQL through
66
+ * {@link RelationLoadContext.exec}, so execution stays on the caller's connection.
67
+ */
68
+ export interface BatchedChildReader {
69
+ buildFindMany(args: Record<string, unknown>): Deferred;
70
+ }
71
+ /**
72
+ * Everything the loader needs from the owning QueryInterface, passed as closures
73
+ * so this module never imports builder.ts at runtime (it is imported BY it).
74
+ */
75
+ export interface RelationLoadContext {
76
+ /** Metadata of the table whose rows are the current `parents`. */
77
+ parentMeta: TableMetadata;
78
+ schema: SchemaMetadata;
79
+ /** Build a child reader for `table`, bound to the caller's pool (tx-safe). */
80
+ makeChild: (table: string) => BatchedChildReader;
81
+ /** Run raw SQL through the caller's executor (same timeout/instrumentation path). */
82
+ exec: ReselectExecutor;
83
+ /** Quote an identifier via the active dialect. */
84
+ quote: (name: string) => string;
85
+ /** Build an `IN`/`ANY` predicate via the active dialect (PG: `expr = ANY($n)`). */
86
+ buildInClause: (expr: string, paramRef: string, negated: boolean) => string;
87
+ /** The single bound value for an `IN` list (PG: the array as-is). */
88
+ inClauseParam: (values: unknown[]) => unknown;
89
+ /** Placeholder for a 1-indexed parameter position (PG: `$n`). */
90
+ paramPlaceholder: (index: number) => string;
91
+ }
92
+ /**
93
+ * Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
94
+ * query result, returning the adjusted projection plus the list of fields that
95
+ * were added ONLY for stitching and must be stripped from the final entities.
96
+ *
97
+ * Used both for the base query (parent keys) and each follow-up query (child
98
+ * keys) so a caller's `select: { title: true }` on a relation still stitches even
99
+ * though the FK was not requested — and the FK never appears in the output.
100
+ */
101
+ export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[]): {
102
+ select?: Record<string, boolean>;
103
+ omit?: Record<string, boolean>;
104
+ strip: string[];
105
+ };
106
+ /** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
107
+ export declare function stripFields(rows: Record<string, unknown>[], fields: string[]): void;
108
+ /**
109
+ * The set of parent FIELD names a batched load of `withClause` needs present on
110
+ * each parent row in order to stitch (the local key of every requested relation).
111
+ * The caller adds these to the base query and strips the added ones afterwards.
112
+ */
113
+ export declare function neededParentKeyFields(parentMeta: TableMetadata, withClause: WithClause): string[];
114
+ /**
115
+ * Load every relation in `withClause` for `parents` and attach it onto each row
116
+ * in place. Mirrors the join strategy's output shape exactly. Recurses for nested
117
+ * `with` by re-running itself against the freshly-loaded child rows.
118
+ */
119
+ export declare function loadRelationsBatched(ctx: RelationLoadContext, parents: Record<string, unknown>[], withClause: WithClause, timeout?: number, depth?: number, path?: string[]): Promise<void>;
120
+ export {};