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.
- package/README.md +26 -1
- package/dist/cjs/cli/index.js +83 -0
- package/dist/cjs/client.js +14 -0
- package/dist/cjs/dialect.js +24 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/mssql.js +3 -1
- package/dist/cjs/query/batched-loader.js +392 -0
- package/dist/cjs/query/builder.js +548 -60
- package/dist/cjs/query/utils.js +36 -0
- package/dist/cjs/serverless.js +35 -3
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +83 -0
- package/dist/client.d.ts +40 -1
- package/dist/client.js +14 -0
- package/dist/dialect.d.ts +11 -0
- package/dist/dialect.js +24 -0
- package/dist/index-advisor.d.ts +83 -0
- package/dist/index-advisor.js +0 -0
- package/dist/mssql.js +3 -1
- package/dist/query/batched-loader.d.ts +120 -0
- package/dist/query/batched-loader.js +386 -0
- package/dist/query/builder.d.ts +118 -1
- package/dist/query/builder.js +549 -61
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +37 -2
- package/dist/query/utils.d.ts +16 -0
- package/dist/query/utils.js +35 -0
- package/dist/serverless.d.ts +32 -4
- package/dist/serverless.js +35 -3
- package/package.json +1 -1
package/dist/cjs/query/utils.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/cjs/serverless.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/index.d.ts
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
|
*
|
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
|
-
|
|
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 {};
|