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/powql.js CHANGED
@@ -117,6 +117,16 @@ const POWQL_WRITE_ACTIONS = new Set([
117
117
  'deleteMany',
118
118
  'upsert',
119
119
  ]);
120
+ /**
121
+ * Per-pool cache of the `schema links` snapshot (fetched at most once per pool):
122
+ * scalar link-path query generation verifies a declared link matches the
123
+ * relation before compiling to it. Keyed on the pool object identity so every
124
+ * table interface over the same connection shares one snapshot; a WeakMap lets a
125
+ * discarded pool's snapshot be collected. A fetch failure caches `[]` (a missing
126
+ * listing means "no verifiable links" — a silent fallback to loaders, never an
127
+ * error).
128
+ */
129
+ const LINK_SNAPSHOT_CACHE = new WeakMap();
120
130
  /** Operator keys recognised inside a `WhereOperator` object. */
121
131
  const OPERATOR_KEYS = new Set([
122
132
  'equals',
@@ -941,10 +951,12 @@ class PowqlInterface {
941
951
  // -------------------------------------------------------------------------
942
952
  async findMany(args = {}) {
943
953
  return this.withMiddleware('findMany', args, async () => {
944
- const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
954
+ const { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.runFind(args, 'findMany');
945
955
  const entities = this.shape(rows, native);
946
956
  if (nestedPlans.length)
947
957
  this.attachNestedRows(entities, nestedPlans);
958
+ if (linkPlans.length)
959
+ this.attachLinkRows(entities, linkPlans, native);
948
960
  if (residualWith) {
949
961
  await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
950
962
  }
@@ -977,6 +989,7 @@ class PowqlInterface {
977
989
  // key would duplicate the column's).
978
990
  const withClause = args.with;
979
991
  const nestedPlans = [];
992
+ const linkPlans = [];
980
993
  let residualWith = withClause;
981
994
  if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
982
995
  const residue = {};
@@ -985,14 +998,25 @@ class PowqlInterface {
985
998
  continue;
986
999
  const rel = this.meta.relations[relName];
987
1000
  const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
988
- if (plan)
1001
+ if (plan) {
989
1002
  nestedPlans.push(plan);
1003
+ continue;
1004
+ }
1005
+ // Nested projection could not serve it: try a scalar link path (the narrow
1006
+ // to-one bigint/bytes case; the `schema links` snapshot is fetched lazily
1007
+ // inside, only when a genuine candidate reaches it), else it stays on the
1008
+ // loaders.
1009
+ const linkPlan = rel && !cols.includes(relName)
1010
+ ? await this.planLinkPathRelation(relName, rel, opt, args.includePii === true, cols, linkPlans.length + 1)
1011
+ : null;
1012
+ if (linkPlan)
1013
+ linkPlans.push(linkPlan);
990
1014
  else
991
1015
  residue[relName] = opt; // unknown relation: the loader raises its E003
992
1016
  }
993
1017
  residualWith = Object.keys(residue).length ? residue : undefined;
994
1018
  }
995
- const nest = nestedPlans.length > 0;
1019
+ const nest = nestedPlans.length > 0 || linkPlans.length > 0;
996
1020
  const alias = nest ? 't0' : undefined;
997
1021
  const where = this.buildWhere(resolvedWhere, params, alias);
998
1022
  const distinct = args.distinct?.length ? ' distinct' : '';
@@ -1014,20 +1038,23 @@ class PowqlInterface {
1014
1038
  for (const plan of nestedPlans) {
1015
1039
  parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
1016
1040
  }
1041
+ // Scalar link-path hops (flat native-typed fields; never a JSON block).
1042
+ for (const plan of linkPlans)
1043
+ parts.push(...this.linkPathFields(plan, 't0'));
1017
1044
  projection = `{ ${parts.join(', ')} }`;
1018
1045
  }
1019
1046
  else {
1020
1047
  projection = this.projection(cols);
1021
1048
  }
1022
1049
  const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
1023
- return { powql, resolvedWhere, nestedPlans, residualWith };
1050
+ return { powql, resolvedWhere, nestedPlans, linkPlans, residualWith };
1024
1051
  }
1025
1052
  /** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
1026
1053
  async runFind(args, action = 'findMany') {
1027
1054
  const params = [];
1028
- const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
1055
+ const { powql, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.buildFind(args, params);
1029
1056
  const { rows, native } = await this.exec(powql, params, args.timeout, action);
1030
- return { rows, native, resolvedWhere, nestedPlans, residualWith };
1057
+ return { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith };
1031
1058
  }
1032
1059
  /**
1033
1060
  * Diagnostic surface: compile the same PowQL {@link findMany} would run for
@@ -1062,12 +1089,14 @@ class PowqlInterface {
1062
1089
  args = { ...args, where: expanded };
1063
1090
  }
1064
1091
  return this.withMiddleware('findUnique', args, async () => {
1065
- const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1092
+ const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1066
1093
  if (!rows.length)
1067
1094
  return null;
1068
1095
  const entities = this.shape(rows, native);
1069
1096
  if (nestedPlans.length)
1070
1097
  this.attachNestedRows(entities, nestedPlans);
1098
+ if (linkPlans.length)
1099
+ this.attachLinkRows(entities, linkPlans, native);
1071
1100
  if (residualWith)
1072
1101
  await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
1073
1102
  return entities[0];
@@ -1075,12 +1104,14 @@ class PowqlInterface {
1075
1104
  }
1076
1105
  async findFirst(args = {}) {
1077
1106
  return this.withMiddleware('findFirst', args, async () => {
1078
- const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
1107
+ const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
1079
1108
  if (!rows.length)
1080
1109
  return null;
1081
1110
  const entities = this.shape(rows, native);
1082
1111
  if (nestedPlans.length)
1083
1112
  this.attachNestedRows(entities, nestedPlans);
1113
+ if (linkPlans.length)
1114
+ this.attachLinkRows(entities, linkPlans, native);
1084
1115
  if (residualWith)
1085
1116
  await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
1086
1117
  return entities[0];
@@ -1726,6 +1757,174 @@ class PowqlInterface {
1726
1757
  row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
1727
1758
  }
1728
1759
  // -------------------------------------------------------------------------
1760
+ // Scalar to-one link paths (PowDB >= 0.19.1, `linkPaths` capability)
1761
+ // -------------------------------------------------------------------------
1762
+ /**
1763
+ * The `schema links` snapshot for this pool, fetched at most once and cached on
1764
+ * the pool identity. A fetch failure resolves to `[]` (a missing listing is a
1765
+ * silent fallback to loaders, never an error). Only called when the `linkPaths`
1766
+ * capability is on, so the listing statement is guaranteed to exist.
1767
+ */
1768
+ linksSnapshot() {
1769
+ const pool = this.pool;
1770
+ let snap = LINK_SNAPSHOT_CACHE.get(pool);
1771
+ if (!snap) {
1772
+ snap = this.fetchLinksSnapshot();
1773
+ LINK_SNAPSHOT_CACHE.set(pool, snap);
1774
+ }
1775
+ return snap;
1776
+ }
1777
+ async fetchLinksSnapshot() {
1778
+ try {
1779
+ const { rows } = await this.exec('schema links', [], undefined, 'introspect');
1780
+ return rows.map((r) => ({
1781
+ owner: String(r.owner ?? ''),
1782
+ name: String(r.name ?? ''),
1783
+ target: String(r.target ?? ''),
1784
+ localKey: String(r.local_key ?? ''),
1785
+ targetKey: String(r.target_key ?? ''),
1786
+ cardinality: String(r.cardinality ?? ''),
1787
+ }));
1788
+ }
1789
+ catch {
1790
+ return [];
1791
+ }
1792
+ }
1793
+ /** True when `name` is a bare PowQL identifier (quoting leaves it unchanged). */
1794
+ isBareIdent(name) {
1795
+ return (0, powdb_js_1.quotePowqlIdent)(name) === name;
1796
+ }
1797
+ /**
1798
+ * Find the declared to-one link on THIS table that matches `rel` exactly: same
1799
+ * target, same correlation columns (owner localKey = the FK on this table, target
1800
+ * targetKey = the referenced key on target), cardinality `"to-one"`. Returns the
1801
+ * declared link (whose NAME drives the path spelling, which may differ from the
1802
+ * relation's own name) or `null` for no verifiable match (→ silent loader
1803
+ * fallback, never an error).
1804
+ */
1805
+ findMatchingLink(snapshot, rel) {
1806
+ const localKey = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey)[0];
1807
+ const targetKey = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
1808
+ if (!localKey || !targetKey)
1809
+ return null;
1810
+ for (const l of snapshot) {
1811
+ if (l.cardinality === 'to-one' &&
1812
+ l.owner === this.table &&
1813
+ l.target === rel.to &&
1814
+ l.localKey === localKey &&
1815
+ l.targetKey === targetKey) {
1816
+ return l;
1817
+ }
1818
+ }
1819
+ return null;
1820
+ }
1821
+ /**
1822
+ * Plan one belongsTo `with` as a scalar link-path relation, or `null` when it
1823
+ * must stay on the loaders (ALWAYS a silent fallback with identical output).
1824
+ *
1825
+ * SCOPED TIGHT: this fires ONLY for a to-one relation whose child projection
1826
+ * includes a bigint/bytes column — exactly the case a JSON nested block cannot
1827
+ * carry, so nested projections have already fallen back to a per-relation loader
1828
+ * (`planNestedRelation` returned `null` for the same shape). Cases nested
1829
+ * projections DO serve keep nested projections: link-bearing statements are
1830
+ * NEVER plan-cached upstream, so replacing a cacheable nested projection with a
1831
+ * link path would regress a hot path for no gain. Requires: single-column
1832
+ * belongsTo; no relation `with` / `where` / `distinct` / `orderBy` /
1833
+ * `limit` / `offset` (a scalar path has no per-hop filter/order and cannot
1834
+ * reproduce those — such inputs stay on the loader for exact parity); a link
1835
+ * name and all projected columns that are bare identifiers (a quoted segment in
1836
+ * a dotted link path is outside the verified spelling — fall back); and a
1837
+ * DECLARED link that verifiably matches (`findMatchingLink`).
1838
+ */
1839
+ async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index) {
1840
+ if (!this.capabilities.linkPaths)
1841
+ return null;
1842
+ if (rel.type !== 'belongsTo')
1843
+ return null;
1844
+ if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length !== 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length !== 1) {
1845
+ return null;
1846
+ }
1847
+ const targetMeta = this.schema.tables[rel.to];
1848
+ if (targetMeta?.primaryKey.length !== 1)
1849
+ return null;
1850
+ const options = (opt === true ? {} : opt);
1851
+ if (options.with || options.where || options.distinct?.length)
1852
+ return null;
1853
+ if (options.orderBy || options.limit !== undefined || options.offset)
1854
+ return null;
1855
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1856
+ const userCols = targetQi.projectedColumns(options.select, options.omit, includePii);
1857
+ const byName = new Map(targetMeta.columns.map((c) => [c.name, c]));
1858
+ // Only adopt link paths where a JSON block genuinely cannot serve the case:
1859
+ // at least one projected child column is bigint/bytes. Otherwise nested
1860
+ // projections already handle it (and cache), so leave it to them.
1861
+ const hasCarrierBlockedCol = userCols.some((c) => {
1862
+ const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
1863
+ return ts === 'bigint' || ts === 'Uint8Array';
1864
+ });
1865
+ if (!hasCarrierBlockedCol)
1866
+ return null;
1867
+ // All cheap checks passed: NOW fetch (and cache) the link snapshot — never
1868
+ // before, so a query with no link-path candidate issues no `schema links`.
1869
+ const link = this.findMatchingLink(await this.linksSnapshot(), rel);
1870
+ if (!link)
1871
+ return null;
1872
+ if (!this.isBareIdent(link.name))
1873
+ return null;
1874
+ if (!userCols.every((c) => this.isBareIdent(c)))
1875
+ return null;
1876
+ // Always project the target PK for presence detection (an absent to-one yields
1877
+ // Empty at every hop; PK-Empty is the unambiguous "no linked row" signal). Add
1878
+ // it if the user's projection dropped it, and remember to strip it back off.
1879
+ const pkCol = targetMeta.primaryKey[0];
1880
+ if (!this.isBareIdent(pkCol))
1881
+ return null;
1882
+ const pkProjected = userCols.includes(pkCol);
1883
+ const cols = pkProjected ? userCols : [...userCols, pkCol];
1884
+ // Synthetic flat result keys (`l<index>_<col>`) keep the hop fields from
1885
+ // colliding with real parent columns or each other. Refuse the (astronomically
1886
+ // unlikely) case where a real parent/child column already uses the prefix.
1887
+ const keyPrefix = `l${index}_`;
1888
+ if (parentCols.some((c) => c.startsWith(keyPrefix)) || cols.some((c) => c.startsWith(keyPrefix)))
1889
+ return null;
1890
+ return { relName, linkName: link.name, targetQi, cols, pkCol, pkProjected, keyPrefix };
1891
+ }
1892
+ /** The flat `l<i>_<col>: t0.<linkName>.<col>` projection fields for one link plan. */
1893
+ linkPathFields(plan, parentAlias) {
1894
+ return plan.cols.map((c) => `${plan.keyPrefix}${c}: ${parentAlias}.${plan.linkName}.${c}`);
1895
+ }
1896
+ /**
1897
+ * Reconstruct each link-path relation's child entity from its flat hop fields
1898
+ * and attach it under the relation name — output indistinguishable from the
1899
+ * loader (same keys, same coercions). Presence: the target PK cell arriving
1900
+ * Empty (a null/dangling FK at the hop) means no linked row → `null`, matching
1901
+ * the loader's `matches[0] ?? null`. Otherwise the gathered snake cells go
1902
+ * through the SAME `rowToEntity` policy the loader uses (native micros → Date,
1903
+ * bigint per the int8 policy), and the PK is stripped back off if the user did
1904
+ * not project it. `native` is the wire that actually served the parent row.
1905
+ */
1906
+ attachLinkRows(entities, plans, native) {
1907
+ for (const plan of plans) {
1908
+ const pkField = plan.targetQi.meta.reverseColumnMap[plan.pkCol] ?? plan.pkCol;
1909
+ for (const entity of entities) {
1910
+ const row = entity;
1911
+ const raw = {};
1912
+ for (const c of plan.cols) {
1913
+ raw[c] = row[`${plan.keyPrefix}${c}`];
1914
+ delete row[`${plan.keyPrefix}${c}`];
1915
+ }
1916
+ const child = (0, powdb_js_1.rowToEntity)(raw, plan.targetQi.meta, native);
1917
+ if (child[pkField] == null) {
1918
+ row[plan.relName] = null;
1919
+ continue;
1920
+ }
1921
+ if (!plan.pkProjected)
1922
+ delete child[pkField];
1923
+ row[plan.relName] = child;
1924
+ }
1925
+ }
1926
+ }
1927
+ // -------------------------------------------------------------------------
1729
1928
  // Writes (reselect — PowDB has no RETURNING)
1730
1929
  // -------------------------------------------------------------------------
1731
1930
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
@@ -95,4 +95,6 @@ exports.WARN_NS = {
95
95
  autoStrategy: 'autoStrategy',
96
96
  /** Deep-`with` (depth > 5) advisory (builder.ts `findMany`). */
97
97
  deepWith: 'deepWith',
98
+ /** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
99
+ powdbLinks: 'powdbLinks',
98
100
  };
@@ -56,7 +56,7 @@
56
56
  import { ValidationError } from './errors.js';
57
57
  import { applyTableFilters } from './introspect.js';
58
58
  import { quotePowqlIdent, requireCapability } from './powdb.js';
59
- import { snakeToCamel } from './schema.js';
59
+ import { singularize, snakeToCamel } from './schema.js';
60
60
  /** Coerce a wire cell to string (legacy wire cells are strings; native cells may be typed). */
61
61
  function asString(v) {
62
62
  return v === null || v === undefined ? '' : String(v);
@@ -125,7 +125,16 @@ export async function introspectPowdbDatabase(exec, options = {}) {
125
125
  // `describe` needs the table name in bare-identifier position → quote it so
126
126
  // a reserved-word / non-bare table name (`order`) does not become a parse
127
127
  // error.
128
- const describeRows = (await exec(`describe ${quotePowqlIdent(tableName)}`)).rows.map((r) => ({
128
+ const describeRows = (await exec(`describe ${quotePowqlIdent(tableName)}`)).rows
129
+ // PowDB >= 0.19.1 appends entity-LINK rows after the 4 column rows in
130
+ // `describe <T>` (a `type` cell of literally `"link"`, an Empty nullable
131
+ // slot, and an arrow description in the index slot). They are NOT columns,
132
+ // so drop them before column parsing, unconditionally (no capability check):
133
+ // the rows simply never appear on a pre-0.19.1 engine, and a linked database
134
+ // introspected without this filter would produce garbage `link`-typed
135
+ // columns. Declared links are read separately via `schema links` below.
136
+ .filter((r) => asString(r.type) !== 'link')
137
+ .map((r) => ({
129
138
  column: asString(r.column),
130
139
  type: asString(r.type),
131
140
  nullable: asBool(r.nullable),
@@ -205,10 +214,120 @@ export async function introspectPowdbDatabase(exec, options = {}) {
205
214
  allColumns,
206
215
  primaryKey,
207
216
  uniqueColumns,
208
- // PowDB has no declared foreign keys no relations from introspection.
217
+ // Populated below from `schema links` when the engine supports link
218
+ // introspection (>= 0.19.1); stays `{}` otherwise (PowDB has no declared
219
+ // foreign keys, so pre-link introspection reports no relations).
209
220
  relations: {},
210
221
  indexes,
211
222
  };
212
223
  }
224
+ // Link introspection (>= 0.19.1): populate relations from declared entity links.
225
+ // Behaves exactly as before (relations stay `{}`) when the capability is absent
226
+ // or not supplied.
227
+ if (options.capabilities?.linkIntrospection) {
228
+ await introspectPowdbLinks(exec, tables);
229
+ }
213
230
  return { tables, enums: {} };
214
231
  }
232
+ /**
233
+ * Read `schema links` and populate {@link TableMetadata.relations}: the first
234
+ * time PowDB introspection can report relations. Each declared link becomes a
235
+ * relation on its OWNER, and its natural REVERSE is synthesized on the target
236
+ * (mirroring the SQL introspector, which always emits both sides of a FK):
237
+ *
238
+ * - `"to-one"` link `Order.user -> User`: owner gets a `belongsTo` (localKey =
239
+ * the FK on the owner, targetKey = the referenced key on target); the target
240
+ * gets a reverse `hasMany` named for the pluralized owner table.
241
+ * - `"to-many"` link `User.orders -> Order`: owner gets a `hasMany` (localKey =
242
+ * the referenced key on the owner, targetKey = the FK on the child); the
243
+ * target gets a reverse `belongsTo` named for the singularized owner table.
244
+ *
245
+ * Reverse synthesis is best-effort and collision-guarded: a synthesized name that
246
+ * would shadow a column field or an already-present relation on the target is
247
+ * skipped (never a hard error), so introspection can only ADD relations, never
248
+ * clobber. m2m junctions cannot be inferred from links and stay undetected;
249
+ * `defineSchema` remains the relation-complete path. Column names arrive as
250
+ * PowDB's emitted (snake) names and stay snake in `foreignKey`/`referenceKey`
251
+ * (the SQL introspector's convention); the relation NAME is camelCased to match
252
+ * the query field surface. Naming edge: `schema links` is the contextual listing
253
+ * keyword: a table literally named `links` is still read via `describe links`.
254
+ */
255
+ async function introspectPowdbLinks(exec, tables) {
256
+ const linkRows = (await exec('schema links')).rows.map((r) => ({
257
+ owner: asString(r.owner),
258
+ name: asString(r.name),
259
+ target: asString(r.target),
260
+ localKey: asString(r.local_key),
261
+ targetKey: asString(r.target_key),
262
+ cardinality: asString(r.cardinality),
263
+ }));
264
+ for (const link of linkRows) {
265
+ const owner = tables[link.owner];
266
+ const target = tables[link.target];
267
+ // A link referencing a filtered-out (include/exclude) table is skipped: we
268
+ // cannot describe the target, so the relation would be unusable.
269
+ if (!owner || !target)
270
+ continue;
271
+ const toOne = link.cardinality === 'to-one';
272
+ const relName = snakeToCamel(link.name);
273
+ // Forward relation on the owner (the declared direction).
274
+ if (toOne) {
275
+ addRelation(owner, {
276
+ type: 'belongsTo',
277
+ name: relName,
278
+ from: link.owner,
279
+ to: link.target,
280
+ foreignKey: link.localKey,
281
+ referenceKey: link.targetKey,
282
+ });
283
+ }
284
+ else {
285
+ addRelation(owner, {
286
+ type: 'hasMany',
287
+ name: relName,
288
+ from: link.owner,
289
+ to: link.target,
290
+ foreignKey: link.targetKey, // FK on the child (target) side
291
+ referenceKey: link.localKey, // referenced key on the owner (parent) side
292
+ });
293
+ }
294
+ // Reverse relation on the target (synthesized, collision-guarded).
295
+ const reverseName = toOne ? pluralize(snakeToCamel(link.owner)) : singularize(snakeToCamel(link.owner));
296
+ if (!relationNameTaken(target, reverseName)) {
297
+ addRelation(target, {
298
+ type: toOne ? 'hasMany' : 'belongsTo',
299
+ name: reverseName,
300
+ from: link.target,
301
+ to: link.owner,
302
+ // Same two columns, roles swapped for the opposite direction.
303
+ foreignKey: toOne ? link.localKey : link.targetKey,
304
+ referenceKey: toOne ? link.targetKey : link.localKey,
305
+ });
306
+ }
307
+ }
308
+ }
309
+ /** True when `name` already names a relation OR a column field/name on `meta`. */
310
+ function relationNameTaken(meta, name) {
311
+ if (meta.relations[name])
312
+ return true;
313
+ return meta.columns.some((c) => c.field === name || c.name === name);
314
+ }
315
+ /** Add a relation to a table's relations map (keyed by its camelCase name). */
316
+ function addRelation(meta, rel) {
317
+ if (meta.relations[rel.name])
318
+ return; // never clobber an existing relation
319
+ meta.relations[rel.name] = rel;
320
+ }
321
+ /**
322
+ * Minimal English pluralizer for reverse-relation names (`user` → `users`,
323
+ * `company` → `companies`, `box` → `boxes`). Only ever produces a candidate name
324
+ * that {@link relationNameTaken} then vets, so an imperfect plural can at worst be
325
+ * skipped, never break a query.
326
+ */
327
+ function pluralize(word) {
328
+ if (/[^aeiou]y$/i.test(word))
329
+ return `${word.slice(0, -1)}ies`;
330
+ if (/(s|x|z|ch|sh)$/i.test(word))
331
+ return `${word}es`;
332
+ return `${word}s`;
333
+ }
package/dist/powdb.d.ts CHANGED
@@ -54,7 +54,8 @@
54
54
  */
55
55
  import { type PgCompatPool, type PgCompatPoolClient, TurbineClient, type TurbineConfig } from './client.js';
56
56
  import { type Dialect } from './dialect.js';
57
- import type { ColumnMetadata, SchemaMetadata, TableMetadata } from './schema.js';
57
+ import type { PowdbExec } from './powdb-introspect.js';
58
+ import { type ColumnMetadata, type SchemaMetadata, type TableMetadata } from './schema.js';
58
59
  /**
59
60
  * Capability descriptor for PowDB. PowQL generation is owned by
60
61
  * {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
@@ -269,11 +270,29 @@ export interface PowdbCapabilities {
269
270
  * the on-disk catalog to v7, so this stays FALSE in ALL_POWDB_CAPABILITIES.
270
271
  */
271
272
  entityLinks: boolean;
273
+ /**
274
+ * ≥ 0.19.1: link INTROSPECTION, the `schema links` listing statement and the
275
+ * appended link rows in `describe <T>`. Only meaningful when probed (there is
276
+ * no query-generation flip behind it), so it stays FALSE in
277
+ * ALL_POWDB_CAPABILITIES like the other probe-only gates. Floored at the PATCH
278
+ * 0.19.1: the listing statement shipped there, not in 0.19.0.
279
+ */
280
+ linkIntrospection: boolean;
281
+ /**
282
+ * ≥ 0.19.1: scalar to-one link PATHS in query generation. Floored at the PATCH
283
+ * 0.19.1 (never 0.19.0) because 0.19.0 had silent-wrong-results link bugs
284
+ * (bare-dotted-path split, wrong aggregates over links) that make traversal
285
+ * unsafe; 0.19.1 turned those into hard errors. This flag flips real query
286
+ * generation (a to-one `with` whose child carries bigint/bytes compiles to
287
+ * link-path projections instead of a loader), so it stays FALSE in
288
+ * ALL_POWDB_CAPABILITIES: it must only light up behind a real version probe.
289
+ */
290
+ linkPaths: boolean;
272
291
  /** Networked only: server ≥ 0.13 AND the client exposes `queryNativeRaw`. */
273
292
  nativeRaw: boolean;
274
293
  }
275
294
  /** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
276
- type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks';
295
+ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks' | 'linkIntrospection' | 'linkPaths';
277
296
  /**
278
297
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
279
298
  * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
@@ -286,6 +305,11 @@ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serve
286
305
  * `entityLinks` stays OFF for a stronger reason still: declaring a link
287
306
  * one-way-upgrades the on-disk catalog to v7 and locks out pre-0.19 binaries,
288
307
  * so it must only ever light up behind a real version probe.
308
+ * `linkIntrospection` / `linkPaths` stay OFF for the same probe-only discipline:
309
+ * `linkPaths` flips real query generation (a to-one `with` compiling to link
310
+ * projections), and `linkIntrospection` is only meaningful once genuinely
311
+ * probed, so both must come from a real version resolution, never a bare
312
+ * construction.
289
313
  */
290
314
  export declare const ALL_POWDB_CAPABILITIES: PowdbCapabilities;
291
315
  /**
@@ -368,8 +392,77 @@ export declare function quotePowqlIdent(name: string): string;
368
392
  */
369
393
  export interface PowqlSchemaDDLOptions {
370
394
  capabilities?: PowdbCapabilities;
395
+ /**
396
+ * Emit `link Owner.name -> Target on local = target` declarations (PowDB entity
397
+ * links, >= 0.19) for every single-column hasMany / hasOne / belongsTo relation
398
+ * in the metadata, appended after the `type` / index statements. Default
399
+ * `false`: links are opt-in. Composite-key relations and m2m junctions are
400
+ * skipped (a link is single-column only); a relation whose name collides with a
401
+ * column on the owner is skipped with a one-time warning (the engine hard-errors
402
+ * on such a collision). When `capabilities` is also supplied it must pass the
403
+ * `entityLinks` gate, else this throws a typed E017.
404
+ *
405
+ * ONE-WAY DOOR: the FIRST `link` a database ever executes permanently upgrades
406
+ * its on-disk catalog to format v7; pre-0.19 PowDB binaries / addons can then no
407
+ * longer open that data directory. A database that never declares a link stays
408
+ * on its current catalog format. The DDL is create-only (no `if not exists`, no
409
+ * drop spelling), so an apply layer must existence-check first (see
410
+ * {@link applyPowdbLinks}).
411
+ */
412
+ emitLinks?: boolean;
371
413
  }
414
+ /**
415
+ * One entity link Turbine wants declared for a relation: `link owner.name ->
416
+ * target on localKey = targetKey`. Cardinality is NOT part of the DDL (PowDB
417
+ * derives to-one vs to-many from whether `targetKey` is unique on `target` at
418
+ * declare time), so a belongsTo and its reverse hasMany both round-trip to the
419
+ * same shape from opposite owners.
420
+ */
421
+ export interface PowdbDesiredLink {
422
+ owner: string;
423
+ name: string;
424
+ target: string;
425
+ localKey: string;
426
+ targetKey: string;
427
+ }
428
+ /**
429
+ * Derive the entity links Turbine would declare from a schema's relations. One
430
+ * link per single-column hasMany / hasOne / belongsTo relation, owned by the
431
+ * relation's `from` table:
432
+ * - belongsTo Order->User: `Order.user -> User on user_id = id`
433
+ * (localKey = the FK on the owner, targetKey = the referenced key on target).
434
+ * - hasMany User->Order: `User.orders -> Order on id = user_id`
435
+ * (localKey = the referenced key on the owner, targetKey = the FK on the child).
436
+ * Composite-key relations and m2m junctions are skipped (links are single-column;
437
+ * a junction cannot be inferred from links). A relation whose name collides with
438
+ * a column on the owner is skipped and reported through `onCollision` (the caller
439
+ * decides whether to warn). Pure: no capability gate, no side effects.
440
+ */
441
+ export declare function deriveDesiredLinks(schema: SchemaMetadata, onCollision?: (owner: string, name: string) => void): PowdbDesiredLink[];
442
+ /** Render one {@link PowdbDesiredLink} as its create-only `link ...` DDL statement. */
443
+ export declare function powdbLinkStatement(link: PowdbDesiredLink): string;
372
444
  export declare function powqlSchemaDDL(schema: SchemaMetadata, opts?: PowqlSchemaDDLOptions): string[];
445
+ /**
446
+ * Existence-checked apply of entity-link DDL against a LIVE PowDB database.
447
+ * Because link DDL is create-only (no `if not exists`, redeclaring is an error,
448
+ * and there is no drop spelling), an apply layer must diff against the live
449
+ * catalog first: this reads the `schema links` listing, then executes only the
450
+ * links that are genuinely missing.
451
+ *
452
+ * - a desired link already declared with the SAME endpoints → skipped (idempotent);
453
+ * - a link declared with the same owner + name but DIFFERENT endpoints → skipped
454
+ * with a one-time warning (never dropped/replaced: there is no drop DDL, and a
455
+ * silent replace would be a destructive schema change);
456
+ * - a name/column collision on the owner → skipped (deriveDesiredLinks warns).
457
+ *
458
+ * Requires the `entityLinks` + `linkIntrospection` capabilities when `capabilities`
459
+ * is supplied (the listing statement is 0.19.1+). Returns the statements executed.
460
+ * `exec` runs one PowQL statement (embedded `db.raw` / a networked query shim); it
461
+ * must return rows keyed by column name for the `schema links` read.
462
+ */
463
+ export declare function applyPowdbLinks(exec: PowdbExec, schema: SchemaMetadata, options?: {
464
+ capabilities?: PowdbCapabilities;
465
+ }): Promise<string[]>;
373
466
  /**
374
467
  * Coerce a single PowDB wire string into the JS value its column type implies.
375
468
  * Every PowDB value arrives as a string; NULL arrives as the bareword `"null"`.
@@ -626,7 +719,11 @@ export declare function encodePowqlLiteral(value: unknown): string;
626
719
  * between v0.18.0 and v0.19.0 (empty git diff), and `link` was already a lexer
627
720
  * keyword at 0.18.0. The 0.19 entity-links surface adds new STATEMENTS built from
628
721
  * pre-existing tokens, so the tokenization / escape surface this ceiling guards is
629
- * unmoved.
722
+ * unmoved. The 0.19.1 link-introspection / link-path round is likewise lexer-neutral:
723
+ * `git diff v0.19.0 v0.19.1 -- crates/query/src/lexer.rs` is empty (the bare-dotted-path
724
+ * hard error is parser-level, not tokenization), so this ceiling stays `'0.19'`. The
725
+ * guard in {@link PowdbEmbeddedPool.exec} compares major.minor only, so `'0.19'`
726
+ * already covers every 0.19.x patch — no bump is needed for 0.19.1.
630
727
  */
631
728
  export declare const POWQL_LEXER_TESTED_CEILING = "0.19";
632
729
  /**