turbine-orm 0.32.2 → 0.34.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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/dialect.js +1 -0
  3. package/dist/cjs/index-advisor.js +0 -0
  4. package/dist/cjs/index.js +2 -1
  5. package/dist/cjs/mssql.js +3 -0
  6. package/dist/cjs/mysql.js +3 -0
  7. package/dist/cjs/optional-peer-import.cjs +28 -0
  8. package/dist/cjs/powdb-introspect.js +222 -0
  9. package/dist/cjs/powdb.js +446 -55
  10. package/dist/cjs/powql.js +566 -111
  11. package/dist/cjs/query/builder.js +136 -53
  12. package/dist/cjs/query/filters.js +4 -4
  13. package/dist/cjs/schema-builder.js +16 -0
  14. package/dist/cjs/schema-metadata.js +81 -10
  15. package/dist/cjs/sqlite.js +2 -0
  16. package/dist/dialect.d.ts +7 -0
  17. package/dist/dialect.js +1 -0
  18. package/dist/index-advisor.d.ts +15 -1
  19. package/dist/index-advisor.js +0 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/mssql.js +3 -0
  23. package/dist/mysql.js +3 -0
  24. package/dist/optional-peer-import.cjs +28 -0
  25. package/dist/optional-peer-import.d.cts +19 -0
  26. package/dist/powdb-introspect.d.ts +84 -0
  27. package/dist/powdb-introspect.js +219 -0
  28. package/dist/powdb.d.ts +249 -13
  29. package/dist/powdb.js +438 -54
  30. package/dist/powql.d.ts +113 -6
  31. package/dist/powql.js +568 -113
  32. package/dist/query/builder.d.ts +11 -0
  33. package/dist/query/builder.js +136 -53
  34. package/dist/query/filters.d.ts +3 -3
  35. package/dist/query/filters.js +4 -4
  36. package/dist/query/types.d.ts +50 -6
  37. package/dist/schema-builder.d.ts +46 -1
  38. package/dist/schema-builder.js +15 -0
  39. package/dist/schema-metadata.d.ts +13 -7
  40. package/dist/schema-metadata.js +82 -11
  41. package/dist/schema.d.ts +25 -0
  42. package/dist/sqlite.js +2 -0
  43. package/package.json +3 -3
