turbine-orm 0.47.0 → 0.48.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/dist/cjs/powdb-introspect.js +121 -2
- package/dist/cjs/powdb.js +188 -5
- package/dist/cjs/powql.js +207 -8
- package/dist/cjs/query/warn-registry.js +2 -0
- package/dist/powdb-introspect.js +122 -3
- package/dist/powdb.d.ts +100 -3
- package/dist/powdb.js +185 -5
- package/dist/powql.d.ts +51 -0
- package/dist/powql.js +207 -8
- package/dist/query/warn-registry.d.ts +2 -0
- package/dist/query/warn-registry.js +2 -0
- package/package.json +3 -3
package/dist/powdb.js
CHANGED
|
@@ -57,6 +57,8 @@ import { TurbineClient, } from './client.js';
|
|
|
57
57
|
import { postgresDialect } from './dialect.js';
|
|
58
58
|
import { ConnectionError, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
|
|
59
59
|
import importOptionalPeer from './optional-peer-import.cjs';
|
|
60
|
+
import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
|
|
61
|
+
import { normalizeKeyColumns } from './schema.js';
|
|
60
62
|
/**
|
|
61
63
|
* Capability descriptor for PowDB. PowQL generation is owned by
|
|
62
64
|
* {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
|
|
@@ -190,7 +192,14 @@ export function assertSupportedPowdbVersion(version) {
|
|
|
190
192
|
throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
|
|
191
193
|
'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
|
|
192
194
|
}
|
|
193
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Minimum engine version each gated feature needs, for the E017 hint text.
|
|
197
|
+
* Most gates carry a `major.minor` floor (patch-insensitive); the two link
|
|
198
|
+
* lanes carry a `major.minor.patch` floor (`0.19.1`) because the listing
|
|
199
|
+
* statement and the safe traversal semantics landed in the PATCH release, not
|
|
200
|
+
* in 0.19.0. {@link atLeastVersion} compares all three components, so a
|
|
201
|
+
* `major.minor` floor still matches every patch of that minor.
|
|
202
|
+
*/
|
|
194
203
|
const POWDB_FEATURE_MIN_VERSION = {
|
|
195
204
|
introspection: '0.10',
|
|
196
205
|
jsonDocs: '0.12',
|
|
@@ -198,6 +207,8 @@ const POWDB_FEATURE_MIN_VERSION = {
|
|
|
198
207
|
serverJoins: '0.13',
|
|
199
208
|
nestedProjections: '0.18',
|
|
200
209
|
entityLinks: '0.19',
|
|
210
|
+
linkIntrospection: '0.19.1',
|
|
211
|
+
linkPaths: '0.19.1',
|
|
201
212
|
};
|
|
202
213
|
/**
|
|
203
214
|
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
@@ -211,6 +222,11 @@ const POWDB_FEATURE_MIN_VERSION = {
|
|
|
211
222
|
* `entityLinks` stays OFF for a stronger reason still: declaring a link
|
|
212
223
|
* one-way-upgrades the on-disk catalog to v7 and locks out pre-0.19 binaries,
|
|
213
224
|
* so it must only ever light up behind a real version probe.
|
|
225
|
+
* `linkIntrospection` / `linkPaths` stay OFF for the same probe-only discipline:
|
|
226
|
+
* `linkPaths` flips real query generation (a to-one `with` compiling to link
|
|
227
|
+
* projections), and `linkIntrospection` is only meaningful once genuinely
|
|
228
|
+
* probed, so both must come from a real version resolution, never a bare
|
|
229
|
+
* construction.
|
|
214
230
|
*/
|
|
215
231
|
export const ALL_POWDB_CAPABILITIES = {
|
|
216
232
|
engineVersion: null,
|
|
@@ -220,6 +236,8 @@ export const ALL_POWDB_CAPABILITIES = {
|
|
|
220
236
|
serverJoins: true,
|
|
221
237
|
nestedProjections: false,
|
|
222
238
|
entityLinks: false,
|
|
239
|
+
linkIntrospection: false,
|
|
240
|
+
linkPaths: false,
|
|
223
241
|
nativeRaw: false,
|
|
224
242
|
};
|
|
225
243
|
/** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
|
|
@@ -229,9 +247,20 @@ function parsePowdbSemver(version) {
|
|
|
229
247
|
return null;
|
|
230
248
|
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
|
|
231
249
|
}
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Is `sem` at least `major.minor.patch`? PATCH-AWARE: `patch` defaults to `0`,
|
|
252
|
+
* so a two-component floor (`atLeastVersion(sem, 0, 19)`) behaves exactly as the
|
|
253
|
+
* old major/minor comparison did (matches every patch of 0.19), while a
|
|
254
|
+
* three-component floor (`atLeastVersion(sem, 0, 19, 1)`) additionally requires
|
|
255
|
+
* the patch, the distinction the link lanes need (0.19.1, never 0.19.0). Every
|
|
256
|
+
* existing two-argument call keeps its prior semantics unchanged.
|
|
257
|
+
*/
|
|
258
|
+
function atLeastVersion(sem, major, minor, patch = 0) {
|
|
259
|
+
if (sem.major !== major)
|
|
260
|
+
return sem.major > major;
|
|
261
|
+
if (sem.minor !== minor)
|
|
262
|
+
return sem.minor > minor;
|
|
263
|
+
return sem.patch >= patch;
|
|
235
264
|
}
|
|
236
265
|
/**
|
|
237
266
|
* Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
|
|
@@ -250,6 +279,8 @@ export function capabilitiesFromVersion(version, opts = {}) {
|
|
|
250
279
|
serverJoins: false,
|
|
251
280
|
nestedProjections: false,
|
|
252
281
|
entityLinks: false,
|
|
282
|
+
linkIntrospection: false,
|
|
283
|
+
linkPaths: false,
|
|
253
284
|
nativeRaw: false,
|
|
254
285
|
};
|
|
255
286
|
}
|
|
@@ -261,6 +292,11 @@ export function capabilitiesFromVersion(version, opts = {}) {
|
|
|
261
292
|
serverJoins: atLeastVersion(sem, 0, 13),
|
|
262
293
|
nestedProjections: atLeastVersion(sem, 0, 18),
|
|
263
294
|
entityLinks: atLeastVersion(sem, 0, 19),
|
|
295
|
+
// PATCH-floored at 0.19.1: the `schema links` listing + `describe` link rows
|
|
296
|
+
// and the safe (hard-erroring) scalar-path traversal both landed in 0.19.1,
|
|
297
|
+
// never 0.19.0.
|
|
298
|
+
linkIntrospection: atLeastVersion(sem, 0, 19, 1),
|
|
299
|
+
linkPaths: atLeastVersion(sem, 0, 19, 1),
|
|
264
300
|
nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
|
|
265
301
|
};
|
|
266
302
|
}
|
|
@@ -469,6 +505,48 @@ export function quotePowqlIdent(name) {
|
|
|
469
505
|
}
|
|
470
506
|
return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
|
|
471
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Derive the entity links Turbine would declare from a schema's relations. One
|
|
510
|
+
* link per single-column hasMany / hasOne / belongsTo relation, owned by the
|
|
511
|
+
* relation's `from` table:
|
|
512
|
+
* - belongsTo Order->User: `Order.user -> User on user_id = id`
|
|
513
|
+
* (localKey = the FK on the owner, targetKey = the referenced key on target).
|
|
514
|
+
* - hasMany User->Order: `User.orders -> Order on id = user_id`
|
|
515
|
+
* (localKey = the referenced key on the owner, targetKey = the FK on the child).
|
|
516
|
+
* Composite-key relations and m2m junctions are skipped (links are single-column;
|
|
517
|
+
* a junction cannot be inferred from links). A relation whose name collides with
|
|
518
|
+
* a column on the owner is skipped and reported through `onCollision` (the caller
|
|
519
|
+
* decides whether to warn). Pure: no capability gate, no side effects.
|
|
520
|
+
*/
|
|
521
|
+
export function deriveDesiredLinks(schema, onCollision) {
|
|
522
|
+
const links = [];
|
|
523
|
+
for (const meta of Object.values(schema.tables)) {
|
|
524
|
+
for (const rel of Object.values(meta.relations)) {
|
|
525
|
+
if (rel.type === 'manyToMany')
|
|
526
|
+
continue;
|
|
527
|
+
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
528
|
+
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
529
|
+
if (fk.length !== 1 || rk.length !== 1)
|
|
530
|
+
continue; // links are single-column
|
|
531
|
+
// A link name that collides with a column on the owner is a hard engine
|
|
532
|
+
// error at declare time, so skip it (and let the caller warn).
|
|
533
|
+
const collides = meta.columns.some((c) => c.name === rel.name || c.field === rel.name);
|
|
534
|
+
if (collides) {
|
|
535
|
+
onCollision?.(meta.name, rel.name);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const localKey = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
539
|
+
const targetKey = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
540
|
+
links.push({ owner: meta.name, name: rel.name, target: rel.to, localKey, targetKey });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return links;
|
|
544
|
+
}
|
|
545
|
+
/** Render one {@link PowdbDesiredLink} as its create-only `link ...` DDL statement. */
|
|
546
|
+
export function powdbLinkStatement(link) {
|
|
547
|
+
return (`link ${quotePowqlIdent(link.owner)}.${quotePowqlIdent(link.name)} -> ` +
|
|
548
|
+
`${quotePowqlIdent(link.target)} on ${quotePowqlIdent(link.localKey)} = ${quotePowqlIdent(link.targetKey)}`);
|
|
549
|
+
}
|
|
472
550
|
export function powqlSchemaDDL(schema, opts = {}) {
|
|
473
551
|
const caps = opts.capabilities;
|
|
474
552
|
const stmts = [];
|
|
@@ -549,8 +627,94 @@ export function powqlSchemaDDL(schema, opts = {}) {
|
|
|
549
627
|
}
|
|
550
628
|
}
|
|
551
629
|
}
|
|
630
|
+
// Entity-link DDL (opt-in). Appended after every type so the referenced types
|
|
631
|
+
// already exist when the links declare. Gated behind the entityLinks capability
|
|
632
|
+
// when a caller supplied one (an old engine has no `link` statement).
|
|
633
|
+
if (opts.emitLinks) {
|
|
634
|
+
if (caps)
|
|
635
|
+
requireCapability(caps, 'entityLinks', 'entity link DDL (`emitLinks`)');
|
|
636
|
+
const links = deriveDesiredLinks(schema, (owner, name) => {
|
|
637
|
+
if (shouldWarnOnce(WARN_NS.powdbLinks, `collide:${owner}.${name}`)) {
|
|
638
|
+
console.warn(`[turbine] powqlSchemaDDL(emitLinks): relation "${name}" on "${owner}" collides with a column of the ` +
|
|
639
|
+
'same name; skipping its link declaration (PowDB hard-errors on a link that shadows a column).');
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
for (const link of links)
|
|
643
|
+
stmts.push(powdbLinkStatement(link));
|
|
644
|
+
}
|
|
552
645
|
return stmts;
|
|
553
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Existence-checked apply of entity-link DDL against a LIVE PowDB database.
|
|
649
|
+
* Because link DDL is create-only (no `if not exists`, redeclaring is an error,
|
|
650
|
+
* and there is no drop spelling), an apply layer must diff against the live
|
|
651
|
+
* catalog first: this reads the `schema links` listing, then executes only the
|
|
652
|
+
* links that are genuinely missing.
|
|
653
|
+
*
|
|
654
|
+
* - a desired link already declared with the SAME endpoints → skipped (idempotent);
|
|
655
|
+
* - a link declared with the same owner + name but DIFFERENT endpoints → skipped
|
|
656
|
+
* with a one-time warning (never dropped/replaced: there is no drop DDL, and a
|
|
657
|
+
* silent replace would be a destructive schema change);
|
|
658
|
+
* - a name/column collision on the owner → skipped (deriveDesiredLinks warns).
|
|
659
|
+
*
|
|
660
|
+
* Requires the `entityLinks` + `linkIntrospection` capabilities when `capabilities`
|
|
661
|
+
* is supplied (the listing statement is 0.19.1+). Returns the statements executed.
|
|
662
|
+
* `exec` runs one PowQL statement (embedded `db.raw` / a networked query shim); it
|
|
663
|
+
* must return rows keyed by column name for the `schema links` read.
|
|
664
|
+
*/
|
|
665
|
+
export async function applyPowdbLinks(exec, schema, options = {}) {
|
|
666
|
+
const caps = options.capabilities;
|
|
667
|
+
if (caps) {
|
|
668
|
+
requireCapability(caps, 'entityLinks', 'entity link DDL (`applyPowdbLinks`)');
|
|
669
|
+
requireCapability(caps, 'linkIntrospection', 'PowDB `schema links` introspection');
|
|
670
|
+
}
|
|
671
|
+
const existing = await listPowdbLinks(exec);
|
|
672
|
+
const byOwnerName = new Map(existing.map((l) => [`${l.owner}${l.name}`, l]));
|
|
673
|
+
const desired = deriveDesiredLinks(schema, (owner, name) => {
|
|
674
|
+
if (shouldWarnOnce(WARN_NS.powdbLinks, `collide:${owner}.${name}`)) {
|
|
675
|
+
console.warn(`[turbine] applyPowdbLinks: relation "${name}" on "${owner}" collides with a column of the same name; ` +
|
|
676
|
+
'skipping its link (PowDB hard-errors on a link that shadows a column).');
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
const executed = [];
|
|
680
|
+
for (const link of desired) {
|
|
681
|
+
const found = byOwnerName.get(`${link.owner}${link.name}`);
|
|
682
|
+
if (found) {
|
|
683
|
+
const same = found.target === link.target && found.localKey === link.localKey && found.targetKey === link.targetKey;
|
|
684
|
+
if (!same && shouldWarnOnce(WARN_NS.powdbLinks, `drift:${link.owner}.${link.name}`)) {
|
|
685
|
+
console.warn(`[turbine] applyPowdbLinks: link "${link.name}" on "${link.owner}" is already declared with different ` +
|
|
686
|
+
`endpoints (have ${found.target} on ${found.localKey} = ${found.targetKey}, want ${link.target} on ` +
|
|
687
|
+
`${link.localKey} = ${link.targetKey}); leaving it untouched (PowDB has no drop-link DDL).`);
|
|
688
|
+
}
|
|
689
|
+
continue; // identical → idempotent skip; drift → warned skip
|
|
690
|
+
}
|
|
691
|
+
const stmt = powdbLinkStatement(link);
|
|
692
|
+
await exec(stmt);
|
|
693
|
+
executed.push(stmt);
|
|
694
|
+
}
|
|
695
|
+
return executed;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Read the live `schema links` listing into {@link PowdbDesiredLink} rows (owner,
|
|
699
|
+
* name, target, localKey, targetKey; cardinality is dropped — it is derived, not
|
|
700
|
+
* a DDL input). An empty catalog returns `[]`, never an error. Naming edge: a
|
|
701
|
+
* table literally named `links` is described with `describe links`, but the
|
|
702
|
+
* link LISTING is the contextual keyword form `schema links`.
|
|
703
|
+
*/
|
|
704
|
+
async function listPowdbLinks(exec) {
|
|
705
|
+
const { rows } = await exec('schema links');
|
|
706
|
+
return rows.map((r) => ({
|
|
707
|
+
owner: powdbCell(r.owner),
|
|
708
|
+
name: powdbCell(r.name),
|
|
709
|
+
target: powdbCell(r.target),
|
|
710
|
+
localKey: powdbCell(r.local_key),
|
|
711
|
+
targetKey: powdbCell(r.target_key),
|
|
712
|
+
}));
|
|
713
|
+
}
|
|
714
|
+
/** Coerce a wire cell (string on the legacy wire, typed on the native wire) to string. */
|
|
715
|
+
function powdbCell(v) {
|
|
716
|
+
return v === null || v === undefined ? '' : String(v);
|
|
717
|
+
}
|
|
554
718
|
/** Coerce a JS value into a PowDB positional param (the write side). */
|
|
555
719
|
function toPowdbParam(value, col) {
|
|
556
720
|
if (value instanceof PowdbFloatParam)
|
|
@@ -790,6 +954,18 @@ export function wrapPowdbError(err) {
|
|
|
790
954
|
if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
|
|
791
955
|
return new ValidationError(`[turbine] PowDB join rejected: ${msg}`);
|
|
792
956
|
}
|
|
957
|
+
// Entity-link misuse hard errors (0.19.1 turned the two silent-wrong-results
|
|
958
|
+
// 0.19.0 behaviors into hard errors) → ValidationError (E003), a query defect
|
|
959
|
+
// to fix. Matched explicitly on the pinned message text so both transports
|
|
960
|
+
// classify identically regardless of how the wire wraps them (embedded tags
|
|
961
|
+
// every error `GenericFailure`; networked carries a wire class): a bare dotted
|
|
962
|
+
// projection path that was not alias-qualified, and an aggregate over a nested
|
|
963
|
+
// or link projection. Turbine's own generator never emits either shape (it
|
|
964
|
+
// always aliases and never aggregates over a link), so these fire only for a
|
|
965
|
+
// raw user PowQL string; mapping them keeps that path typed.
|
|
966
|
+
if (/is ambiguous in a projection|aggregates over a nested or link projection/i.test(msg)) {
|
|
967
|
+
return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
968
|
+
}
|
|
793
969
|
// Typed wire error class (networked, server >= 0.17): the client surfaces the
|
|
794
970
|
// stable one-byte class from the error frame as `.wireErrorClass`. Classify by
|
|
795
971
|
// it BEFORE the generic message regexes: the server sanitizes non-allowlisted
|
|
@@ -1460,7 +1636,11 @@ export function encodePowqlLiteral(value) {
|
|
|
1460
1636
|
* between v0.18.0 and v0.19.0 (empty git diff), and `link` was already a lexer
|
|
1461
1637
|
* keyword at 0.18.0. The 0.19 entity-links surface adds new STATEMENTS built from
|
|
1462
1638
|
* pre-existing tokens, so the tokenization / escape surface this ceiling guards is
|
|
1463
|
-
* unmoved.
|
|
1639
|
+
* unmoved. The 0.19.1 link-introspection / link-path round is likewise lexer-neutral:
|
|
1640
|
+
* `git diff v0.19.0 v0.19.1 -- crates/query/src/lexer.rs` is empty (the bare-dotted-path
|
|
1641
|
+
* hard error is parser-level, not tokenization), so this ceiling stays `'0.19'`. The
|
|
1642
|
+
* guard in {@link PowdbEmbeddedPool.exec} compares major.minor only, so `'0.19'`
|
|
1643
|
+
* already covers every 0.19.x patch — no bump is needed for 0.19.1.
|
|
1464
1644
|
*/
|
|
1465
1645
|
export const POWQL_LEXER_TESTED_CEILING = '0.19';
|
|
1466
1646
|
/** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
|
package/dist/powql.d.ts
CHANGED
|
@@ -453,6 +453,57 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
453
453
|
*/
|
|
454
454
|
private attachNestedRows;
|
|
455
455
|
private attachOneNested;
|
|
456
|
+
/**
|
|
457
|
+
* The `schema links` snapshot for this pool, fetched at most once and cached on
|
|
458
|
+
* the pool identity. A fetch failure resolves to `[]` (a missing listing is a
|
|
459
|
+
* silent fallback to loaders, never an error). Only called when the `linkPaths`
|
|
460
|
+
* capability is on, so the listing statement is guaranteed to exist.
|
|
461
|
+
*/
|
|
462
|
+
private linksSnapshot;
|
|
463
|
+
private fetchLinksSnapshot;
|
|
464
|
+
/** True when `name` is a bare PowQL identifier (quoting leaves it unchanged). */
|
|
465
|
+
private isBareIdent;
|
|
466
|
+
/**
|
|
467
|
+
* Find the declared to-one link on THIS table that matches `rel` exactly: same
|
|
468
|
+
* target, same correlation columns (owner localKey = the FK on this table, target
|
|
469
|
+
* targetKey = the referenced key on target), cardinality `"to-one"`. Returns the
|
|
470
|
+
* declared link (whose NAME drives the path spelling, which may differ from the
|
|
471
|
+
* relation's own name) or `null` for no verifiable match (→ silent loader
|
|
472
|
+
* fallback, never an error).
|
|
473
|
+
*/
|
|
474
|
+
private findMatchingLink;
|
|
475
|
+
/**
|
|
476
|
+
* Plan one belongsTo `with` as a scalar link-path relation, or `null` when it
|
|
477
|
+
* must stay on the loaders (ALWAYS a silent fallback with identical output).
|
|
478
|
+
*
|
|
479
|
+
* SCOPED TIGHT: this fires ONLY for a to-one relation whose child projection
|
|
480
|
+
* includes a bigint/bytes column — exactly the case a JSON nested block cannot
|
|
481
|
+
* carry, so nested projections have already fallen back to a per-relation loader
|
|
482
|
+
* (`planNestedRelation` returned `null` for the same shape). Cases nested
|
|
483
|
+
* projections DO serve keep nested projections: link-bearing statements are
|
|
484
|
+
* NEVER plan-cached upstream, so replacing a cacheable nested projection with a
|
|
485
|
+
* link path would regress a hot path for no gain. Requires: single-column
|
|
486
|
+
* belongsTo; no relation `with` / `where` / `distinct` / `orderBy` /
|
|
487
|
+
* `limit` / `offset` (a scalar path has no per-hop filter/order and cannot
|
|
488
|
+
* reproduce those — such inputs stay on the loader for exact parity); a link
|
|
489
|
+
* name and all projected columns that are bare identifiers (a quoted segment in
|
|
490
|
+
* a dotted link path is outside the verified spelling — fall back); and a
|
|
491
|
+
* DECLARED link that verifiably matches (`findMatchingLink`).
|
|
492
|
+
*/
|
|
493
|
+
private planLinkPathRelation;
|
|
494
|
+
/** The flat `l<i>_<col>: t0.<linkName>.<col>` projection fields for one link plan. */
|
|
495
|
+
private linkPathFields;
|
|
496
|
+
/**
|
|
497
|
+
* Reconstruct each link-path relation's child entity from its flat hop fields
|
|
498
|
+
* and attach it under the relation name — output indistinguishable from the
|
|
499
|
+
* loader (same keys, same coercions). Presence: the target PK cell arriving
|
|
500
|
+
* Empty (a null/dangling FK at the hop) means no linked row → `null`, matching
|
|
501
|
+
* the loader's `matches[0] ?? null`. Otherwise the gathered snake cells go
|
|
502
|
+
* through the SAME `rowToEntity` policy the loader uses (native micros → Date,
|
|
503
|
+
* bigint per the int8 policy), and the PK is stripped back off if the user did
|
|
504
|
+
* not project it. `native` is the wire that actually served the parent row.
|
|
505
|
+
*/
|
|
506
|
+
private attachLinkRows;
|
|
456
507
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
457
508
|
private scalarData;
|
|
458
509
|
/**
|
package/dist/powql.js
CHANGED
|
@@ -81,6 +81,16 @@ const POWQL_WRITE_ACTIONS = new Set([
|
|
|
81
81
|
'deleteMany',
|
|
82
82
|
'upsert',
|
|
83
83
|
]);
|
|
84
|
+
/**
|
|
85
|
+
* Per-pool cache of the `schema links` snapshot (fetched at most once per pool):
|
|
86
|
+
* scalar link-path query generation verifies a declared link matches the
|
|
87
|
+
* relation before compiling to it. Keyed on the pool object identity so every
|
|
88
|
+
* table interface over the same connection shares one snapshot; a WeakMap lets a
|
|
89
|
+
* discarded pool's snapshot be collected. A fetch failure caches `[]` (a missing
|
|
90
|
+
* listing means "no verifiable links" — a silent fallback to loaders, never an
|
|
91
|
+
* error).
|
|
92
|
+
*/
|
|
93
|
+
const LINK_SNAPSHOT_CACHE = new WeakMap();
|
|
84
94
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
85
95
|
const OPERATOR_KEYS = new Set([
|
|
86
96
|
'equals',
|
|
@@ -905,10 +915,12 @@ export class PowqlInterface {
|
|
|
905
915
|
// -------------------------------------------------------------------------
|
|
906
916
|
async findMany(args = {}) {
|
|
907
917
|
return this.withMiddleware('findMany', args, async () => {
|
|
908
|
-
const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
|
|
918
|
+
const { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.runFind(args, 'findMany');
|
|
909
919
|
const entities = this.shape(rows, native);
|
|
910
920
|
if (nestedPlans.length)
|
|
911
921
|
this.attachNestedRows(entities, nestedPlans);
|
|
922
|
+
if (linkPlans.length)
|
|
923
|
+
this.attachLinkRows(entities, linkPlans, native);
|
|
912
924
|
if (residualWith) {
|
|
913
925
|
await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
|
|
914
926
|
}
|
|
@@ -941,6 +953,7 @@ export class PowqlInterface {
|
|
|
941
953
|
// key would duplicate the column's).
|
|
942
954
|
const withClause = args.with;
|
|
943
955
|
const nestedPlans = [];
|
|
956
|
+
const linkPlans = [];
|
|
944
957
|
let residualWith = withClause;
|
|
945
958
|
if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
|
|
946
959
|
const residue = {};
|
|
@@ -949,14 +962,25 @@ export class PowqlInterface {
|
|
|
949
962
|
continue;
|
|
950
963
|
const rel = this.meta.relations[relName];
|
|
951
964
|
const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
|
|
952
|
-
if (plan)
|
|
965
|
+
if (plan) {
|
|
953
966
|
nestedPlans.push(plan);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
// Nested projection could not serve it: try a scalar link path (the narrow
|
|
970
|
+
// to-one bigint/bytes case; the `schema links` snapshot is fetched lazily
|
|
971
|
+
// inside, only when a genuine candidate reaches it), else it stays on the
|
|
972
|
+
// loaders.
|
|
973
|
+
const linkPlan = rel && !cols.includes(relName)
|
|
974
|
+
? await this.planLinkPathRelation(relName, rel, opt, args.includePii === true, cols, linkPlans.length + 1)
|
|
975
|
+
: null;
|
|
976
|
+
if (linkPlan)
|
|
977
|
+
linkPlans.push(linkPlan);
|
|
954
978
|
else
|
|
955
979
|
residue[relName] = opt; // unknown relation: the loader raises its E003
|
|
956
980
|
}
|
|
957
981
|
residualWith = Object.keys(residue).length ? residue : undefined;
|
|
958
982
|
}
|
|
959
|
-
const nest = nestedPlans.length > 0;
|
|
983
|
+
const nest = nestedPlans.length > 0 || linkPlans.length > 0;
|
|
960
984
|
const alias = nest ? 't0' : undefined;
|
|
961
985
|
const where = this.buildWhere(resolvedWhere, params, alias);
|
|
962
986
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
@@ -978,20 +1002,23 @@ export class PowqlInterface {
|
|
|
978
1002
|
for (const plan of nestedPlans) {
|
|
979
1003
|
parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
|
|
980
1004
|
}
|
|
1005
|
+
// Scalar link-path hops (flat native-typed fields; never a JSON block).
|
|
1006
|
+
for (const plan of linkPlans)
|
|
1007
|
+
parts.push(...this.linkPathFields(plan, 't0'));
|
|
981
1008
|
projection = `{ ${parts.join(', ')} }`;
|
|
982
1009
|
}
|
|
983
1010
|
else {
|
|
984
1011
|
projection = this.projection(cols);
|
|
985
1012
|
}
|
|
986
1013
|
const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
|
|
987
|
-
return { powql, resolvedWhere, nestedPlans, residualWith };
|
|
1014
|
+
return { powql, resolvedWhere, nestedPlans, linkPlans, residualWith };
|
|
988
1015
|
}
|
|
989
1016
|
/** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
|
|
990
1017
|
async runFind(args, action = 'findMany') {
|
|
991
1018
|
const params = [];
|
|
992
|
-
const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
|
|
1019
|
+
const { powql, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.buildFind(args, params);
|
|
993
1020
|
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
994
|
-
return { rows, native, resolvedWhere, nestedPlans, residualWith };
|
|
1021
|
+
return { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith };
|
|
995
1022
|
}
|
|
996
1023
|
/**
|
|
997
1024
|
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
@@ -1026,12 +1053,14 @@ export class PowqlInterface {
|
|
|
1026
1053
|
args = { ...args, where: expanded };
|
|
1027
1054
|
}
|
|
1028
1055
|
return this.withMiddleware('findUnique', args, async () => {
|
|
1029
|
-
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1056
|
+
const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1030
1057
|
if (!rows.length)
|
|
1031
1058
|
return null;
|
|
1032
1059
|
const entities = this.shape(rows, native);
|
|
1033
1060
|
if (nestedPlans.length)
|
|
1034
1061
|
this.attachNestedRows(entities, nestedPlans);
|
|
1062
|
+
if (linkPlans.length)
|
|
1063
|
+
this.attachLinkRows(entities, linkPlans, native);
|
|
1035
1064
|
if (residualWith)
|
|
1036
1065
|
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
1037
1066
|
return entities[0];
|
|
@@ -1039,12 +1068,14 @@ export class PowqlInterface {
|
|
|
1039
1068
|
}
|
|
1040
1069
|
async findFirst(args = {}) {
|
|
1041
1070
|
return this.withMiddleware('findFirst', args, async () => {
|
|
1042
|
-
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1071
|
+
const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1043
1072
|
if (!rows.length)
|
|
1044
1073
|
return null;
|
|
1045
1074
|
const entities = this.shape(rows, native);
|
|
1046
1075
|
if (nestedPlans.length)
|
|
1047
1076
|
this.attachNestedRows(entities, nestedPlans);
|
|
1077
|
+
if (linkPlans.length)
|
|
1078
|
+
this.attachLinkRows(entities, linkPlans, native);
|
|
1048
1079
|
if (residualWith)
|
|
1049
1080
|
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
1050
1081
|
return entities[0];
|
|
@@ -1690,6 +1721,174 @@ export class PowqlInterface {
|
|
|
1690
1721
|
row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
|
|
1691
1722
|
}
|
|
1692
1723
|
// -------------------------------------------------------------------------
|
|
1724
|
+
// Scalar to-one link paths (PowDB >= 0.19.1, `linkPaths` capability)
|
|
1725
|
+
// -------------------------------------------------------------------------
|
|
1726
|
+
/**
|
|
1727
|
+
* The `schema links` snapshot for this pool, fetched at most once and cached on
|
|
1728
|
+
* the pool identity. A fetch failure resolves to `[]` (a missing listing is a
|
|
1729
|
+
* silent fallback to loaders, never an error). Only called when the `linkPaths`
|
|
1730
|
+
* capability is on, so the listing statement is guaranteed to exist.
|
|
1731
|
+
*/
|
|
1732
|
+
linksSnapshot() {
|
|
1733
|
+
const pool = this.pool;
|
|
1734
|
+
let snap = LINK_SNAPSHOT_CACHE.get(pool);
|
|
1735
|
+
if (!snap) {
|
|
1736
|
+
snap = this.fetchLinksSnapshot();
|
|
1737
|
+
LINK_SNAPSHOT_CACHE.set(pool, snap);
|
|
1738
|
+
}
|
|
1739
|
+
return snap;
|
|
1740
|
+
}
|
|
1741
|
+
async fetchLinksSnapshot() {
|
|
1742
|
+
try {
|
|
1743
|
+
const { rows } = await this.exec('schema links', [], undefined, 'introspect');
|
|
1744
|
+
return rows.map((r) => ({
|
|
1745
|
+
owner: String(r.owner ?? ''),
|
|
1746
|
+
name: String(r.name ?? ''),
|
|
1747
|
+
target: String(r.target ?? ''),
|
|
1748
|
+
localKey: String(r.local_key ?? ''),
|
|
1749
|
+
targetKey: String(r.target_key ?? ''),
|
|
1750
|
+
cardinality: String(r.cardinality ?? ''),
|
|
1751
|
+
}));
|
|
1752
|
+
}
|
|
1753
|
+
catch {
|
|
1754
|
+
return [];
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
/** True when `name` is a bare PowQL identifier (quoting leaves it unchanged). */
|
|
1758
|
+
isBareIdent(name) {
|
|
1759
|
+
return quotePowqlIdent(name) === name;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Find the declared to-one link on THIS table that matches `rel` exactly: same
|
|
1763
|
+
* target, same correlation columns (owner localKey = the FK on this table, target
|
|
1764
|
+
* targetKey = the referenced key on target), cardinality `"to-one"`. Returns the
|
|
1765
|
+
* declared link (whose NAME drives the path spelling, which may differ from the
|
|
1766
|
+
* relation's own name) or `null` for no verifiable match (→ silent loader
|
|
1767
|
+
* fallback, never an error).
|
|
1768
|
+
*/
|
|
1769
|
+
findMatchingLink(snapshot, rel) {
|
|
1770
|
+
const localKey = normalizeKeyColumns(rel.foreignKey)[0];
|
|
1771
|
+
const targetKey = normalizeKeyColumns(rel.referenceKey)[0];
|
|
1772
|
+
if (!localKey || !targetKey)
|
|
1773
|
+
return null;
|
|
1774
|
+
for (const l of snapshot) {
|
|
1775
|
+
if (l.cardinality === 'to-one' &&
|
|
1776
|
+
l.owner === this.table &&
|
|
1777
|
+
l.target === rel.to &&
|
|
1778
|
+
l.localKey === localKey &&
|
|
1779
|
+
l.targetKey === targetKey) {
|
|
1780
|
+
return l;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Plan one belongsTo `with` as a scalar link-path relation, or `null` when it
|
|
1787
|
+
* must stay on the loaders (ALWAYS a silent fallback with identical output).
|
|
1788
|
+
*
|
|
1789
|
+
* SCOPED TIGHT: this fires ONLY for a to-one relation whose child projection
|
|
1790
|
+
* includes a bigint/bytes column — exactly the case a JSON nested block cannot
|
|
1791
|
+
* carry, so nested projections have already fallen back to a per-relation loader
|
|
1792
|
+
* (`planNestedRelation` returned `null` for the same shape). Cases nested
|
|
1793
|
+
* projections DO serve keep nested projections: link-bearing statements are
|
|
1794
|
+
* NEVER plan-cached upstream, so replacing a cacheable nested projection with a
|
|
1795
|
+
* link path would regress a hot path for no gain. Requires: single-column
|
|
1796
|
+
* belongsTo; no relation `with` / `where` / `distinct` / `orderBy` /
|
|
1797
|
+
* `limit` / `offset` (a scalar path has no per-hop filter/order and cannot
|
|
1798
|
+
* reproduce those — such inputs stay on the loader for exact parity); a link
|
|
1799
|
+
* name and all projected columns that are bare identifiers (a quoted segment in
|
|
1800
|
+
* a dotted link path is outside the verified spelling — fall back); and a
|
|
1801
|
+
* DECLARED link that verifiably matches (`findMatchingLink`).
|
|
1802
|
+
*/
|
|
1803
|
+
async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index) {
|
|
1804
|
+
if (!this.capabilities.linkPaths)
|
|
1805
|
+
return null;
|
|
1806
|
+
if (rel.type !== 'belongsTo')
|
|
1807
|
+
return null;
|
|
1808
|
+
if (normalizeKeyColumns(rel.foreignKey).length !== 1 || normalizeKeyColumns(rel.referenceKey).length !== 1) {
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1812
|
+
if (targetMeta?.primaryKey.length !== 1)
|
|
1813
|
+
return null;
|
|
1814
|
+
const options = (opt === true ? {} : opt);
|
|
1815
|
+
if (options.with || options.where || options.distinct?.length)
|
|
1816
|
+
return null;
|
|
1817
|
+
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
1818
|
+
return null;
|
|
1819
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1820
|
+
const userCols = targetQi.projectedColumns(options.select, options.omit, includePii);
|
|
1821
|
+
const byName = new Map(targetMeta.columns.map((c) => [c.name, c]));
|
|
1822
|
+
// Only adopt link paths where a JSON block genuinely cannot serve the case:
|
|
1823
|
+
// at least one projected child column is bigint/bytes. Otherwise nested
|
|
1824
|
+
// projections already handle it (and cache), so leave it to them.
|
|
1825
|
+
const hasCarrierBlockedCol = userCols.some((c) => {
|
|
1826
|
+
const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
|
|
1827
|
+
return ts === 'bigint' || ts === 'Uint8Array';
|
|
1828
|
+
});
|
|
1829
|
+
if (!hasCarrierBlockedCol)
|
|
1830
|
+
return null;
|
|
1831
|
+
// All cheap checks passed: NOW fetch (and cache) the link snapshot — never
|
|
1832
|
+
// before, so a query with no link-path candidate issues no `schema links`.
|
|
1833
|
+
const link = this.findMatchingLink(await this.linksSnapshot(), rel);
|
|
1834
|
+
if (!link)
|
|
1835
|
+
return null;
|
|
1836
|
+
if (!this.isBareIdent(link.name))
|
|
1837
|
+
return null;
|
|
1838
|
+
if (!userCols.every((c) => this.isBareIdent(c)))
|
|
1839
|
+
return null;
|
|
1840
|
+
// Always project the target PK for presence detection (an absent to-one yields
|
|
1841
|
+
// Empty at every hop; PK-Empty is the unambiguous "no linked row" signal). Add
|
|
1842
|
+
// it if the user's projection dropped it, and remember to strip it back off.
|
|
1843
|
+
const pkCol = targetMeta.primaryKey[0];
|
|
1844
|
+
if (!this.isBareIdent(pkCol))
|
|
1845
|
+
return null;
|
|
1846
|
+
const pkProjected = userCols.includes(pkCol);
|
|
1847
|
+
const cols = pkProjected ? userCols : [...userCols, pkCol];
|
|
1848
|
+
// Synthetic flat result keys (`l<index>_<col>`) keep the hop fields from
|
|
1849
|
+
// colliding with real parent columns or each other. Refuse the (astronomically
|
|
1850
|
+
// unlikely) case where a real parent/child column already uses the prefix.
|
|
1851
|
+
const keyPrefix = `l${index}_`;
|
|
1852
|
+
if (parentCols.some((c) => c.startsWith(keyPrefix)) || cols.some((c) => c.startsWith(keyPrefix)))
|
|
1853
|
+
return null;
|
|
1854
|
+
return { relName, linkName: link.name, targetQi, cols, pkCol, pkProjected, keyPrefix };
|
|
1855
|
+
}
|
|
1856
|
+
/** The flat `l<i>_<col>: t0.<linkName>.<col>` projection fields for one link plan. */
|
|
1857
|
+
linkPathFields(plan, parentAlias) {
|
|
1858
|
+
return plan.cols.map((c) => `${plan.keyPrefix}${c}: ${parentAlias}.${plan.linkName}.${c}`);
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Reconstruct each link-path relation's child entity from its flat hop fields
|
|
1862
|
+
* and attach it under the relation name — output indistinguishable from the
|
|
1863
|
+
* loader (same keys, same coercions). Presence: the target PK cell arriving
|
|
1864
|
+
* Empty (a null/dangling FK at the hop) means no linked row → `null`, matching
|
|
1865
|
+
* the loader's `matches[0] ?? null`. Otherwise the gathered snake cells go
|
|
1866
|
+
* through the SAME `rowToEntity` policy the loader uses (native micros → Date,
|
|
1867
|
+
* bigint per the int8 policy), and the PK is stripped back off if the user did
|
|
1868
|
+
* not project it. `native` is the wire that actually served the parent row.
|
|
1869
|
+
*/
|
|
1870
|
+
attachLinkRows(entities, plans, native) {
|
|
1871
|
+
for (const plan of plans) {
|
|
1872
|
+
const pkField = plan.targetQi.meta.reverseColumnMap[plan.pkCol] ?? plan.pkCol;
|
|
1873
|
+
for (const entity of entities) {
|
|
1874
|
+
const row = entity;
|
|
1875
|
+
const raw = {};
|
|
1876
|
+
for (const c of plan.cols) {
|
|
1877
|
+
raw[c] = row[`${plan.keyPrefix}${c}`];
|
|
1878
|
+
delete row[`${plan.keyPrefix}${c}`];
|
|
1879
|
+
}
|
|
1880
|
+
const child = rowToEntity(raw, plan.targetQi.meta, native);
|
|
1881
|
+
if (child[pkField] == null) {
|
|
1882
|
+
row[plan.relName] = null;
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (!plan.pkProjected)
|
|
1886
|
+
delete child[pkField];
|
|
1887
|
+
row[plan.relName] = child;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
// -------------------------------------------------------------------------
|
|
1693
1892
|
// Writes (reselect — PowDB has no RETURNING)
|
|
1694
1893
|
// -------------------------------------------------------------------------
|
|
1695
1894
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
@@ -54,4 +54,6 @@ export declare const WARN_NS: {
|
|
|
54
54
|
readonly autoStrategy: "autoStrategy";
|
|
55
55
|
/** Deep-`with` (depth > 5) advisory (builder.ts `findMany`). */
|
|
56
56
|
readonly deepWith: "deepWith";
|
|
57
|
+
/** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
|
|
58
|
+
readonly powdbLinks: "powdbLinks";
|
|
57
59
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.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": {
|
|
@@ -108,8 +108,8 @@
|
|
|
108
108
|
"@size-limit/esbuild": "^12.1.0",
|
|
109
109
|
"@size-limit/file": "^12.1.0",
|
|
110
110
|
"@types/node": "^26.1.0",
|
|
111
|
-
"@zvndev/powdb-client": "^0.19.
|
|
112
|
-
"@zvndev/powdb-embedded": "^0.19.
|
|
111
|
+
"@zvndev/powdb-client": "^0.19.1",
|
|
112
|
+
"@zvndev/powdb-embedded": "^0.19.1",
|
|
113
113
|
"c8": "^11.0.0",
|
|
114
114
|
"husky": "^9.1.7",
|
|
115
115
|
"lint-staged": "^17.0.8",
|