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/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
- /** Minimum engine version each gated feature needs, for the E017 hint text. */
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
- /** Is `sem` at least `major.minor`? */
233
- function atLeastVersion(sem, major, minor) {
234
- return sem.major > major || (sem.major === major && sem.minor >= minor);
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
  };
@@ -89,4 +89,6 @@ export const WARN_NS = {
89
89
  autoStrategy: 'autoStrategy',
90
90
  /** Deep-`with` (depth > 5) advisory (builder.ts `findMany`). */
91
91
  deepWith: 'deepWith',
92
+ /** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
93
+ powdbLinks: 'powdbLinks',
92
94
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.47.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.0",
112
- "@zvndev/powdb-embedded": "^0.19.0",
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",