@@ -995,14 +995,22 @@ class QueryInterface {
995
995
  else {
996
996
  selectClause = `${qt}.*`;
997
997
  }
998
- let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
998
+ // Piece-then-assemble. The join-sink between FROM and WHERE carries any
999
+ // `plan: 'lateral'` pick joins; it is populated during the ORDER BY build
1000
+ // below and spliced in at final assembly. Empty for every other query
1001
+ // shape → the assembled SQL is byte-identical to the incremental-append
1002
+ // form for the default plan (asserted by the byte-equality snapshot test).
1003
+ const lateralJoins = [];
1004
+ // WHERE + cursor conditions accumulate into `tail`, pushing params in
1005
+ // where → cursor order (the collect path mirrors this exactly).
1006
+ let tail = freshWhereSql;
999
1007
  if (args?.cursor) {
1000
1008
  // Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
1001
1009
  const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
1002
1010
  if (cursorEntries.length > 0) {
1003
1011
  const cursorConditions = cursorEntries.map(([k, v]) => {
1004
1012
  const col = this.toSqlColumn(k);
1005
- // orderBy values can be the { sort, nulls } spec form normalize
1013
+ // orderBy values can be the { sort, nulls } spec form: normalize
1006
1014
  // before comparing, or a desc spec would seek the ascending side.
1007
1015
  const dir = args.orderBy?.[k];
1008
1016
  const desc = (0, filters_js_1.isOrderBySpec)(dir) ? dir.sort === 'desc' : dir === 'desc';
@@ -1010,33 +1018,38 @@ class QueryInterface {
1010
1018
  freshParams.push(v);
1011
1019
  return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
1012
1020
  });
1013
- if (freshWhereSql) {
1014
- sql += ` AND ${cursorConditions.join(' AND ')}`;
1015
- }
1016
- else {
1017
- sql += ` WHERE ${cursorConditions.join(' AND ')}`;
1018
- }
1021
+ tail += freshWhereSql ? ` AND ${cursorConditions.join(' AND ')}` : ` WHERE ${cursorConditions.join(' AND ')}`;
1019
1022
  }
1020
1023
  }
1021
- if (args?.orderBy) {
1022
- if (distinctPrefix) {
1023
- // Postgres requires DISTINCT ON expressions to lead the ORDER BY.
1024
- // Prisma semantics ("first row per combination, result in the user's
1025
- // order") need two levels: inner DISTINCT ON ordered by the distinct
1026
- // columns then the user's order (picks the right representative row),
1027
- // outer re-ordered by the user's order alone.
1028
- if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
1029
- throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
1030
- }
1031
- const userOrder = this.buildOrderBy(args.orderBy, freshParams);
1032
- sql += ` ORDER BY ${distinctCols.map((c) => `${c} ASC`).join(', ')}, ${userOrder}`;
1033
- sql = `SELECT * FROM (${sql}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
1034
- }
1035
- else {
1036
- // Pass freshParams so vector KNN ordering binds its `$n::vector` query
1037
- // vector at the correct position (after cursor params, before LIMIT).
1038
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
1024
+ // ORDER BY is built AFTER the cursor pushes (param order
1025
+ // where → with → cursor → orderBy → limit → offset) and BEFORE final
1026
+ // assembly (so the lateral sink is filled before the FROM clause is
1027
+ // written). distinct + relation orderBy is refused up front, so a lateral
1028
+ // pick can never reach the distinct branch (lateralJoins stays empty).
1029
+ let sql;
1030
+ if (args?.orderBy && distinctPrefix) {
1031
+ // Postgres requires DISTINCT ON expressions to lead the ORDER BY. Prisma
1032
+ // semantics ("first row per combination, result in the user's order")
1033
+ // need two levels: inner DISTINCT ON ordered by the distinct columns then
1034
+ // the user's order (picks the right representative row), outer re-ordered
1035
+ // by the user's order alone.
1036
+ if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
1037
+ throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
1039
1038
  }
1039
+ const userOrder = this.buildOrderBy(args.orderBy, freshParams);
1040
+ const inner = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${tail} ORDER BY ${distinctCols
1041
+ .map((c) => `${c} ASC`)
1042
+ .join(', ')}, ${userOrder}`;
1043
+ sql = `SELECT * FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
1044
+ }
1045
+ else {
1046
+ // Pass freshParams so vector KNN ordering binds its `$n::vector` query
1047
+ // vector at the correct position (after cursor params, before LIMIT), and
1048
+ // lateralJoins so a lateral pick splices its join into the FROM clause.
1049
+ const orderBySql = args?.orderBy
1050
+ ? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
1051
+ : '';
1052
+ sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
1040
1053
  }
1041
1054
  // Pagination — push params in the same order the collect path mirrors
1042
1055
  // (limit before offset); the SQL TEXT shape is dialect-owned via
@@ -4354,7 +4367,13 @@ class QueryInterface {
4354
4367
  const pickWhere = d.pick?.where
4355
4368
  ? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
4356
4369
  : '';
4357
- return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
4370
+ // Plan discriminator: the lateral plan emits DIFFERENT SQL (a FROM-clause
4371
+ // join + a qualified order term) so a warm cache must never serve one
4372
+ // plan's SQL for the other. Emitted ONLY for `'lateral'`: absent means
4373
+ // the default subquery plan, keeping every pre-existing cache key
4374
+ // byte-identical (no cold-cache churn on upgrade).
4375
+ const planTag = d.plan === 'lateral' ? ';plan=lat' : '';
4376
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere}${planTag})`;
4358
4377
  }
4359
4378
  if ((0, filters_js_1.isOrderBySpec)(d))
4360
4379
  return `spec(${d.sort},${d.nulls ?? ''})`;
@@ -4371,7 +4390,7 @@ class QueryInterface {
4371
4390
  }
4372
4391
  return String(d);
4373
4392
  }
4374
- buildOrderBy(orderBy, params) {
4393
+ buildOrderBy(orderBy, params, lateralSink) {
4375
4394
  // Dev-only: validate that orderBy fields exist in the table schema. Relation
4376
4395
  // orderBy keys (object values that are neither a vector nor an OrderBySpec)
4377
4396
  // are validated in the relation branch below, so skip them here.
@@ -4414,7 +4433,7 @@ class QueryInterface {
4414
4433
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
4415
4434
  // { name: 'asc' } }`).
4416
4435
  if (this.isRelationOrderByValue(value)) {
4417
- return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
4436
+ return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
4418
4437
  }
4419
4438
  // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
4420
4439
  if (meta && !(key in meta.columnMap)) {
@@ -4508,7 +4527,18 @@ class QueryInterface {
4508
4527
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4509
4528
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4510
4529
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4511
- return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
4530
+ // Rows whose document lacks the path extract to NULL. Without a nulls
4531
+ // clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
4532
+ // pick-row ordering (NULLS LAST both directions since 0.33) and from
4533
+ // engines whose path ordering is nulls-last in both directions. Default to
4534
+ // NULLS LAST in BOTH directions unless the caller set `nulls` explicitly;
4535
+ // the grammar gate matches nullsSuffix.
4536
+ const nullsSql = spec.nulls
4537
+ ? this.nullsSuffix(spec.nulls)
4538
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4539
+ ? ' NULLS LAST'
4540
+ : '';
4541
+ return `${lhs} ${dir}${nullsSql}`;
4512
4542
  }
4513
4543
  /**
4514
4544
  * Compile a relation ordering term. For a to-many relation the only allowed
@@ -4523,7 +4553,7 @@ class QueryInterface {
4523
4553
  * subquery's orderBy the relations live on the TARGET table's metadata and
4524
4554
  * the correlation parent is the relation's alias, not `this.table`.
4525
4555
  */
4526
- buildRelationOrderBy(relName, value, alias, params, ctx) {
4556
+ buildRelationOrderBy(relName, value, alias, params, ctx, lateralSink) {
4527
4557
  const ownerMeta = ctx?.meta ?? this.tableMeta;
4528
4558
  const ownerTable = ctx?.table ?? this.table;
4529
4559
  const parentRef = ctx?.parentRef ?? this.table;
@@ -4539,7 +4569,7 @@ class QueryInterface {
4539
4569
  // scope errors, shared with the cache-hit collect mirror.
4540
4570
  if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
4541
4571
  this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4542
- return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
4572
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params, lateralSink);
4543
4573
  }
4544
4574
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4545
4575
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -4631,6 +4661,25 @@ class QueryInterface {
4631
4661
  throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4632
4662
  "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4633
4663
  }
4664
+ // Physical plan gate. A typo like `plan: 'latreal'` must never silently run
4665
+ // the subquery plan (a silent plan change wearing a validation gap). Shared
4666
+ // by build and cache-hit collect so a warmed cache throws identically.
4667
+ if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
4668
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
4669
+ `${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
4670
+ }
4671
+ if (spec.plan === 'lateral') {
4672
+ if (!this.dialect.supportsLateralJoin) {
4673
+ throw new errors_js_1.UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", this.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
4674
+ }
4675
+ // The lateral exposes one reserved output column, `__turbine_pick`. A
4676
+ // parent column with that exact name would make the unqualified WHERE
4677
+ // reference ambiguous once the join is in scope; refuse it explicitly.
4678
+ if (this.tableMeta.allColumns.includes('__turbine_pick')) {
4679
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${this.tableMeta.name}" ` +
4680
+ 'has a column named "__turbine_pick", which the lateral join output reserves.');
4681
+ }
4682
+ }
4634
4683
  }
4635
4684
  /**
4636
4685
  * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
@@ -4649,36 +4698,83 @@ class QueryInterface {
4649
4698
  * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4650
4699
  * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4651
4700
  */
4652
- buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4701
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params, lateralSink) {
4653
4702
  if (!params) {
4654
4703
  throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4655
4704
  }
4656
4705
  const targetMeta = this.schema.tables[relDef.to];
4657
4706
  if (!targetMeta)
4658
4707
  throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4708
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4709
+ const limitOne = this.buildPagination('1', undefined, true);
4710
+ // Parents with ZERO surviving related rows have no row to pick: the
4711
+ // correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
4712
+ // its single row identically. Without a nulls clause, Postgres DESC
4713
+ // defaults to NULLS FIRST (every childless parent tops a "highest first"
4714
+ // sort). Default to NULLS LAST in BOTH directions (deterministic across
4715
+ // engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
4716
+ // the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
4717
+ const nullsSql = spec.nulls
4718
+ ? this.nullsSuffix(spec.nulls)
4719
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4720
+ ? ' NULLS LAST'
4721
+ : '';
4722
+ // Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
4723
+ // FROM clause (via the sink) and order by its single reserved output column.
4724
+ // Param push order is IDENTICAL to the subquery plan (compilePickPieces is
4725
+ // shared), so the cache-hit collect mirror needs no changes. Scope +
4726
+ // capability were already enforced by validatePickOrderBy (shared with the
4727
+ // collect path); the missing-sink guard catches a non-findMany build
4728
+ // context and hard-fails rather than silently emitting a subquery.
4729
+ if (spec.plan === 'lateral') {
4730
+ if (!lateralSink) {
4731
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
4732
+ 'in a top-level findMany orderBy.');
4733
+ }
4734
+ const childAlias = `${alias}i`;
4735
+ const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params);
4736
+ lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${this.q('__turbine_pick')} FROM ${this.q(relDef.to)} ${childAlias}` +
4737
+ ` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
4738
+ return `${alias}.${this.q('__turbine_pick')} ${dir}${nullsSql}`;
4739
+ }
4740
+ // Subquery plan (default): a correlated scalar subquery in ORDER BY.
4741
+ const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, alias, parentRef, params);
4742
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4743
+ }
4744
+ /**
4745
+ * Compile the shared inner pieces of a pick-row ordering against `childAlias`
4746
+ * (the table alias the related row is read from): the `by` value expression,
4747
+ * the correlation + target global filter + `pick.where` predicate, and the
4748
+ * `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
4749
+ * the subquery and lateral plans build IDENTICAL pieces in the SAME param
4750
+ * push order (`by` JSON path → target global filter → `pick.where` →
4751
+ * `pick.orderBy` JSON paths), which is why the collect mirror
4752
+ * ({@link collectRelationPickOrderParams}) is plan-agnostic.
4753
+ */
4754
+ compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params) {
4659
4755
  // The value surfaced from the picked row (SELECT list: its param binds first).
4660
4756
  let byExpr;
4661
4757
  if (typeof spec.by === 'string') {
4662
4758
  const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4663
- byExpr = `${alias}.${this.q(col)}`;
4759
+ byExpr = `${childAlias}.${this.q(col)}`;
4664
4760
  }
4665
4761
  else {
4666
4762
  const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4667
4763
  path: spec.by.path,
4668
4764
  });
