turbine-orm 0.38.1 → 0.39.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 +1 -1
- package/dist/cjs/powdb.js +52 -3
- package/dist/cjs/powql.js +223 -18
- package/dist/powdb.d.ts +16 -4
- package/dist/powdb.js +52 -3
- package/dist/powql.d.ts +60 -3
- package/dist/powql.js +223 -18
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -895,7 +895,7 @@ Everything is honest about what ports and what doesn't. Features marked **PG-onl
|
|
|
895
895
|
|
|
896
896
|
**Engine notes:** SQLite uses `RETURNING` (≥ 3.35) just like Postgres. MySQL has no `RETURNING`, so writes re-`SELECT` the affected row and **`createMany` returns `[]`** (the rows ARE inserted — re-query if you need them). SQL Server returns rows via `OUTPUT`/`MERGE`; `DISTINCT ON` is Postgres-only. Only Postgres streams via a true cursor (constant memory); the other engines' `findManyStream` materializes the result then yields it in batches. Optimistic locking throws `OptimisticLockError` on all engines (on MySQL the conflict is detected from the version-checked UPDATE's affected-row count). The `turbine` CLI (`generate`, `migrate`) is currently PostgreSQL-only — point the engine factories at a hand-written or programmatically introspected `SCHEMA`.
|
|
897
897
|
|
|
898
|
-
**PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations
|
|
898
|
+
**PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations run as **one statement** on engine 0.18+ (PowQL nested projections — per-parent order/limit, childless parents kept, the same single-query shape as Postgres `json_agg`); older engines and ineligible shapes (many-to-many via the junction) load client-side with identical output. Nested writes cover hasMany/hasOne/belongsTo; many-to-many nested writes are not supported. Transactions are single-writer: concurrent `$transaction` calls queue FIFO (bounded by `transactionQueueTimeoutMs`); nested/re-entrant transactions throw typed errors (no savepoints). Schema is code-first via `defineSchema` — `schemaDefToMetadata()` bridges it to any engine that needs runtime metadata, and a programmatic `describe`-based introspector exists since 0.34 (relations excluded). JSON documents are first-class on engine 0.12+: `JsonFilter` where-filters, JSON-path `orderBy`/`groupBy`, doc-field expression indexes, and a lossless native wire (0.13+) that keeps JSON `null`, missing fields, and the string `"null"` distinct. Embedded `syncMode: 'normal'` moves fsync off the commit path; the networked transport runs the same data over a socket. Cursor streaming and the Postgres-only trio (pgvector / LISTEN/NOTIFY / RLS session GUCs) throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
|
|
899
899
|
|
|
900
900
|
Full setup, signatures, and the complete support matrix: **[turbineorm.dev/engines](https://turbineorm.dev/engines)**.
|
|
901
901
|
|
package/dist/cjs/powdb.js
CHANGED
|
@@ -253,6 +253,7 @@ const POWDB_FEATURE_MIN_VERSION = {
|
|
|
253
253
|
jsonDocs: '0.12',
|
|
254
254
|
docFieldIndexes: '0.13',
|
|
255
255
|
serverJoins: '0.13',
|
|
256
|
+
nestedProjections: '0.18',
|
|
256
257
|
};
|
|
257
258
|
/**
|
|
258
259
|
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
@@ -260,7 +261,9 @@ const POWDB_FEATURE_MIN_VERSION = {
|
|
|
260
261
|
* did not go through {@link turbinePowDB}'s version probe (e.g. an injected
|
|
261
262
|
* pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
|
|
262
263
|
* actual wire path and must only be enabled after a real server-version probe,
|
|
263
|
-
* never inferred from a bare construction.
|
|
264
|
+
* never inferred from a bare construction. `nestedProjections` stays OFF for
|
|
265
|
+
* the same reason: it changes the generated PowQL for every `with` query, and
|
|
266
|
+
* an unprobed engine below 0.18 would reject the syntax outright.
|
|
264
267
|
*/
|
|
265
268
|
exports.ALL_POWDB_CAPABILITIES = {
|
|
266
269
|
engineVersion: null,
|
|
@@ -268,6 +271,7 @@ exports.ALL_POWDB_CAPABILITIES = {
|
|
|
268
271
|
docFieldIndexes: true,
|
|
269
272
|
introspection: true,
|
|
270
273
|
serverJoins: true,
|
|
274
|
+
nestedProjections: false,
|
|
271
275
|
nativeRaw: false,
|
|
272
276
|
};
|
|
273
277
|
/** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
|
|
@@ -296,6 +300,7 @@ function capabilitiesFromVersion(version, opts = {}) {
|
|
|
296
300
|
docFieldIndexes: false,
|
|
297
301
|
introspection: false,
|
|
298
302
|
serverJoins: false,
|
|
303
|
+
nestedProjections: false,
|
|
299
304
|
nativeRaw: false,
|
|
300
305
|
};
|
|
301
306
|
}
|
|
@@ -305,6 +310,7 @@ function capabilitiesFromVersion(version, opts = {}) {
|
|
|
305
310
|
jsonDocs: atLeastVersion(sem, 0, 12),
|
|
306
311
|
docFieldIndexes: atLeastVersion(sem, 0, 13),
|
|
307
312
|
serverJoins: atLeastVersion(sem, 0, 13),
|
|
313
|
+
nestedProjections: atLeastVersion(sem, 0, 18),
|
|
308
314
|
nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
|
|
309
315
|
};
|
|
310
316
|
}
|
|
@@ -731,7 +737,10 @@ function rowToEntity(raw, meta, native = false) {
|
|
|
731
737
|
* `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
|
|
732
738
|
*
|
|
733
739
|
* So we always run the unique-constraint and message-shape checks first (they
|
|
734
|
-
* fire for both transports
|
|
740
|
+
* fire for both transports and extract detail like constraint / column names),
|
|
741
|
+
* then classify by the typed wire error class (`.wireErrorClass`, networked
|
|
742
|
+
* server >= 0.17 — accurate even when the server sanitized the message text),
|
|
743
|
+
* then fall through to the networked `.code` switch.
|
|
735
744
|
*/
|
|
736
745
|
function wrapPowdbError(err) {
|
|
737
746
|
if (!err || typeof err !== 'object')
|
|
@@ -822,6 +831,46 @@ function wrapPowdbError(err) {
|
|
|
822
831
|
if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
|
|
823
832
|
return new errors_js_1.ValidationError(`[turbine] PowDB join rejected: ${msg}`);
|
|
824
833
|
}
|
|
834
|
+
// Typed wire error class (networked, server >= 0.17): the client surfaces the
|
|
835
|
+
// stable one-byte class from the error frame as `.wireErrorClass`. Classify by
|
|
836
|
+
// it BEFORE the generic message regexes: the server sanitizes non-allowlisted
|
|
837
|
+
// messages to the generic "query execution error" text, but the class is
|
|
838
|
+
// derived from the typed error at response-build time, so it stays accurate
|
|
839
|
+
// when the message no longer is. The specific message families above still run
|
|
840
|
+
// first because they extract richer detail (constraint / column names) that
|
|
841
|
+
// the class byte cannot carry. Class values are append-only wire contract
|
|
842
|
+
// (docs/errors.md upstream); unknown / absent classes fall through to the
|
|
843
|
+
// pre-0.17 message + `.code` behavior.
|
|
844
|
+
const wireClass = err.wireErrorClass;
|
|
845
|
+
if (typeof wireClass === 'number') {
|
|
846
|
+
switch (wireClass) {
|
|
847
|
+
case 3: // timeout (per-query budget, gate wait, idle timeout) — retryable
|
|
848
|
+
return new errors_js_1.TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
|
|
849
|
+
case 4: // limit_exceeded (memory / size budget) — a query-shape defect
|
|
850
|
+
return new errors_js_1.ValidationError(`[turbine] PowDB resource limit exceeded: ${msg}`);
|
|
851
|
+
case 5: // readonly_refused — the snapshot-serving routing signal
|
|
852
|
+
return new errors_js_1.ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
|
|
853
|
+
cause: err,
|
|
854
|
+
reason: 'snapshot',
|
|
855
|
+
});
|
|
856
|
+
case 6: // auth_failed at CONNECT
|
|
857
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
|
|
858
|
+
case 7: // rate_limited (repeated bad auth) — connection-establishment class
|
|
859
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB rate-limited this address after repeated failed authentication: ${msg}. Wait before retrying.`, { cause: err });
|
|
860
|
+
case 8: {
|
|
861
|
+
// constraint_violation — today that is always a unique index
|
|
862
|
+
const m = /on\s+\S+\.(\w+)/i.exec(msg);
|
|
863
|
+
return new errors_js_1.UniqueConstraintError({ constraint: m?.[1], cause: err });
|
|
864
|
+
}
|
|
865
|
+
case 9: // cancelled (issuing client disconnected) — final, never retry
|
|
866
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
|
|
867
|
+
case 1: // parse
|
|
868
|
+
case 2: // execution
|
|
869
|
+
return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
870
|
+
default: // internal (0) or an unknown future class: fall through
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
825
874
|
// Type mismatch / parse / execution / storage / unexpected(token) / row too
|
|
826
875
|
// large → validation (E003). On the embedded transport these are the only
|
|
827
876
|
// signal we get (code is always 'GenericFailure'); on the networked path they
|
|
@@ -1449,7 +1498,7 @@ function encodePowqlLiteral(value) {
|
|
|
1449
1498
|
* the ceiling yet still routes through the string wire is refused rather than
|
|
1450
1499
|
* materialized.
|
|
1451
1500
|
*/
|
|
1452
|
-
exports.POWQL_LEXER_TESTED_CEILING = '0.
|
|
1501
|
+
exports.POWQL_LEXER_TESTED_CEILING = '0.18';
|
|
1453
1502
|
/** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
|
|
1454
1503
|
function encodePowqlString(s) {
|
|
1455
1504
|
let out = '"';
|
package/dist/cjs/powql.js
CHANGED
|
@@ -930,30 +930,63 @@ class PowqlInterface {
|
|
|
930
930
|
// -------------------------------------------------------------------------
|
|
931
931
|
async findMany(args = {}) {
|
|
932
932
|
return this.withMiddleware('findMany', args, async () => {
|
|
933
|
-
const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
|
|
933
|
+
const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
|
|
934
934
|
const entities = this.shape(rows, native);
|
|
935
|
-
if (
|
|
936
|
-
|
|
935
|
+
if (nestedPlans.length)
|
|
936
|
+
this.attachNestedRows(entities, nestedPlans);
|
|
937
|
+
if (residualWith) {
|
|
938
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
|
|
937
939
|
}
|
|
938
940
|
return entities;
|
|
939
941
|
});
|
|
940
942
|
}
|
|
941
943
|
/**
|
|
942
|
-
* Compile the
|
|
943
|
-
*
|
|
944
|
+
* Compile the findMany select into PowQL (no execution), pushing values into
|
|
945
|
+
* `params`. Returns the query plus the RESOLVED where (relation filters
|
|
944
946
|
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
945
947
|
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
948
|
+
*
|
|
949
|
+
* When the engine supports nested projections (>= 0.18) and the strategy
|
|
950
|
+
* does not opt out, eligible `with` relations compile INTO this statement as
|
|
951
|
+
* nested-projection blocks (`nestedPlans`) — one round-trip for the whole
|
|
952
|
+
* shape — and only the ineligible remainder (`residualWith`) goes to the
|
|
953
|
+
* post-execution loaders. Without nesting the emitted PowQL is byte-identical
|
|
954
|
+
* to the pre-0.18 output (no alias, `.col` refs).
|
|
946
955
|
*/
|
|
947
956
|
async buildFind(args, params) {
|
|
948
957
|
if (args.cursor) {
|
|
949
958
|
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
950
959
|
}
|
|
951
960
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
952
|
-
const where = this.buildWhere(resolvedWhere, params);
|
|
953
961
|
const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
|
|
962
|
+
// Partition the `with` clause: nested-projection blocks vs loader residue.
|
|
963
|
+
// A parent `distinct` never nests (distinct over a row containing a JSON
|
|
964
|
+
// array is not a defined comparison), and a relation whose field name
|
|
965
|
+
// collides with a projected parent column stays on the loaders (its block
|
|
966
|
+
// key would duplicate the column's).
|
|
967
|
+
const withClause = args.with;
|
|
968
|
+
const nestedPlans = [];
|
|
969
|
+
let residualWith = withClause;
|
|
970
|
+
if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
|
|
971
|
+
const residue = {};
|
|
972
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
973
|
+
if (!opt)
|
|
974
|
+
continue;
|
|
975
|
+
const rel = this.meta.relations[relName];
|
|
976
|
+
const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
|
|
977
|
+
if (plan)
|
|
978
|
+
nestedPlans.push(plan);
|
|
979
|
+
else
|
|
980
|
+
residue[relName] = opt; // unknown relation: the loader raises its E003
|
|
981
|
+
}
|
|
982
|
+
residualWith = Object.keys(residue).length ? residue : undefined;
|
|
983
|
+
}
|
|
984
|
+
const nest = nestedPlans.length > 0;
|
|
985
|
+
const alias = nest ? 't0' : undefined;
|
|
986
|
+
const where = this.buildWhere(resolvedWhere, params, alias);
|
|
954
987
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
955
988
|
const filter = where ? ` filter ${where}` : '';
|
|
956
|
-
const order = this.buildOrder(args.orderBy, params);
|
|
989
|
+
const order = this.buildOrder(args.orderBy, params, alias);
|
|
957
990
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
958
991
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
959
992
|
this.warnedUnlimited = true;
|
|
@@ -961,15 +994,29 @@ class PowqlInterface {
|
|
|
961
994
|
}
|
|
962
995
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
963
996
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
964
|
-
|
|
965
|
-
|
|
997
|
+
let projection;
|
|
998
|
+
if (nest) {
|
|
999
|
+
// Aliased scan: every parent column keys itself (`col: t0.col`) so the
|
|
1000
|
+
// result columns keep their bare snake names, then the nested blocks.
|
|
1001
|
+
const aliasCtr = { n: 1 };
|
|
1002
|
+
const parts = cols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: t0.${(0, powdb_js_1.quotePowqlIdent)(c)}`);
|
|
1003
|
+
for (const plan of nestedPlans) {
|
|
1004
|
+
parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
|
|
1005
|
+
}
|
|
1006
|
+
projection = `{ ${parts.join(', ')} }`;
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
projection = this.projection(cols);
|
|
1010
|
+
}
|
|
1011
|
+
const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
|
|
1012
|
+
return { powql, resolvedWhere, nestedPlans, residualWith };
|
|
966
1013
|
}
|
|
967
|
-
/** Build + run the
|
|
1014
|
+
/** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
|
|
968
1015
|
async runFind(args, action = 'findMany') {
|
|
969
1016
|
const params = [];
|
|
970
|
-
const { powql, resolvedWhere } = await this.buildFind(args, params);
|
|
1017
|
+
const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
|
|
971
1018
|
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
972
|
-
return { rows, native, resolvedWhere };
|
|
1019
|
+
return { rows, native, resolvedWhere, nestedPlans, residualWith };
|
|
973
1020
|
}
|
|
974
1021
|
/**
|
|
975
1022
|
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
@@ -997,23 +1044,27 @@ class PowqlInterface {
|
|
|
997
1044
|
}
|
|
998
1045
|
async findUnique(args) {
|
|
999
1046
|
return this.withMiddleware('findUnique', args, async () => {
|
|
1000
|
-
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1047
|
+
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1001
1048
|
if (!rows.length)
|
|
1002
1049
|
return null;
|
|
1003
1050
|
const entities = this.shape(rows, native);
|
|
1004
|
-
if (
|
|
1005
|
-
|
|
1051
|
+
if (nestedPlans.length)
|
|
1052
|
+
this.attachNestedRows(entities, nestedPlans);
|
|
1053
|
+
if (residualWith)
|
|
1054
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
1006
1055
|
return entities[0];
|
|
1007
1056
|
});
|
|
1008
1057
|
}
|
|
1009
1058
|
async findFirst(args = {}) {
|
|
1010
1059
|
return this.withMiddleware('findFirst', args, async () => {
|
|
1011
|
-
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1060
|
+
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1012
1061
|
if (!rows.length)
|
|
1013
1062
|
return null;
|
|
1014
1063
|
const entities = this.shape(rows, native);
|
|
1015
|
-
if (
|
|
1016
|
-
|
|
1064
|
+
if (nestedPlans.length)
|
|
1065
|
+
this.attachNestedRows(entities, nestedPlans);
|
|
1066
|
+
if (residualWith)
|
|
1067
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
1017
1068
|
return entities[0];
|
|
1018
1069
|
});
|
|
1019
1070
|
}
|
|
@@ -1503,6 +1554,160 @@ class PowqlInterface {
|
|
|
1503
1554
|
return String(v);
|
|
1504
1555
|
}
|
|
1505
1556
|
// -------------------------------------------------------------------------
|
|
1557
|
+
// Nested relations: nested projections / shaped results (PowDB >= 0.18)
|
|
1558
|
+
// -------------------------------------------------------------------------
|
|
1559
|
+
/**
|
|
1560
|
+
* Should this query's `with` compile to nested-projection blocks? Requires
|
|
1561
|
+
* the engine capability (>= 0.18), and an EXPLICIT `relationLoadStrategy:
|
|
1562
|
+
* 'batched'` (per-query or client-level) opts back out to the keyed loaders.
|
|
1563
|
+
* The default and `'join'` both prefer nesting: unlike the F2 INNER join it
|
|
1564
|
+
* has no fan-out, keeps childless parents, works under parent paging, and
|
|
1565
|
+
* applies per-parent `order`/`limit`/`offset` natively, so it is the
|
|
1566
|
+
* strictly-better single-statement path wherever it is eligible. Ineligible
|
|
1567
|
+
* relations fall through to the existing strategy resolution untouched.
|
|
1568
|
+
*/
|
|
1569
|
+
nestedProjectionsPreferred(args) {
|
|
1570
|
+
if (!this.capabilities.nestedProjections)
|
|
1571
|
+
return false;
|
|
1572
|
+
const explicit = args.relationLoadStrategy ?? this.options.relationLoadStrategy;
|
|
1573
|
+
return explicit !== 'batched';
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Plan one `with` relation as a nested-projection block, or return `null`
|
|
1577
|
+
* when the shape must stay on the loaders (ALWAYS a silent fallback with
|
|
1578
|
+
* identical output, never an error):
|
|
1579
|
+
* - m2m (the block takes exactly one child table; the junction-order
|
|
1580
|
+
* stitch has no nested equivalent), and composite relation keys;
|
|
1581
|
+
* - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
|
|
1582
|
+
* - `distinct` inside the relation options (no nested grammar for it);
|
|
1583
|
+
* - a projected child column whose tsType is `bigint` or `Uint8Array`
|
|
1584
|
+
* (values ride a JSON array, which cannot carry them losslessly);
|
|
1585
|
+
* - a projection key collision (a child column named like a sub-relation);
|
|
1586
|
+
* - depth >= 10 (the loader path enforces the same cap by throwing, so the
|
|
1587
|
+
* fallback surfaces the identical E003 today's users get);
|
|
1588
|
+
* - any ineligible descendant (the WHOLE relation falls back, so one
|
|
1589
|
+
* statement never mixes nested and loader semantics mid-subtree).
|
|
1590
|
+
*/
|
|
1591
|
+
planNestedRelation(relName, rel, opt, includePii, depth = 0) {
|
|
1592
|
+
if (depth >= 10)
|
|
1593
|
+
return null;
|
|
1594
|
+
if (rel.type === 'manyToMany')
|
|
1595
|
+
return null;
|
|
1596
|
+
if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length > 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1) {
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
if (!this.schema.tables[rel.to])
|
|
1600
|
+
return null; // the loader raises its own error
|
|
1601
|
+
const options = (opt === true ? {} : opt);
|
|
1602
|
+
if (options.distinct?.length)
|
|
1603
|
+
return null;
|
|
1604
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1605
|
+
if (single && (options.limit !== undefined || options.offset))
|
|
1606
|
+
return null;
|
|
1607
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1608
|
+
const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
|
|
1609
|
+
const byName = new Map(targetQi.meta.columns.map((c) => [c.name, c]));
|
|
1610
|
+
for (const c of cols) {
|
|
1611
|
+
const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
|
|
1612
|
+
if (ts === 'bigint' || ts === 'Uint8Array')
|
|
1613
|
+
return null;
|
|
1614
|
+
}
|
|
1615
|
+
const children = [];
|
|
1616
|
+
if (options.with) {
|
|
1617
|
+
for (const [subName, subOpt] of Object.entries(options.with)) {
|
|
1618
|
+
if (!subOpt)
|
|
1619
|
+
continue;
|
|
1620
|
+
const subRel = targetQi.meta.relations[subName];
|
|
1621
|
+
if (!subRel)
|
|
1622
|
+
return null; // let the loader raise its unknown-relation error
|
|
1623
|
+
const child = targetQi.planNestedRelation(subName, subRel, subOpt, includePii, depth + 1);
|
|
1624
|
+
if (!child)
|
|
1625
|
+
return null;
|
|
1626
|
+
children.push(child);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
// The block's object keys are the projected snake column names plus the
|
|
1630
|
+
// sub-relation field names; a duplicate key would mis-shape the JSON.
|
|
1631
|
+
const keys = new Set(cols);
|
|
1632
|
+
for (const child of children) {
|
|
1633
|
+
if (keys.has(child.relName))
|
|
1634
|
+
return null;
|
|
1635
|
+
keys.add(child.relName);
|
|
1636
|
+
}
|
|
1637
|
+
return { relName, rel, options, targetQi, single, cols, children };
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Compile one {@link NestedRelationPlan} into its projection-field block:
|
|
1641
|
+
* `name: Target as tN filter <correlation> [and (<child where>)] [order …]
|
|
1642
|
+
* [limit …] [offset …] { col: tN.col, …, <sub-blocks> }`. Params bind in
|
|
1643
|
+
* emission order (the projection is the statement's final clause, so nested
|
|
1644
|
+
* params always follow the parent's filter/order/limit/offset params).
|
|
1645
|
+
* Aliases share the parent statement's counter, so arbitrarily deep and
|
|
1646
|
+
* self-referential trees stay collision-free (the same discipline as the SQL
|
|
1647
|
+
* engine's json_agg subqueries).
|
|
1648
|
+
*/
|
|
1649
|
+
async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout) {
|
|
1650
|
+
const { rel, relName, targetQi, options, single } = plan;
|
|
1651
|
+
const alias = `t${aliasCtr.n++}`;
|
|
1652
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey)[0];
|
|
1653
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
|
|
1654
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk : rk;
|
|
1655
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk : fk;
|
|
1656
|
+
// Exactly one equi-correlation predicate (the 0.18 correlation rule); the
|
|
1657
|
+
// child where chains behind it as a parenthesized `and` group, which the
|
|
1658
|
+
// grammar accepts even when the group itself contains `or`.
|
|
1659
|
+
const correlation = `${alias}.${(0, powdb_js_1.quotePowqlIdent)(childKeyCol)} = ${parentAlias}.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}`;
|
|
1660
|
+
const resolved = await targetQi.resolveRelationFilters(options.where, timeout);
|
|
1661
|
+
const childWhere = targetQi.buildWhere(resolved, params, alias);
|
|
1662
|
+
const filter = childWhere ? `${correlation} and (${childWhere})` : correlation;
|
|
1663
|
+
const order = targetQi.buildOrder(options.orderBy, params, alias);
|
|
1664
|
+
const limitClause = single
|
|
1665
|
+
? ' limit 1'
|
|
1666
|
+
: options.limit !== undefined
|
|
1667
|
+
? ` limit ${this.param(options.limit, params)}`
|
|
1668
|
+
: '';
|
|
1669
|
+
const offsetClause = !single && options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
1670
|
+
const parts = plan.cols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: ${alias}.${(0, powdb_js_1.quotePowqlIdent)(c)}`);
|
|
1671
|
+
for (const child of plan.children) {
|
|
1672
|
+
parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout));
|
|
1673
|
+
}
|
|
1674
|
+
return (`${(0, powdb_js_1.quotePowqlIdent)(relName)}: ${targetQi.qt} as ${alias} ` +
|
|
1675
|
+
`filter ${filter}${order}${limitClause}${offsetClause} { ${parts.join(', ')} }`);
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Shape the nested JSON children back into typed entities on every parent
|
|
1679
|
+
* row. The nested field arrives as a decoded JSON array on the native wire
|
|
1680
|
+
* (or JSON text on the legacy wire — parsed here); its values are real JSON
|
|
1681
|
+
* types, so each child object goes through the NATIVE coercion policy
|
|
1682
|
+
* (`rowToEntity(…, true)`: a date column's micros number becomes a `Date`, a
|
|
1683
|
+
* json column's document passes through, a str `"null"` stays a string).
|
|
1684
|
+
* to-one relations unwrap to `[0] ?? null`, matching the loaders exactly.
|
|
1685
|
+
*/
|
|
1686
|
+
attachNestedRows(entities, plans) {
|
|
1687
|
+
for (const entity of entities) {
|
|
1688
|
+
for (const plan of plans)
|
|
1689
|
+
this.attachOneNested(entity, plan);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
attachOneNested(row, plan) {
|
|
1693
|
+
let value = row[plan.relName];
|
|
1694
|
+
if (typeof value === 'string') {
|
|
1695
|
+
try {
|
|
1696
|
+
value = JSON.parse(value);
|
|
1697
|
+
}
|
|
1698
|
+
catch {
|
|
1699
|
+
value = [];
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
const items = Array.isArray(value) ? value : [];
|
|
1703
|
+
const shaped = items.map((o) => (0, powdb_js_1.rowToEntity)(o, plan.targetQi.meta, true));
|
|
1704
|
+
for (const child of shaped) {
|
|
1705
|
+
for (const sub of plan.children)
|
|
1706
|
+
plan.targetQi.attachOneNested(child, sub);
|
|
1707
|
+
}
|
|
1708
|
+
row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
|
|
1709
|
+
}
|
|
1710
|
+
// -------------------------------------------------------------------------
|
|
1506
1711
|
// Writes (reselect — PowDB has no RETURNING)
|
|
1507
1712
|
// -------------------------------------------------------------------------
|
|
1508
1713
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
package/dist/powdb.d.ts
CHANGED
|
@@ -254,18 +254,27 @@ export interface PowdbCapabilities {
|
|
|
254
254
|
introspection: boolean;
|
|
255
255
|
/** ≥ 0.13: server-side joins, hash-accelerated and bounded. */
|
|
256
256
|
serverJoins: boolean;
|
|
257
|
+
/**
|
|
258
|
+
* ≥ 0.18: nested projections (shaped results) — a projection field may be a
|
|
259
|
+
* whole correlated child query returning a per-parent JSON array. When set,
|
|
260
|
+
* eligible `with` clauses compile into the parent statement instead of the
|
|
261
|
+
* batched loaders.
|
|
262
|
+
*/
|
|
263
|
+
nestedProjections: boolean;
|
|
257
264
|
/** Networked only: server ≥ 0.13 AND the client exposes `queryNativeRaw`. */
|
|
258
265
|
nativeRaw: boolean;
|
|
259
266
|
}
|
|
260
267
|
/** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
|
|
261
|
-
type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins';
|
|
268
|
+
type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections';
|
|
262
269
|
/**
|
|
263
270
|
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
264
271
|
* for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
|
|
265
272
|
* did not go through {@link turbinePowDB}'s version probe (e.g. an injected
|
|
266
273
|
* pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
|
|
267
274
|
* actual wire path and must only be enabled after a real server-version probe,
|
|
268
|
-
* never inferred from a bare construction.
|
|
275
|
+
* never inferred from a bare construction. `nestedProjections` stays OFF for
|
|
276
|
+
* the same reason: it changes the generated PowQL for every `with` query, and
|
|
277
|
+
* an unprobed engine below 0.18 would reject the syntax outright.
|
|
269
278
|
*/
|
|
270
279
|
export declare const ALL_POWDB_CAPABILITIES: PowdbCapabilities;
|
|
271
280
|
/**
|
|
@@ -388,7 +397,10 @@ export declare function rowToEntity(raw: Record<string, unknown>, meta: TableMet
|
|
|
388
397
|
* `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
|
|
389
398
|
*
|
|
390
399
|
* So we always run the unique-constraint and message-shape checks first (they
|
|
391
|
-
* fire for both transports
|
|
400
|
+
* fire for both transports and extract detail like constraint / column names),
|
|
401
|
+
* then classify by the typed wire error class (`.wireErrorClass`, networked
|
|
402
|
+
* server >= 0.17 — accurate even when the server sanitized the message text),
|
|
403
|
+
* then fall through to the networked `.code` switch.
|
|
392
404
|
*/
|
|
393
405
|
export declare function wrapPowdbError(err: unknown): Error;
|
|
394
406
|
/**
|
|
@@ -599,7 +611,7 @@ export declare function encodePowqlLiteral(value: unknown): string;
|
|
|
599
611
|
* the ceiling yet still routes through the string wire is refused rather than
|
|
600
612
|
* materialized.
|
|
601
613
|
*/
|
|
602
|
-
export declare const POWQL_LEXER_TESTED_CEILING = "0.
|
|
614
|
+
export declare const POWQL_LEXER_TESTED_CEILING = "0.18";
|
|
603
615
|
/**
|
|
604
616
|
* Substitute every `$N` placeholder in a generator-produced PowQL template with
|
|
605
617
|
* the encoded literal of `params[N-1]`. Safe because the template is produced by
|
package/dist/powdb.js
CHANGED
|
@@ -196,6 +196,7 @@ const POWDB_FEATURE_MIN_VERSION = {
|
|
|
196
196
|
jsonDocs: '0.12',
|
|
197
197
|
docFieldIndexes: '0.13',
|
|
198
198
|
serverJoins: '0.13',
|
|
199
|
+
nestedProjections: '0.18',
|
|
199
200
|
};
|
|
200
201
|
/**
|
|
201
202
|
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
@@ -203,7 +204,9 @@ const POWDB_FEATURE_MIN_VERSION = {
|
|
|
203
204
|
* did not go through {@link turbinePowDB}'s version probe (e.g. an injected
|
|
204
205
|
* pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
|
|
205
206
|
* actual wire path and must only be enabled after a real server-version probe,
|
|
206
|
-
* never inferred from a bare construction.
|
|
207
|
+
* never inferred from a bare construction. `nestedProjections` stays OFF for
|
|
208
|
+
* the same reason: it changes the generated PowQL for every `with` query, and
|
|
209
|
+
* an unprobed engine below 0.18 would reject the syntax outright.
|
|
207
210
|
*/
|
|
208
211
|
export const ALL_POWDB_CAPABILITIES = {
|
|
209
212
|
engineVersion: null,
|
|
@@ -211,6 +214,7 @@ export const ALL_POWDB_CAPABILITIES = {
|
|
|
211
214
|
docFieldIndexes: true,
|
|
212
215
|
introspection: true,
|
|
213
216
|
serverJoins: true,
|
|
217
|
+
nestedProjections: false,
|
|
214
218
|
nativeRaw: false,
|
|
215
219
|
};
|
|
216
220
|
/** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
|
|
@@ -239,6 +243,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
|
|
|
239
243
|
docFieldIndexes: false,
|
|
240
244
|
introspection: false,
|
|
241
245
|
serverJoins: false,
|
|
246
|
+
nestedProjections: false,
|
|
242
247
|
nativeRaw: false,
|
|
243
248
|
};
|
|
244
249
|
}
|
|
@@ -248,6 +253,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
|
|
|
248
253
|
jsonDocs: atLeastVersion(sem, 0, 12),
|
|
249
254
|
docFieldIndexes: atLeastVersion(sem, 0, 13),
|
|
250
255
|
serverJoins: atLeastVersion(sem, 0, 13),
|
|
256
|
+
nestedProjections: atLeastVersion(sem, 0, 18),
|
|
251
257
|
nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
|
|
252
258
|
};
|
|
253
259
|
}
|
|
@@ -674,7 +680,10 @@ export function rowToEntity(raw, meta, native = false) {
|
|
|
674
680
|
* `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
|
|
675
681
|
*
|
|
676
682
|
* So we always run the unique-constraint and message-shape checks first (they
|
|
677
|
-
* fire for both transports
|
|
683
|
+
* fire for both transports and extract detail like constraint / column names),
|
|
684
|
+
* then classify by the typed wire error class (`.wireErrorClass`, networked
|
|
685
|
+
* server >= 0.17 — accurate even when the server sanitized the message text),
|
|
686
|
+
* then fall through to the networked `.code` switch.
|
|
678
687
|
*/
|
|
679
688
|
export function wrapPowdbError(err) {
|
|
680
689
|
if (!err || typeof err !== 'object')
|
|
@@ -765,6 +774,46 @@ export function wrapPowdbError(err) {
|
|
|
765
774
|
if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
|
|
766
775
|
return new ValidationError(`[turbine] PowDB join rejected: ${msg}`);
|
|
767
776
|
}
|
|
777
|
+
// Typed wire error class (networked, server >= 0.17): the client surfaces the
|
|
778
|
+
// stable one-byte class from the error frame as `.wireErrorClass`. Classify by
|
|
779
|
+
// it BEFORE the generic message regexes: the server sanitizes non-allowlisted
|
|
780
|
+
// messages to the generic "query execution error" text, but the class is
|
|
781
|
+
// derived from the typed error at response-build time, so it stays accurate
|
|
782
|
+
// when the message no longer is. The specific message families above still run
|
|
783
|
+
// first because they extract richer detail (constraint / column names) that
|
|
784
|
+
// the class byte cannot carry. Class values are append-only wire contract
|
|
785
|
+
// (docs/errors.md upstream); unknown / absent classes fall through to the
|
|
786
|
+
// pre-0.17 message + `.code` behavior.
|
|
787
|
+
const wireClass = err.wireErrorClass;
|
|
788
|
+
if (typeof wireClass === 'number') {
|
|
789
|
+
switch (wireClass) {
|
|
790
|
+
case 3: // timeout (per-query budget, gate wait, idle timeout) — retryable
|
|
791
|
+
return new TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
|
|
792
|
+
case 4: // limit_exceeded (memory / size budget) — a query-shape defect
|
|
793
|
+
return new ValidationError(`[turbine] PowDB resource limit exceeded: ${msg}`);
|
|
794
|
+
case 5: // readonly_refused — the snapshot-serving routing signal
|
|
795
|
+
return new ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
|
|
796
|
+
cause: err,
|
|
797
|
+
reason: 'snapshot',
|
|
798
|
+
});
|
|
799
|
+
case 6: // auth_failed at CONNECT
|
|
800
|
+
return new ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
|
|
801
|
+
case 7: // rate_limited (repeated bad auth) — connection-establishment class
|
|
802
|
+
return new ConnectionError(`[turbine] PowDB rate-limited this address after repeated failed authentication: ${msg}. Wait before retrying.`, { cause: err });
|
|
803
|
+
case 8: {
|
|
804
|
+
// constraint_violation — today that is always a unique index
|
|
805
|
+
const m = /on\s+\S+\.(\w+)/i.exec(msg);
|
|
806
|
+
return new UniqueConstraintError({ constraint: m?.[1], cause: err });
|
|
807
|
+
}
|
|
808
|
+
case 9: // cancelled (issuing client disconnected) — final, never retry
|
|
809
|
+
return new ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
|
|
810
|
+
case 1: // parse
|
|
811
|
+
case 2: // execution
|
|
812
|
+
return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
813
|
+
default: // internal (0) or an unknown future class: fall through
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
768
817
|
// Type mismatch / parse / execution / storage / unexpected(token) / row too
|
|
769
818
|
// large → validation (E003). On the embedded transport these are the only
|
|
770
819
|
// signal we get (code is always 'GenericFailure'); on the networked path they
|
|
@@ -1391,7 +1440,7 @@ export function encodePowqlLiteral(value) {
|
|
|
1391
1440
|
* the ceiling yet still routes through the string wire is refused rather than
|
|
1392
1441
|
* materialized.
|
|
1393
1442
|
*/
|
|
1394
|
-
export const POWQL_LEXER_TESTED_CEILING = '0.
|
|
1443
|
+
export const POWQL_LEXER_TESTED_CEILING = '0.18';
|
|
1395
1444
|
/** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
|
|
1396
1445
|
function encodePowqlString(s) {
|
|
1397
1446
|
let out = '"';
|
package/dist/powql.d.ts
CHANGED
|
@@ -267,13 +267,20 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
267
267
|
private shape;
|
|
268
268
|
findMany(args?: FindManyArgs<T>): Promise<T[]>;
|
|
269
269
|
/**
|
|
270
|
-
* Compile the
|
|
271
|
-
*
|
|
270
|
+
* Compile the findMany select into PowQL (no execution), pushing values into
|
|
271
|
+
* `params`. Returns the query plus the RESOLVED where (relation filters
|
|
272
272
|
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
273
273
|
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
274
|
+
*
|
|
275
|
+
* When the engine supports nested projections (>= 0.18) and the strategy
|
|
276
|
+
* does not opt out, eligible `with` relations compile INTO this statement as
|
|
277
|
+
* nested-projection blocks (`nestedPlans`) — one round-trip for the whole
|
|
278
|
+
* shape — and only the ineligible remainder (`residualWith`) goes to the
|
|
279
|
+
* post-execution loaders. Without nesting the emitted PowQL is byte-identical
|
|
280
|
+
* to the pre-0.18 output (no alias, `.col` refs).
|
|
274
281
|
*/
|
|
275
282
|
private buildFind;
|
|
276
|
-
/** Build + run the
|
|
283
|
+
/** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
|
|
277
284
|
private runFind;
|
|
278
285
|
/**
|
|
279
286
|
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
@@ -396,6 +403,56 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
396
403
|
* an int key matches whether it came back typed or as text.
|
|
397
404
|
*/
|
|
398
405
|
private joinKey;
|
|
406
|
+
/**
|
|
407
|
+
* Should this query's `with` compile to nested-projection blocks? Requires
|
|
408
|
+
* the engine capability (>= 0.18), and an EXPLICIT `relationLoadStrategy:
|
|
409
|
+
* 'batched'` (per-query or client-level) opts back out to the keyed loaders.
|
|
410
|
+
* The default and `'join'` both prefer nesting: unlike the F2 INNER join it
|
|
411
|
+
* has no fan-out, keeps childless parents, works under parent paging, and
|
|
412
|
+
* applies per-parent `order`/`limit`/`offset` natively, so it is the
|
|
413
|
+
* strictly-better single-statement path wherever it is eligible. Ineligible
|
|
414
|
+
* relations fall through to the existing strategy resolution untouched.
|
|
415
|
+
*/
|
|
416
|
+
private nestedProjectionsPreferred;
|
|
417
|
+
/**
|
|
418
|
+
* Plan one `with` relation as a nested-projection block, or return `null`
|
|
419
|
+
* when the shape must stay on the loaders (ALWAYS a silent fallback with
|
|
420
|
+
* identical output, never an error):
|
|
421
|
+
* - m2m (the block takes exactly one child table; the junction-order
|
|
422
|
+
* stitch has no nested equivalent), and composite relation keys;
|
|
423
|
+
* - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
|
|
424
|
+
* - `distinct` inside the relation options (no nested grammar for it);
|
|
425
|
+
* - a projected child column whose tsType is `bigint` or `Uint8Array`
|
|
426
|
+
* (values ride a JSON array, which cannot carry them losslessly);
|
|
427
|
+
* - a projection key collision (a child column named like a sub-relation);
|
|
428
|
+
* - depth >= 10 (the loader path enforces the same cap by throwing, so the
|
|
429
|
+
* fallback surfaces the identical E003 today's users get);
|
|
430
|
+
* - any ineligible descendant (the WHOLE relation falls back, so one
|
|
431
|
+
* statement never mixes nested and loader semantics mid-subtree).
|
|
432
|
+
*/
|
|
433
|
+
private planNestedRelation;
|
|
434
|
+
/**
|
|
435
|
+
* Compile one {@link NestedRelationPlan} into its projection-field block:
|
|
436
|
+
* `name: Target as tN filter <correlation> [and (<child where>)] [order …]
|
|
437
|
+
* [limit …] [offset …] { col: tN.col, …, <sub-blocks> }`. Params bind in
|
|
438
|
+
* emission order (the projection is the statement's final clause, so nested
|
|
439
|
+
* params always follow the parent's filter/order/limit/offset params).
|
|
440
|
+
* Aliases share the parent statement's counter, so arbitrarily deep and
|
|
441
|
+
* self-referential trees stay collision-free (the same discipline as the SQL
|
|
442
|
+
* engine's json_agg subqueries).
|
|
443
|
+
*/
|
|
444
|
+
private buildNestedBlock;
|
|
445
|
+
/**
|
|
446
|
+
* Shape the nested JSON children back into typed entities on every parent
|
|
447
|
+
* row. The nested field arrives as a decoded JSON array on the native wire
|
|
448
|
+
* (or JSON text on the legacy wire — parsed here); its values are real JSON
|
|
449
|
+
* types, so each child object goes through the NATIVE coercion policy
|
|
450
|
+
* (`rowToEntity(…, true)`: a date column's micros number becomes a `Date`, a
|
|
451
|
+
* json column's document passes through, a str `"null"` stays a string).
|
|
452
|
+
* to-one relations unwrap to `[0] ?? null`, matching the loaders exactly.
|
|
453
|
+
*/
|
|
454
|
+
private attachNestedRows;
|
|
455
|
+
private attachOneNested;
|
|
399
456
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
400
457
|
private scalarData;
|
|
401
458
|
/**
|
package/dist/powql.js
CHANGED
|
@@ -894,30 +894,63 @@ export class PowqlInterface {
|
|
|
894
894
|
// -------------------------------------------------------------------------
|
|
895
895
|
async findMany(args = {}) {
|
|
896
896
|
return this.withMiddleware('findMany', args, async () => {
|
|
897
|
-
const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
|
|
897
|
+
const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
|
|
898
898
|
const entities = this.shape(rows, native);
|
|
899
|
-
if (
|
|
900
|
-
|
|
899
|
+
if (nestedPlans.length)
|
|
900
|
+
this.attachNestedRows(entities, nestedPlans);
|
|
901
|
+
if (residualWith) {
|
|
902
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
|
|
901
903
|
}
|
|
902
904
|
return entities;
|
|
903
905
|
});
|
|
904
906
|
}
|
|
905
907
|
/**
|
|
906
|
-
* Compile the
|
|
907
|
-
*
|
|
908
|
+
* Compile the findMany select into PowQL (no execution), pushing values into
|
|
909
|
+
* `params`. Returns the query plus the RESOLVED where (relation filters
|
|
908
910
|
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
909
911
|
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
912
|
+
*
|
|
913
|
+
* When the engine supports nested projections (>= 0.18) and the strategy
|
|
914
|
+
* does not opt out, eligible `with` relations compile INTO this statement as
|
|
915
|
+
* nested-projection blocks (`nestedPlans`) — one round-trip for the whole
|
|
916
|
+
* shape — and only the ineligible remainder (`residualWith`) goes to the
|
|
917
|
+
* post-execution loaders. Without nesting the emitted PowQL is byte-identical
|
|
918
|
+
* to the pre-0.18 output (no alias, `.col` refs).
|
|
910
919
|
*/
|
|
911
920
|
async buildFind(args, params) {
|
|
912
921
|
if (args.cursor) {
|
|
913
922
|
throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
914
923
|
}
|
|
915
924
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
916
|
-
const where = this.buildWhere(resolvedWhere, params);
|
|
917
925
|
const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
|
|
926
|
+
// Partition the `with` clause: nested-projection blocks vs loader residue.
|
|
927
|
+
// A parent `distinct` never nests (distinct over a row containing a JSON
|
|
928
|
+
// array is not a defined comparison), and a relation whose field name
|
|
929
|
+
// collides with a projected parent column stays on the loaders (its block
|
|
930
|
+
// key would duplicate the column's).
|
|
931
|
+
const withClause = args.with;
|
|
932
|
+
const nestedPlans = [];
|
|
933
|
+
let residualWith = withClause;
|
|
934
|
+
if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
|
|
935
|
+
const residue = {};
|
|
936
|
+
for (const [relName, opt] of Object.entries(withClause)) {
|
|
937
|
+
if (!opt)
|
|
938
|
+
continue;
|
|
939
|
+
const rel = this.meta.relations[relName];
|
|
940
|
+
const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
|
|
941
|
+
if (plan)
|
|
942
|
+
nestedPlans.push(plan);
|
|
943
|
+
else
|
|
944
|
+
residue[relName] = opt; // unknown relation: the loader raises its E003
|
|
945
|
+
}
|
|
946
|
+
residualWith = Object.keys(residue).length ? residue : undefined;
|
|
947
|
+
}
|
|
948
|
+
const nest = nestedPlans.length > 0;
|
|
949
|
+
const alias = nest ? 't0' : undefined;
|
|
950
|
+
const where = this.buildWhere(resolvedWhere, params, alias);
|
|
918
951
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
919
952
|
const filter = where ? ` filter ${where}` : '';
|
|
920
|
-
const order = this.buildOrder(args.orderBy, params);
|
|
953
|
+
const order = this.buildOrder(args.orderBy, params, alias);
|
|
921
954
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
922
955
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
923
956
|
this.warnedUnlimited = true;
|
|
@@ -925,15 +958,29 @@ export class PowqlInterface {
|
|
|
925
958
|
}
|
|
926
959
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
927
960
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
928
|
-
|
|
929
|
-
|
|
961
|
+
let projection;
|
|
962
|
+
if (nest) {
|
|
963
|
+
// Aliased scan: every parent column keys itself (`col: t0.col`) so the
|
|
964
|
+
// result columns keep their bare snake names, then the nested blocks.
|
|
965
|
+
const aliasCtr = { n: 1 };
|
|
966
|
+
const parts = cols.map((c) => `${quotePowqlIdent(c)}: t0.${quotePowqlIdent(c)}`);
|
|
967
|
+
for (const plan of nestedPlans) {
|
|
968
|
+
parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
|
|
969
|
+
}
|
|
970
|
+
projection = `{ ${parts.join(', ')} }`;
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
projection = this.projection(cols);
|
|
974
|
+
}
|
|
975
|
+
const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
|
|
976
|
+
return { powql, resolvedWhere, nestedPlans, residualWith };
|
|
930
977
|
}
|
|
931
|
-
/** Build + run the
|
|
978
|
+
/** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
|
|
932
979
|
async runFind(args, action = 'findMany') {
|
|
933
980
|
const params = [];
|
|
934
|
-
const { powql, resolvedWhere } = await this.buildFind(args, params);
|
|
981
|
+
const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
|
|
935
982
|
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
936
|
-
return { rows, native, resolvedWhere };
|
|
983
|
+
return { rows, native, resolvedWhere, nestedPlans, residualWith };
|
|
937
984
|
}
|
|
938
985
|
/**
|
|
939
986
|
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
@@ -961,23 +1008,27 @@ export class PowqlInterface {
|
|
|
961
1008
|
}
|
|
962
1009
|
async findUnique(args) {
|
|
963
1010
|
return this.withMiddleware('findUnique', args, async () => {
|
|
964
|
-
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1011
|
+
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
965
1012
|
if (!rows.length)
|
|
966
1013
|
return null;
|
|
967
1014
|
const entities = this.shape(rows, native);
|
|
968
|
-
if (
|
|
969
|
-
|
|
1015
|
+
if (nestedPlans.length)
|
|
1016
|
+
this.attachNestedRows(entities, nestedPlans);
|
|
1017
|
+
if (residualWith)
|
|
1018
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
970
1019
|
return entities[0];
|
|
971
1020
|
});
|
|
972
1021
|
}
|
|
973
1022
|
async findFirst(args = {}) {
|
|
974
1023
|
return this.withMiddleware('findFirst', args, async () => {
|
|
975
|
-
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1024
|
+
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
976
1025
|
if (!rows.length)
|
|
977
1026
|
return null;
|
|
978
1027
|
const entities = this.shape(rows, native);
|
|
979
|
-
if (
|
|
980
|
-
|
|
1028
|
+
if (nestedPlans.length)
|
|
1029
|
+
this.attachNestedRows(entities, nestedPlans);
|
|
1030
|
+
if (residualWith)
|
|
1031
|
+
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
981
1032
|
return entities[0];
|
|
982
1033
|
});
|
|
983
1034
|
}
|
|
@@ -1467,6 +1518,160 @@ export class PowqlInterface {
|
|
|
1467
1518
|
return String(v);
|
|
1468
1519
|
}
|
|
1469
1520
|
// -------------------------------------------------------------------------
|
|
1521
|
+
// Nested relations: nested projections / shaped results (PowDB >= 0.18)
|
|
1522
|
+
// -------------------------------------------------------------------------
|
|
1523
|
+
/**
|
|
1524
|
+
* Should this query's `with` compile to nested-projection blocks? Requires
|
|
1525
|
+
* the engine capability (>= 0.18), and an EXPLICIT `relationLoadStrategy:
|
|
1526
|
+
* 'batched'` (per-query or client-level) opts back out to the keyed loaders.
|
|
1527
|
+
* The default and `'join'` both prefer nesting: unlike the F2 INNER join it
|
|
1528
|
+
* has no fan-out, keeps childless parents, works under parent paging, and
|
|
1529
|
+
* applies per-parent `order`/`limit`/`offset` natively, so it is the
|
|
1530
|
+
* strictly-better single-statement path wherever it is eligible. Ineligible
|
|
1531
|
+
* relations fall through to the existing strategy resolution untouched.
|
|
1532
|
+
*/
|
|
1533
|
+
nestedProjectionsPreferred(args) {
|
|
1534
|
+
if (!this.capabilities.nestedProjections)
|
|
1535
|
+
return false;
|
|
1536
|
+
const explicit = args.relationLoadStrategy ?? this.options.relationLoadStrategy;
|
|
1537
|
+
return explicit !== 'batched';
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Plan one `with` relation as a nested-projection block, or return `null`
|
|
1541
|
+
* when the shape must stay on the loaders (ALWAYS a silent fallback with
|
|
1542
|
+
* identical output, never an error):
|
|
1543
|
+
* - m2m (the block takes exactly one child table; the junction-order
|
|
1544
|
+
* stitch has no nested equivalent), and composite relation keys;
|
|
1545
|
+
* - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
|
|
1546
|
+
* - `distinct` inside the relation options (no nested grammar for it);
|
|
1547
|
+
* - a projected child column whose tsType is `bigint` or `Uint8Array`
|
|
1548
|
+
* (values ride a JSON array, which cannot carry them losslessly);
|
|
1549
|
+
* - a projection key collision (a child column named like a sub-relation);
|
|
1550
|
+
* - depth >= 10 (the loader path enforces the same cap by throwing, so the
|
|
1551
|
+
* fallback surfaces the identical E003 today's users get);
|
|
1552
|
+
* - any ineligible descendant (the WHOLE relation falls back, so one
|
|
1553
|
+
* statement never mixes nested and loader semantics mid-subtree).
|
|
1554
|
+
*/
|
|
1555
|
+
planNestedRelation(relName, rel, opt, includePii, depth = 0) {
|
|
1556
|
+
if (depth >= 10)
|
|
1557
|
+
return null;
|
|
1558
|
+
if (rel.type === 'manyToMany')
|
|
1559
|
+
return null;
|
|
1560
|
+
if (normalizeKeyColumns(rel.foreignKey).length > 1 || normalizeKeyColumns(rel.referenceKey).length > 1) {
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
if (!this.schema.tables[rel.to])
|
|
1564
|
+
return null; // the loader raises its own error
|
|
1565
|
+
const options = (opt === true ? {} : opt);
|
|
1566
|
+
if (options.distinct?.length)
|
|
1567
|
+
return null;
|
|
1568
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1569
|
+
if (single && (options.limit !== undefined || options.offset))
|
|
1570
|
+
return null;
|
|
1571
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1572
|
+
const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
|
|
1573
|
+
const byName = new Map(targetQi.meta.columns.map((c) => [c.name, c]));
|
|
1574
|
+
for (const c of cols) {
|
|
1575
|
+
const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
|
|
1576
|
+
if (ts === 'bigint' || ts === 'Uint8Array')
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
const children = [];
|
|
1580
|
+
if (options.with) {
|
|
1581
|
+
for (const [subName, subOpt] of Object.entries(options.with)) {
|
|
1582
|
+
if (!subOpt)
|
|
1583
|
+
continue;
|
|
1584
|
+
const subRel = targetQi.meta.relations[subName];
|
|
1585
|
+
if (!subRel)
|
|
1586
|
+
return null; // let the loader raise its unknown-relation error
|
|
1587
|
+
const child = targetQi.planNestedRelation(subName, subRel, subOpt, includePii, depth + 1);
|
|
1588
|
+
if (!child)
|
|
1589
|
+
return null;
|
|
1590
|
+
children.push(child);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
// The block's object keys are the projected snake column names plus the
|
|
1594
|
+
// sub-relation field names; a duplicate key would mis-shape the JSON.
|
|
1595
|
+
const keys = new Set(cols);
|
|
1596
|
+
for (const child of children) {
|
|
1597
|
+
if (keys.has(child.relName))
|
|
1598
|
+
return null;
|
|
1599
|
+
keys.add(child.relName);
|
|
1600
|
+
}
|
|
1601
|
+
return { relName, rel, options, targetQi, single, cols, children };
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Compile one {@link NestedRelationPlan} into its projection-field block:
|
|
1605
|
+
* `name: Target as tN filter <correlation> [and (<child where>)] [order …]
|
|
1606
|
+
* [limit …] [offset …] { col: tN.col, …, <sub-blocks> }`. Params bind in
|
|
1607
|
+
* emission order (the projection is the statement's final clause, so nested
|
|
1608
|
+
* params always follow the parent's filter/order/limit/offset params).
|
|
1609
|
+
* Aliases share the parent statement's counter, so arbitrarily deep and
|
|
1610
|
+
* self-referential trees stay collision-free (the same discipline as the SQL
|
|
1611
|
+
* engine's json_agg subqueries).
|
|
1612
|
+
*/
|
|
1613
|
+
async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout) {
|
|
1614
|
+
const { rel, relName, targetQi, options, single } = plan;
|
|
1615
|
+
const alias = `t${aliasCtr.n++}`;
|
|
1616
|
+
const fk = normalizeKeyColumns(rel.foreignKey)[0];
|
|
1617
|
+
const rk = normalizeKeyColumns(rel.referenceKey)[0];
|
|
1618
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk : rk;
|
|
1619
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk : fk;
|
|
1620
|
+
// Exactly one equi-correlation predicate (the 0.18 correlation rule); the
|
|
1621
|
+
// child where chains behind it as a parenthesized `and` group, which the
|
|
1622
|
+
// grammar accepts even when the group itself contains `or`.
|
|
1623
|
+
const correlation = `${alias}.${quotePowqlIdent(childKeyCol)} = ${parentAlias}.${quotePowqlIdent(parentKeyCol)}`;
|
|
1624
|
+
const resolved = await targetQi.resolveRelationFilters(options.where, timeout);
|
|
1625
|
+
const childWhere = targetQi.buildWhere(resolved, params, alias);
|
|
1626
|
+
const filter = childWhere ? `${correlation} and (${childWhere})` : correlation;
|
|
1627
|
+
const order = targetQi.buildOrder(options.orderBy, params, alias);
|
|
1628
|
+
const limitClause = single
|
|
1629
|
+
? ' limit 1'
|
|
1630
|
+
: options.limit !== undefined
|
|
1631
|
+
? ` limit ${this.param(options.limit, params)}`
|
|
1632
|
+
: '';
|
|
1633
|
+
const offsetClause = !single && options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
1634
|
+
const parts = plan.cols.map((c) => `${quotePowqlIdent(c)}: ${alias}.${quotePowqlIdent(c)}`);
|
|
1635
|
+
for (const child of plan.children) {
|
|
1636
|
+
parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout));
|
|
1637
|
+
}
|
|
1638
|
+
return (`${quotePowqlIdent(relName)}: ${targetQi.qt} as ${alias} ` +
|
|
1639
|
+
`filter ${filter}${order}${limitClause}${offsetClause} { ${parts.join(', ')} }`);
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Shape the nested JSON children back into typed entities on every parent
|
|
1643
|
+
* row. The nested field arrives as a decoded JSON array on the native wire
|
|
1644
|
+
* (or JSON text on the legacy wire — parsed here); its values are real JSON
|
|
1645
|
+
* types, so each child object goes through the NATIVE coercion policy
|
|
1646
|
+
* (`rowToEntity(…, true)`: a date column's micros number becomes a `Date`, a
|
|
1647
|
+
* json column's document passes through, a str `"null"` stays a string).
|
|
1648
|
+
* to-one relations unwrap to `[0] ?? null`, matching the loaders exactly.
|
|
1649
|
+
*/
|
|
1650
|
+
attachNestedRows(entities, plans) {
|
|
1651
|
+
for (const entity of entities) {
|
|
1652
|
+
for (const plan of plans)
|
|
1653
|
+
this.attachOneNested(entity, plan);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
attachOneNested(row, plan) {
|
|
1657
|
+
let value = row[plan.relName];
|
|
1658
|
+
if (typeof value === 'string') {
|
|
1659
|
+
try {
|
|
1660
|
+
value = JSON.parse(value);
|
|
1661
|
+
}
|
|
1662
|
+
catch {
|
|
1663
|
+
value = [];
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
const items = Array.isArray(value) ? value : [];
|
|
1667
|
+
const shaped = items.map((o) => rowToEntity(o, plan.targetQi.meta, true));
|
|
1668
|
+
for (const child of shaped) {
|
|
1669
|
+
for (const sub of plan.children)
|
|
1670
|
+
plan.targetQi.attachOneNested(child, sub);
|
|
1671
|
+
}
|
|
1672
|
+
row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
|
|
1673
|
+
}
|
|
1674
|
+
// -------------------------------------------------------------------------
|
|
1470
1675
|
// Writes (reselect — PowDB has no RETURNING)
|
|
1471
1676
|
// -------------------------------------------------------------------------
|
|
1472
1677
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -103,8 +103,8 @@
|
|
|
103
103
|
"@size-limit/esbuild": "^12.1.0",
|
|
104
104
|
"@size-limit/file": "^12.1.0",
|
|
105
105
|
"@types/node": "^26.1.0",
|
|
106
|
-
"@zvndev/powdb-client": "^0.
|
|
107
|
-
"@zvndev/powdb-embedded": "^0.
|
|
106
|
+
"@zvndev/powdb-client": "^0.17.0",
|
|
107
|
+
"@zvndev/powdb-embedded": "^0.17.0",
|
|
108
108
|
"c8": "^11.0.0",
|
|
109
109
|
"husky": "^9.1.7",
|
|
110
110
|
"lint-staged": "^17.0.8",
|