4669
4765
  params.push(this.jsonPathParam(spec.by.path));
4670
- const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4766
+ const extract = this.dialect.buildJsonPathExtract(`${childAlias}.${this.q(col)}`, this.p(params.length));
4671
4767
  byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4672
4768
  }
4673
4769
  // Correlation to the parent row, then the target's global filter (a
4674
4770
  // soft-deleted / other-tenant row must never be picked: matches the
4675
4771
  // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4676
- let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4677
- const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4772
+ let where = this.dialect.buildCorrelation(childAlias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4773
+ const gf = this.targetGlobalFilterAlias(relDef.to, childAlias, params);
4678
4774
  if (gf)
4679
4775
  where += ` AND ${gf}`;
4680
4776
  if (spec.pick.where) {
4681
- const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4777
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, childAlias, spec.pick.where, params);
4682
4778
  if (pickWhere)
4683
4779
  where += ` AND ${pickWhere}`;
4684
4780
  }
@@ -4686,21 +4782,8 @@ class QueryInterface {
4686
4782
  // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4687
4783
  // here routes back through buildRelationOrderBy with ctx set and throws
4688
4784
  // the top-level-only E003.
4689
- const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4690
- const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4691
- const limitOne = this.buildPagination('1', undefined, true);
4692
- // Parents with ZERO surviving related rows make the correlated subquery
4693
- // yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
4694
- // FIRST — every childless parent would top a "highest first" sort. Default
4695
- // to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
4696
- // NULL-is-smallest default diverges from Postgres) unless the caller set
4697
- // `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
4698
- const nullsSql = spec.nulls
4699
- ? this.nullsSuffix(spec.nulls)
4700
- : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4701
- ? ' NULLS LAST'
4702
- : '';
4703
- return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4785
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
4786
+ return { byExpr, where, orderClause };
4704
4787
  }
4705
4788
  /**
4706
4789
  * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
@@ -319,9 +319,9 @@ function isJsonPathOrderBy(value) {
319
319
  }
320
320
  /**
321
321
  * Check if an orderBy value is a pick-row relation ordering:
322
- * `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape is
323
- * required `pick` must be an object carrying `orderBy`, `by` must be
324
- * present, and no keys outside `{ pick, by, direction, nulls }` so a to-one
322
+ * `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
323
+ * is required (`pick` must be an object carrying `orderBy`, `by` must be
324
+ * present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
325
325
  * relation whose target has real columns literally named `pick` and `by`
326
326
  * (whose values are direction strings or `{ sort, nulls }` specs, never an
327
327
  * object with `orderBy`) still falls through to column ordering. `distance`
@@ -339,7 +339,7 @@ function isRelationPickOrderBy(value) {
339
339
  if (typeof v.pick !== 'object' || v.pick === null || Array.isArray(v.pick) || !('orderBy' in v.pick))
340
340
  return false;
341
341
  for (const key of Object.keys(v)) {
342
- if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls')
342
+ if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls' && key !== 'plan')
343
343
  return false;
344
344
  }
345
345
  return true;
@@ -25,6 +25,7 @@
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.camelToSnake = exports.column = exports.ColumnBuilder = void 0;
28
+ exports.isDocFieldIndexDef = isDocFieldIndexDef;
28
29
  exports.defineSchema = defineSchema;
29
30
  exports.table = table;
30
31
  exports.applyManyToManyRelations = applyManyToManyRelations;
@@ -102,6 +103,10 @@ function resolveColumn(def) {
102
103
  check: def.check ?? null,
103
104
  };
104
105
  }
106
+ /** Type guard: is this index declaration a doc-field expression index? */
107
+ function isDocFieldIndexDef(idx) {
108
+ return 'docField' in idx && typeof idx.docField === 'string';
109
+ }
105
110
  /** Check if a value is a TableDef (from legacy table() builder) */
106
111
  function isTableDef(v) {
107
112
  return typeof v === 'object' && v !== null && 'columns' in v && 'name' in v;
@@ -145,7 +150,17 @@ function defineSchema(input, options) {
145
150
  let pk;
146
151
  let m2m;
147
152
  let checks;
153
+ let indexes;
148
154
  for (const [fieldName, def] of Object.entries(raw)) {
155
+ if (fieldName === 'indexes') {
156
+ if (def !== undefined) {
157
+ if (!Array.isArray(def)) {
158
+ throw new Error(`Table "${accessor}": "indexes" must be an array of index declarations`);
159
+ }
160
+ indexes = def;
161
+ }
162
+ continue;
163
+ }
149
164
  if (fieldName === 'manyToMany') {
150
165
  if (def !== undefined) {
151
166
  if (!Array.isArray(def)) {
@@ -205,6 +220,7 @@ function defineSchema(input, options) {
205
220
  ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
206
221
  ...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
207
222
  ...(checks && checks.length > 0 ? { checks } : {}),
223
+ ...(indexes && indexes.length > 0 ? { indexes } : {}),
208
224
  };
209
225
  }
210
226
  }
@@ -25,10 +25,12 @@
25
25
  * the same conservative auto-`manyToMany` treatment as introspection.
26
26
  * - Explicit `manyToMany` declarations on the SchemaDef are merged via
27
27
  * {@link applyManyToManyRelations} (additive, never clobbering).
28
- * - `indexes` is always `[]` SchemaDef cannot express indexes, and an
29
- * empty list keeps `schemaHasIndexInfo()` false so the index advisor
30
- * and the dev-mode missing-index warning stay silent instead of
31
- * producing blanket false positives.
28
+ * - `indexes` carries any declared `TableDef.indexes` (plain column and/or
29
+ * PowDB doc-field expression indexes); a table with none declared gets
30
+ * `[]`, which keeps `schemaHasIndexInfo()` false so the index advisor and
31
+ * the dev-mode missing-index warning stay silent instead of producing
32
+ * blanket false positives. Doc-field (docPath) indexes are ignored by the
33
+ * advisor, so a doc-only index set never flips `schemaHasIndexInfo()`.
32
34
  *
33
35
  * @example
34
36
  * ```ts
@@ -45,6 +47,7 @@
45
47
  */
46
48
  Object.defineProperty(exports, "__esModule", { value: true });
47
49
  exports.schemaDefToMetadata = schemaDefToMetadata;
50
+ const errors_js_1 = require("./errors.js");
48
51
  const introspect_js_1 = require("./introspect.js");
49
52
  const schema_js_1 = require("./schema.js");
50
53
  const schema_builder_js_1 = require("./schema-builder.js");
@@ -97,6 +100,67 @@ function resolveColumnName(raw, target) {
97
100
  return (0, schema_js_1.camelToSnake)(raw);
98
101
  }
99
102
  // ---------------------------------------------------------------------------
103
+ // Index declarations → IndexMetadata
104
+ // ---------------------------------------------------------------------------
105
+ /**
106
+ * Render a doc-field JSON path into an illustrative PowQL fragment for the
107
+ * `IndexMetadata.definition` field (debuggability only; the authoritative,
108
+ * lexer-exact emission lives in `powqlSchemaDDL`). String segments are shown
109
+ * double-quoted, integer array indexes bare.
110
+ */
111
+ function docPathFragment(column, path) {
112
+ const segs = path.map((s) => (typeof s === 'number' ? `->${s}` : `->"${s}"`)).join('');
113
+ return `(.${column}${segs})`;
114
+ }
115
+ /**
116
+ * Convert a table's {@link SchemaIndexDef} list into {@link IndexMetadata}.
117
+ * A doc-field index carries `docPath` and `columns: [<json column>]`; a plain
118
+ * column index carries its snake_case column list and no `docPath`. Names are
119
+ * auto-derived (`<table>_<cols>_idx`) when not supplied.
120
+ */
121
+ function mapIndexes(tableDef, declared) {
122
+ if (!declared || declared.length === 0)
123
+ return [];
124
+ const out = [];
125
+ for (const idx of declared) {
126
+ if ((0, schema_builder_js_1.isDocFieldIndexDef)(idx)) {
127
+ const column = (0, schema_js_1.camelToSnake)(idx.docField);
128
+ const segPart = idx.path.map((s) => (typeof s === 'number' ? String(s) : s)).join('_');
129
+ const name = idx.name ?? `${tableDef.name}_${column}_${segPart}_idx`;
130
+ // Validate numeric (array-index) segments up front: PowDB rejects a
131
+ // negative / fractional / NaN JSON-path index at migration time with an
132
+ // opaque parse error, so fail here with a typed ValidationError naming the
133
+ // index instead of emitting malformed PowQL later.
134
+ for (const seg of idx.path) {
135
+ if (typeof seg === 'number' && (!Number.isInteger(seg) || seg < 0)) {
136
+ throw new errors_js_1.ValidationError(`[turbine] Doc-field index "${name}" on "${tableDef.name}": array-index path segment ${seg} must be a ` +
137
+ 'non-negative integer (a JSON array index). Use a string for an object key.');
138
+ }
139
+ }
140
+ out.push({
141
+ name,
142
+ columns: [column],
143
+ unique: idx.unique ?? false,
144
+ definition: `${idx.unique ? 'unique ' : 'index '}${docPathFragment(column, idx.path)}`,
145
+ docPath: [...idx.path],
146
+ declared: true,
147
+ });
148
+ }
149
+ else {
150
+ const columns = idx.columns.map(schema_js_1.camelToSnake);
151
+ const name = idx.name ?? `${tableDef.name}_${columns.join('_')}_idx`;
152
+ out.push({
153
+ name,
154
+ columns,
155
+ unique: idx.unique ?? false,
156
+ definition: `${idx.unique ? 'unique ' : 'index '}(${columns.join(', ')})`,
157
+ declared: true,
158
+ });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+ // ---------------------------------------------------------------------------
100
164
  // The converter
101
165
  // ---------------------------------------------------------------------------
102
166
  /**
@@ -122,10 +186,14 @@ function resolveColumnName(raw, target) {
122
186
  * - Explicit `manyToMany` declarations → merged additively.
123
187
  * - Schema-level `enums`.
124
188
  *
189
+ * What maps (continued):
190
+ * - `indexes` → declared `TableDef.indexes` become `IndexMetadata` (plain
191
+ * column indexes and PowDB doc-field expression indexes, the latter
192
+ * carrying `docPath`). A table with no declared indexes gets `[]`, keeping
193
+ * `schemaHasIndexInfo()` false so index-advisor consumers produce no false
194
+ * positives on index-less code-first metadata.
195
+ *
125
196
  * What SchemaDef cannot express (and how it degrades):
126
- * - Indexes → every table gets `indexes: []`, which keeps
127
- * `schemaHasIndexInfo()` false so index-advisor consumers produce no
128
- * false positives on code-first metadata.
129
197
  * - Views → never marked (`isView` is introspection-only).
130
198
  * - Composite foreign keys → `references:` is single-column by design.
131
199
  */
@@ -302,9 +370,12 @@ function schemaDefToMetadata(def) {
302
370
  primaryKey: pk,
303
371
  uniqueColumns,
304
372
  relations: relationsByTable.get(tableDef.name) ?? {},
305
- // SchemaDef cannot express indexes. An empty list keeps
306
- // schemaHasIndexInfo() false no index-advisor false positives.
307
- indexes: [],
373
+ // Declared `indexes` (plain column + doc-field expression) carry through;
374
+ // an undeclared table gets `[]`, which keeps schemaHasIndexInfo() false so
375
+ // the index advisor stays silent. Doc-field (docPath) indexes are ignored
376
+ // by the advisor entirely (see index-advisor.ts), so a doc-only index set
377
+ // never flips schemaHasIndexInfo() and never produces FK false positives.
378
+ indexes: mapIndexes(tableDef, tableDef.indexes),
308
379
  };
309
380
  }
310
381
  const enums = {};
@@ -378,6 +378,8 @@ exports.sqliteDialect = {
378
378
  supportsListenNotify: false,
379
379
  supportsRLS: false,
380
380
  supportsAdvisoryLock: false,
381
+ // No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
382
+ supportsLateralJoin: false,
381
383
  // json_group_array / json_object have no inline ORDER BY argument, so every
382
384
  // ordered to-many relation is forced through the inner-subquery rewrite.
383
385
  aggSupportsInlineOrderBy: false,
package/dist/dialect.d.ts CHANGED
@@ -295,6 +295,13 @@ export interface Dialect {
295
295
  readonly supportsRLS: boolean;
296
296
  /** Whether this dialect/engine supports advisory-lock-style migration locking. */
297
297
  readonly supportsAdvisoryLock: boolean;
298
+ /**
299
+ * Whether this dialect supports `LEFT JOIN LATERAL (...) ON true` in the FROM
300
+ * clause. Gates the opt-in `plan: 'lateral'` pick-row ordering. Optional:
301
+ * absent is treated as `false`, so only dialects that set it true admit the
302
+ * lateral plan (else E017). PostgreSQL only in this release.
303
+ */
304
+ readonly supportsLateralJoin?: boolean;
298
305
  /** Build a dialect-specific RETURNING clause. Return an empty string when unsupported. */
299
306
  buildReturningClause(selection?: string): string;
300
307
  /** Build a single-row INSERT statement. Inputs are SQL-ready quoted fragments. */
package/dist/dialect.js CHANGED
@@ -22,6 +22,7 @@ export const postgresDialect = {
22
22
  supportsListenNotify: true,
23
23
  supportsRLS: true,
24
24
  supportsAdvisoryLock: true,
25
+ supportsLateralJoin: true,
25
26
  paramPlaceholder(index) {
26
27
  return `$${index}`;
27
28
  },
@@ -58,7 +58,21 @@ export declare function isProbeIndexed(meta: TableMetadata, columns: string[]):
58
58
  * should gate on {@link schemaHasIndexInfo} to avoid blanket false positives.
59
59
  */
60
60
  export declare function findMissingRelationIndexes(schema: SchemaMetadata): MissingRelationIndex[];
61
- /** True when at least one table in the schema carries index metadata. */
61
+ /**
62
+ * True when at least one table in the schema carries real, DB-backed index
63
+ * metadata (i.e. from introspection).
64
+ *
65
+ * Excluded (so they never flip the flag, keeping the schema "index-info
66
+ * unknown"):
67
+ * - doc-field expression indexes (`docPath`): they carry no FK-coverage info;
68
+ * - code-first DECLARED indexes (`declared`): the SQL DDL generators do NOT
69
+ * emit `TableDef.indexes` yet, so a declared index does not reflect a real
70
+ * index on the SQL engines. Counting one would arm blanket FK false
71
+ * positives (the FK auto-index the push path DID create is then reported
72
+ * "missing") and, inversely, suppress warnings for indexes never created.
73
+ * A pure code-first schema therefore stays silent exactly as it did before
74
+ * `TableDef.indexes` existed.
75
+ */
62
76
  export declare function schemaHasIndexInfo(schema: SchemaMetadata): boolean;
63
77
  /**
64
78
  * Single-relation check for the dev-mode runtime warning: the (table, columns)
Binary file
package/dist/index.d.ts CHANGED
@@ -47,7 +47,7 @@ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type Column
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
50
- export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, defineSchema, type ManyToManyDef, type ReferenceDef, type SchemaDef, type TableDef, table, } from './schema-builder.js';
50
+ export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnIndexDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, type DocFieldIndexDef, defineSchema, isDocFieldIndexDef, type ManyToManyDef, type ReferenceDef, type SchemaDef, type SchemaIndexDef, type TableDef, table, } from './schema-builder.js';
51
51
  export { schemaDefToMetadata } from './schema-metadata.js';
52
52
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
53
53
  export { type DefinedSeed, defineSeed, type SeedFunction } from './seed.js';
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ export { validateChannel } from './realtime.js';
53
53
  // Schema utilities
54
54
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
55
55
  // Schema builder — define schemas in TypeScript
56
- export { applyManyToManyRelations, ColumnBuilder, column, defineSchema,
56
+ export { applyManyToManyRelations, ColumnBuilder, column, defineSchema, isDocFieldIndexDef,
57
57
  // Legacy compat (deprecated — use object format with defineSchema)
58
58
  table, } from './schema-builder.js';
59
59
  // Schema metadata bridge — defineSchema() → SchemaMetadata without a live DB
package/dist/mssql.js CHANGED
@@ -467,6 +467,9 @@ export const mssqlDialect = {
467
467
  supportsVector: false,
468
468
  supportsListenNotify: false,
469
469
  supportsRLS: false,
470
+ // SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
471
+ // is Postgres-only (out of scope here).
472
+ supportsLateralJoin: false,
470
473
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
471
474
  supportsAdvisoryLock: true,
472
475
  // FOR JSON over zero rows is NULL → coalesced in the relation override.
package/dist/mysql.js CHANGED
@@ -368,6 +368,9 @@ export const mysqlDialect = {
368
368
  supportsVector: false,
369
369
  supportsListenNotify: false,
370
370
  supportsRLS: false,
371
+ // MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
372
+ // Postgres-only in this release (flipping it on is a one-line change + tests).
373
+ supportsLateralJoin: false,
371
374
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
372
375
  supportsAdvisoryLock: true,
373
376
  // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
@@ -86,4 +86,32 @@ async function importOptionalPeer(specifier, allowEsmFallback = true) {
86
86
  return esmCapableCopy(specifier, false);
87
87
  }
88
88
  }
89
+ /**
90
+ * Merged namespace so callers can reach {@link peerPackageVersion} off the same
91
+ * default import (`importOptionalPeer.peerPackageVersion(...)`). Lives in this
92
+ * `.cts` file for the same reason the dynamic import does: a `.cts` compiles to
93
+ * CommonJS in BOTH build passes, so `require` / `require.resolve` are natively
94
+ * available and `import.meta` is never emitted (which would break the CJS build
95
+ * and crash CJS consumers, see `resolveEmbeddedVersion` in powdb.ts).
96
+ */
97
+ (function (importOptionalPeer) {
98
+ /**
99
+ * Resolve an optional peer's declared `package.json` version WITHOUT loading
100
+ * the package itself (so an ESM-only peer never trips `require`). `require` is
101
+ * anchored on THIS module's location (inside the published `dist/`), so bare
102
+ * resolution walks up `node_modules` and finds the peer exactly where
103
+ * `import.meta.url` used to point, but it compiles under `module: CommonJS`
104
+ * too. Returns `null` when the peer / its package.json cannot be resolved.
105
+ */
106
+ function peerPackageVersion(specifier) {
107
+ try {
108
+ const pkg = require(`${specifier}/package.json`);
109
+ return typeof pkg.version === 'string' ? pkg.version : null;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ importOptionalPeer.peerPackageVersion = peerPackageVersion;
116
+ })(importOptionalPeer || (importOptionalPeer = {}));
89
117
  module.exports = importOptionalPeer;