kitcn 0.17.5 → 0.19.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.
@@ -23,6 +23,27 @@ function isFieldReference(value) {
23
23
  return value && typeof value === "object" && value.__brand === "FieldReference";
24
24
  }
25
25
  /**
26
+ * The pattern is the same for every row of a scan while the value is not, so
27
+ * only the value genuinely has to be split per call. A small bounded cache
28
+ * keeps the split pattern across rows without unbounded growth on
29
+ * caller-supplied patterns; a query with a handful of LIKE filters still hits
30
+ * on every row.
31
+ */
32
+ const LIKE_PATTERN_CACHE_MAX = 16;
33
+ const likePatternCache = /* @__PURE__ */ new Map();
34
+ function likePatternCodePoints(pattern, caseInsensitive) {
35
+ const key = caseInsensitive ? `i:${pattern}` : `s:${pattern}`;
36
+ const cached = likePatternCache.get(key);
37
+ if (cached) return cached;
38
+ const source = Array.from(caseInsensitive ? pattern.toLowerCase() : pattern);
39
+ if (likePatternCache.size >= LIKE_PATTERN_CACHE_MAX) for (const oldest of likePatternCache.keys()) {
40
+ likePatternCache.delete(oldest);
41
+ break;
42
+ }
43
+ likePatternCache.set(key, source);
44
+ return source;
45
+ }
46
+ /**
26
47
  * SQL `LIKE` semantics: `%` matches any run of characters, `_` matches exactly
27
48
  * one, everything else is literal. Wildcards work anywhere in the pattern, not
28
49
  * only at the ends.
@@ -36,7 +57,7 @@ function isFieldReference(value) {
36
57
  */
37
58
  function matchLikePattern(value, pattern, caseInsensitive) {
38
59
  const target = Array.from(caseInsensitive ? value.toLowerCase() : value);
39
- const source = Array.from(caseInsensitive ? pattern.toLowerCase() : pattern);
60
+ const source = likePatternCodePoints(pattern, caseInsensitive);
40
61
  let valueIndex = 0;
41
62
  let patternIndex = 0;
42
63
  let wildcardPatternIndex = -1;
@@ -507,11 +528,19 @@ function getIndexFields(table, index, schema) {
507
528
  fields.push("_id");
508
529
  return fields;
509
530
  }
510
- function getIndexKey(doc, indexFields) {
531
+ /**
532
+ * Read the index key out of a document.
533
+ *
534
+ * Takes field paths already split on `.` rather than the field names: the field
535
+ * list is fixed for the life of a query, so splitting it once at `withIndex`
536
+ * time saves an array allocation per field per document pulled through the
537
+ * stream.
538
+ */
539
+ function getIndexKey(doc, indexFieldPaths) {
511
540
  const key = [];
512
- for (const field of indexFields) {
541
+ for (const path of indexFieldPaths) {
513
542
  let obj = doc;
514
- for (const subfield of field.split(".")) obj = obj[subfield];
543
+ for (const subfield of path) obj = obj[subfield];
515
544
  key.push(obj);
516
545
  }
517
546
  return key;
@@ -708,17 +737,26 @@ var QueryStream = class {
708
737
  }
709
738
  [Symbol.asyncIterator]() {
710
739
  const iterator = this.iterWithKeys()[Symbol.asyncIterator]();
711
- return { async next() {
712
- const result = await iterator.next();
713
- if (result.done) return {
714
- done: true,
715
- value: void 0
716
- };
717
- return {
718
- done: false,
719
- value: result.value[0]
720
- };
721
- } };
740
+ return {
741
+ async next() {
742
+ const result = await iterator.next();
743
+ if (result.done) return {
744
+ done: true,
745
+ value: void 0
746
+ };
747
+ return {
748
+ done: false,
749
+ value: result.value[0]
750
+ };
751
+ },
752
+ async return() {
753
+ await iterator.return?.();
754
+ return {
755
+ done: true,
756
+ value: void 0
757
+ };
758
+ }
759
+ };
722
760
  }
723
761
  };
724
762
  var StreamDatabaseReader = class {
@@ -837,6 +875,7 @@ var OrderedStreamQuery = class extends StreamableQuery {
837
875
  table: this.parent.parent.table,
838
876
  index: this.parent.index,
839
877
  indexFields: this.parent.q.indexFields,
878
+ indexFieldPaths: this.parent.q.indexFieldPaths,
840
879
  order: this.order,
841
880
  bounds: {
842
881
  lowerBound: this.parent.q.lowerBoundIndexKey ?? [],
@@ -855,7 +894,7 @@ var OrderedStreamQuery = class extends StreamableQuery {
855
894
  return db.query(table).withIndex(index, indexRange).order(order);
856
895
  }
857
896
  iterWithKeys() {
858
- const { indexFields } = this.reflect();
897
+ const { indexFieldPaths } = this.reflect();
859
898
  const iterable = this.inner();
860
899
  return { [Symbol.asyncIterator]() {
861
900
  const iterator = iterable[Symbol.asyncIterator]();
@@ -867,7 +906,7 @@ var OrderedStreamQuery = class extends StreamableQuery {
867
906
  };
868
907
  return {
869
908
  done: false,
870
- value: [result.value, getIndexKey(result.value, indexFields)]
909
+ value: [result.value, getIndexKey(result.value, indexFieldPaths)]
871
910
  };
872
911
  } };
873
912
  } };
@@ -929,8 +968,11 @@ var ReflectIndexRange = class {
929
968
  upperBoundInclusive = true;
930
969
  equalityIndexFilter = [];
931
970
  indexFields;
971
+ /** `indexFields`, each split on `.` once, for `getIndexKey`. */
972
+ indexFieldPaths;
932
973
  constructor(indexFields) {
933
974
  this.indexFields = indexFields;
975
+ this.indexFieldPaths = indexFields.map((field) => field.split("."));
934
976
  }
935
977
  eq(field, value) {
936
978
  if (!this.#canLowerBound(field) || !this.#canUpperBound(field)) throw new Error(`Cannot use eq on field '${field}'`);
@@ -1022,16 +1064,24 @@ var ReflectIndexRange = class {
1022
1064
  function mergedStream(streams, orderByIndexFields) {
1023
1065
  return new MergedStream(streams, orderByIndexFields);
1024
1066
  }
1067
+ /**
1068
+ * Marks sources that are already `OrderByStream`s, so `narrow` reuses them
1069
+ * instead of adding a redundant delegation layer per call.
1070
+ *
1071
+ * Module-private on purpose: wrapping is also where the ordering invariant is
1072
+ * checked, so no caller outside this file can skip it.
1073
+ */
1074
+ const PRE_ORDERED_STREAMS = Symbol("preOrderedStreams");
1025
1075
  var MergedStream = class MergedStream extends QueryStream {
1026
1076
  #order;
1027
1077
  #streams;
1028
1078
  #equalityIndexFilter;
1029
1079
  #indexFields;
1030
- constructor(streams, orderByIndexFields) {
1080
+ constructor(streams, orderByIndexFields, preOrdered) {
1031
1081
  super();
1032
1082
  if (streams.length === 0) throw new Error("Cannot union empty array of streams");
1033
1083
  this.#order = allSame(streams.map((stream) => stream.getOrder()), "Cannot merge streams with different orders");
1034
- this.#streams = streams.map((stream) => new OrderByStream(stream, orderByIndexFields));
1084
+ this.#streams = preOrdered === PRE_ORDERED_STREAMS ? streams : streams.map((stream) => new OrderByStream(stream, orderByIndexFields.slice()));
1035
1085
  this.#indexFields = allSame(this.#streams.map((stream) => stream.getIndexFields()), "Cannot merge streams with different index fields. Consider using .orderBy()");
1036
1086
  this.#equalityIndexFilter = commonPrefix(this.#streams.map((stream) => stream.getEqualityIndexFilter()));
1037
1087
  }
@@ -1044,40 +1094,60 @@ var MergedStream = class MergedStream extends QueryStream {
1044
1094
  done: false,
1045
1095
  value: void 0
1046
1096
  }));
1047
- return { async next() {
1048
- await Promise.all(iterators.map(async (iterator, i) => {
1049
- if (!results[i].done && !results[i].value) results[i] = await iterator.next();
1050
- }));
1051
- let minIndexKeyAndIndex;
1052
- for (let i = 0; i < results.length; i++) {
1053
- const result = results[i];
1054
- if (result.done || !result.value) continue;
1055
- const [_, resultIndexKey] = result.value;
1056
- if (minIndexKeyAndIndex === void 0) {
1057
- minIndexKeyAndIndex = [resultIndexKey, i];
1058
- continue;
1097
+ let started = false;
1098
+ let refillIndex;
1099
+ return {
1100
+ async next() {
1101
+ if (started) {
1102
+ if (refillIndex !== void 0) {
1103
+ results[refillIndex] = await iterators[refillIndex].next();
1104
+ refillIndex = void 0;
1105
+ }
1106
+ } else {
1107
+ started = true;
1108
+ await Promise.all(iterators.map(async (iterator, i) => {
1109
+ results[i] = await iterator.next();
1110
+ }));
1059
1111
  }
1060
- const [prevMin, _prevMinIndex] = minIndexKeyAndIndex;
1061
- if (compareKeys({
1062
- value: resultIndexKey,
1063
- kind: "exact"
1064
- }, {
1065
- value: prevMin,
1066
- kind: "exact"
1067
- }) * comparisonInversion < 0) minIndexKeyAndIndex = [resultIndexKey, i];
1112
+ let minIndexKeyAndIndex;
1113
+ for (let i = 0; i < results.length; i++) {
1114
+ const result = results[i];
1115
+ if (result.done || !result.value) continue;
1116
+ const [_, resultIndexKey] = result.value;
1117
+ if (minIndexKeyAndIndex === void 0) {
1118
+ minIndexKeyAndIndex = [resultIndexKey, i];
1119
+ continue;
1120
+ }
1121
+ const [prevMin, _prevMinIndex] = minIndexKeyAndIndex;
1122
+ if (compareKeys({
1123
+ value: resultIndexKey,
1124
+ kind: "exact"
1125
+ }, {
1126
+ value: prevMin,
1127
+ kind: "exact"
1128
+ }) * comparisonInversion < 0) minIndexKeyAndIndex = [resultIndexKey, i];
1129
+ }
1130
+ if (minIndexKeyAndIndex === void 0) return {
1131
+ done: true,
1132
+ value: void 0
1133
+ };
1134
+ const [_, minIndex] = minIndexKeyAndIndex;
1135
+ const result = results[minIndex].value;
1136
+ results[minIndex].value = void 0;
1137
+ refillIndex = minIndex;
1138
+ return {
1139
+ done: false,
1140
+ value: result
1141
+ };
1142
+ },
1143
+ async return() {
1144
+ await Promise.all(iterators.map((iterator) => iterator.return?.()));
1145
+ return {
1146
+ done: true,
1147
+ value: void 0
1148
+ };
1068
1149
  }
1069
- if (minIndexKeyAndIndex === void 0) return {
1070
- done: true,
1071
- value: void 0
1072
- };
1073
- const [_, minIndex] = minIndexKeyAndIndex;
1074
- const result = results[minIndex].value;
1075
- results[minIndex].value = void 0;
1076
- return {
1077
- done: false,
1078
- value: result
1079
- };
1080
- } };
1150
+ };
1081
1151
  } };
1082
1152
  }
1083
1153
  getOrder() {
@@ -1090,7 +1160,7 @@ var MergedStream = class MergedStream extends QueryStream {
1090
1160
  return this.#indexFields;
1091
1161
  }
1092
1162
  narrow(indexBounds) {
1093
- return new MergedStream(this.#streams.map((stream) => stream.narrow(indexBounds)), this.#indexFields);
1163
+ return new MergedStream(this.#streams.map((stream) => stream.narrow(indexBounds)), this.#indexFields, PRE_ORDERED_STREAMS);
1094
1164
  }
1095
1165
  };
1096
1166
  function allSame(values, errorMessage) {
@@ -1138,16 +1208,22 @@ var ConcatStreams = class ConcatStreams extends QueryStream {
1138
1208
  this.#equalityIndexFilter = commonPrefix(streams.map((stream) => stream.getEqualityIndexFilter()));
1139
1209
  }
1140
1210
  iterWithKeys() {
1141
- const iterables = this.#streams.map((stream) => stream.iterWithKeys());
1211
+ const streams = this.#streams;
1142
1212
  const comparisonInversion = this.#order === "asc" ? 1 : -1;
1143
- let previousIndexKey;
1144
1213
  return { [Symbol.asyncIterator]() {
1145
- const iterators = iterables.map((iterable) => iterable[Symbol.asyncIterator]());
1146
- return { async next() {
1147
- while (iterators.length > 0) {
1148
- const result = await iterators[0].next();
1149
- if (result.done) iterators.shift();
1150
- else {
1214
+ let index = 0;
1215
+ let iterator;
1216
+ let previousIndexKey;
1217
+ return {
1218
+ async next() {
1219
+ while (index < streams.length) {
1220
+ iterator ??= streams[index].iterWithKeys()[Symbol.asyncIterator]();
1221
+ const result = await iterator.next();
1222
+ if (result.done) {
1223
+ index++;
1224
+ iterator = void 0;
1225
+ continue;
1226
+ }
1151
1227
  const [_, indexKey] = result.value;
1152
1228
  if (previousIndexKey !== void 0 && compareKeys({
1153
1229
  value: previousIndexKey,
@@ -1159,12 +1235,21 @@ var ConcatStreams = class ConcatStreams extends QueryStream {
1159
1235
  previousIndexKey = indexKey;
1160
1236
  return result;
1161
1237
  }
1238
+ return {
1239
+ done: true,
1240
+ value: void 0
1241
+ };
1242
+ },
1243
+ async return() {
1244
+ await iterator?.return?.();
1245
+ index = streams.length;
1246
+ iterator = void 0;
1247
+ return {
1248
+ done: true,
1249
+ value: void 0
1250
+ };
1162
1251
  }
1163
- return {
1164
- done: true,
1165
- value: void 0
1166
- };
1167
- } };
1252
+ };
1168
1253
  } };
1169
1254
  }
1170
1255
  getOrder() {
@@ -1237,6 +1322,18 @@ var FlatMapStreamIterator = class {
1237
1322
  value: [u, [...this.#currentOuterItem.indexKey, ...indexKey]]
1238
1323
  };
1239
1324
  }
1325
+ async return() {
1326
+ try {
1327
+ await this.#currentOuterItem?.innerIterator.return?.();
1328
+ } finally {
1329
+ this.#currentOuterItem = null;
1330
+ await this.#outerIterator.return?.();
1331
+ }
1332
+ return {
1333
+ done: true,
1334
+ value: void 0
1335
+ };
1336
+ }
1240
1337
  };
1241
1338
  var FlatMapStream = class FlatMapStream extends QueryStream {
1242
1339
  #stream;
@@ -1456,15 +1553,24 @@ var OrderByStream = class OrderByStream extends QueryStream {
1456
1553
  const staticFilter = this.#staticFilter;
1457
1554
  return { [Symbol.asyncIterator]() {
1458
1555
  const iterator = iterable[Symbol.asyncIterator]();
1459
- return { async next() {
1460
- const result = await iterator.next();
1461
- if (result.done) return result;
1462
- const [doc, indexKey] = result.value;
1463
- return {
1464
- done: false,
1465
- value: [doc, indexKey.slice(staticFilter.length)]
1466
- };
1467
- } };
1556
+ return {
1557
+ async next() {
1558
+ const result = await iterator.next();
1559
+ if (result.done) return result;
1560
+ const [doc, indexKey] = result.value;
1561
+ return {
1562
+ done: false,
1563
+ value: [doc, indexKey.slice(staticFilter.length)]
1564
+ };
1565
+ },
1566
+ async return() {
1567
+ await iterator.return?.();
1568
+ return {
1569
+ done: true,
1570
+ value: void 0
1571
+ };
1572
+ }
1573
+ };
1468
1574
  } };
1469
1575
  }
1470
1576
  narrow(indexBounds) {
@@ -1495,36 +1601,57 @@ var DistinctStream = class DistinctStream extends QueryStream {
1495
1601
  iterWithKeys() {
1496
1602
  const stream = this.#stream;
1497
1603
  const distinctIndexFieldsLength = this.#distinctIndexFieldsLength;
1604
+ const order = stream.getOrder();
1605
+ const comparisonInversion = order === "asc" ? 1 : -1;
1498
1606
  return { [Symbol.asyncIterator]() {
1499
- let currentStream = stream;
1500
- let currentIterator = currentStream.iterWithKeys()[Symbol.asyncIterator]();
1501
- return { async next() {
1502
- const result = await currentIterator.next();
1503
- if (result.done) return {
1504
- done: true,
1505
- value: void 0
1506
- };
1507
- const [doc, indexKey] = result.value;
1508
- if (doc === null) return {
1509
- done: false,
1510
- value: [null, indexKey]
1511
- };
1512
- const distinctIndexKey = indexKey.slice(0, distinctIndexFieldsLength);
1513
- if (stream.getOrder() === "asc") currentStream = currentStream.narrow({
1514
- lowerBound: distinctIndexKey,
1515
- lowerBoundInclusive: false,
1516
- upperBound: [],
1517
- upperBoundInclusive: true
1518
- });
1519
- else currentStream = currentStream.narrow({
1520
- lowerBound: [],
1521
- lowerBoundInclusive: true,
1522
- upperBound: distinctIndexKey,
1523
- upperBoundInclusive: false
1524
- });
1525
- currentIterator = currentStream.iterWithKeys()[Symbol.asyncIterator]();
1526
- return result;
1527
- } };
1607
+ let currentIterator = stream.iterWithKeys()[Symbol.asyncIterator]();
1608
+ let previousDistinctIndexKey;
1609
+ return {
1610
+ async next() {
1611
+ const result = await currentIterator.next();
1612
+ if (result.done) return {
1613
+ done: true,
1614
+ value: void 0
1615
+ };
1616
+ const [doc, indexKey] = result.value;
1617
+ if (doc === null) return {
1618
+ done: false,
1619
+ value: [null, indexKey]
1620
+ };
1621
+ const distinctIndexKey = indexKey.slice(0, distinctIndexFieldsLength);
1622
+ if (previousDistinctIndexKey !== void 0 && compareKeys({
1623
+ value: previousDistinctIndexKey,
1624
+ kind: "exact"
1625
+ }, {
1626
+ value: distinctIndexKey,
1627
+ kind: "exact"
1628
+ }) * comparisonInversion >= 0) throw new Error(`DistinctStream in wrong order: ${JSON.stringify(previousDistinctIndexKey)}, ${JSON.stringify(distinctIndexKey)}`);
1629
+ previousDistinctIndexKey = distinctIndexKey;
1630
+ let narrowed;
1631
+ if (order === "asc") narrowed = stream.narrow({
1632
+ lowerBound: distinctIndexKey,
1633
+ lowerBoundInclusive: false,
1634
+ upperBound: [],
1635
+ upperBoundInclusive: true
1636
+ });
1637
+ else narrowed = stream.narrow({
1638
+ lowerBound: [],
1639
+ lowerBoundInclusive: true,
1640
+ upperBound: distinctIndexKey,
1641
+ upperBoundInclusive: false
1642
+ });
1643
+ await currentIterator.return?.();
1644
+ currentIterator = narrowed.iterWithKeys()[Symbol.asyncIterator]();
1645
+ return result;
1646
+ },
1647
+ async return() {
1648
+ await currentIterator.return?.();
1649
+ return {
1650
+ done: true,
1651
+ value: void 0
1652
+ };
1653
+ }
1654
+ };
1528
1655
  } };
1529
1656
  }
1530
1657
  narrow(indexBounds) {
@@ -2470,6 +2470,11 @@ declare class GelRelationalQuery<TSchema extends TablesRelationalConfig, TTableC
2470
2470
  private _orderBySpecs;
2471
2471
  private _resolveNonPaginatedLimit;
2472
2472
  private _compareByOrderSpecs;
2473
+ /**
2474
+ * Physical table name to relational config. Called once per row on the
2475
+ * relation-count path, so the linear schema scan is indexed once per schema
2476
+ * object rather than repeated. The schema is fixed for the process lifetime.
2477
+ */
2473
2478
  private _getTableConfigByDbName;
2474
2479
  private _matchLike;
2475
2480
  /**
@@ -2534,15 +2539,25 @@ declare class GelRelationalQuery<TSchema extends TablesRelationalConfig, TTableC
2534
2539
  * The where-clause compiler is built from declared indexes only, and `_id` is
2535
2540
  * never one of them, so an `id` filter can never be index-selected: it lands
2536
2541
  * in the post-filters and the stream walks the creation-time index until it
2537
- * happens on the row. `db.get()` reads exactly the rows asked for, so the ids
2538
- * are fetched directly and replayed as a creation-time-ordered stream — the
2539
- * order the scan would have produced, so stage order and cursors are the same.
2542
+ * happens on the row. `db.get()` reads exactly the rows asked for.
2543
+ *
2544
+ * Rows come back in the order the ids were given, one read at a time. Missing
2545
+ * or policy-filtered ids still cost a read, but a page does not reread the
2546
+ * complete list. An `orderBy` on creation time is the exception: an id carries
2547
+ * no creation time, so every id must be read before the first row is placed.
2540
2548
  *
2541
2549
  * Returns null when something else already owns the read: a pinned index, an
2542
2550
  * index the compiler did select, a `where(predicate)`, or an `orderBy` that
2543
2551
  * walks a different index.
2544
2552
  */
2545
2553
  private _buildIdLookupStream;
2554
+ /**
2555
+ * The declared index a stream read can walk to emit `field` in order.
2556
+ *
2557
+ * A stream orders by the index it scans, so only an index that leads with
2558
+ * the field produces that order.
2559
+ */
2560
+ private _findStreamOrderIndex;
2546
2561
  private _buildBasePipelineStream;
2547
2562
  /**
2548
2563
  * Stream equivalent of the `db.query(...)` chain, used when a post-fetch
@@ -2616,6 +2631,16 @@ declare class GelRelationalQuery<TSchema extends TablesRelationalConfig, TTableC
2616
2631
  */
2617
2632
  private _toConvexQuery;
2618
2633
  private _buildRelationKey;
2634
+ /**
2635
+ * How many leading fields of the scanned index are pinned to a single value.
2636
+ *
2637
+ * `splitFilters` emits index filters in index-key order — a run of `eq`, then
2638
+ * at most one range on the first unpinned field — so the leading `eq` run is
2639
+ * the prefix Convex holds constant. A multi-probe plan carries no index
2640
+ * filters; each probe supplies its own bound instead, and the union is only
2641
+ * as pinned as its least pinned probe.
2642
+ */
2643
+ private _indexEqPrefixCount;
2619
2644
  private _buildIndexPredicate;
2620
2645
  private _buildFilterPredicate;
2621
2646
  private _queryByFields;
@@ -2660,9 +2685,7 @@ declare class GelRelationalQuery<TSchema extends TablesRelationalConfig, TTableC
2660
2685
  private _createRelationCountError;
2661
2686
  private _remapRelationCountError;
2662
2687
  private _coerceRelationCountWhere;
2663
- private _normalizeRelationCountCacheValue;
2664
2688
  private _getRelationCountParentKey;
2665
- private _buildRelationCountExecutionKey;
2666
2689
  private _readIndexedRelationCount;
2667
2690
  private _countRelationForRow;
2668
2691
  private _loadRelationCounts;
@@ -2672,6 +2695,22 @@ declare class GelRelationalQuery<TSchema extends TablesRelationalConfig, TTableC
2672
2695
  * M6.5 Phase 2: Added support for nested relations
2673
2696
  */
2674
2697
  private _loadOneRelation;
2698
+ /**
2699
+ * Junction links per parent, stopped once each parent holds `fetchLimit`
2700
+ * links whose target actually reaches the page.
2701
+ *
2702
+ * A link only contributes if its target exists and survives target RLS and
2703
+ * the relation `where` — all of which run after the junction read. Sizing the
2704
+ * read on links alone therefore under-fills: three dangling or filtered links
2705
+ * first and `{ limit: 3 }` returns nothing. So the read is refilled in rounds,
2706
+ * each round resolving only the targets it newly needs and asking again for
2707
+ * whatever the survivors did not cover.
2708
+ *
2709
+ * Rounds are the unit rather than single links because both the target fetch
2710
+ * and the relation `where` de-duplicate and batch their own reads across the
2711
+ * parents in the round.
2712
+ */
2713
+ private _readBoundedThroughLinks;
2675
2714
  /**
2676
2715
  * Load many() relation (one-to-many)
2677
2716
  * Example: users.posts where posts.authorId → users.id
@@ -4505,22 +4544,22 @@ declare const BUILTIN_SCHEMA_EXTENSIONS: readonly [SchemaExtension<{
4505
4544
  fieldName: "status";
4506
4545
  };
4507
4546
  };
4508
- direction: ConvexTextBuilderInitial<""> & {
4547
+ cursor: ConvexTextBuilderInitial<""> & {
4509
4548
  _: {
4510
4549
  tableName: "migration_state";
4511
4550
  };
4512
4551
  } & {
4513
4552
  _: {
4514
- fieldName: "direction";
4553
+ fieldName: "cursor";
4515
4554
  };
4516
4555
  };
4517
- cursor: ConvexTextBuilderInitial<""> & {
4556
+ direction: ConvexTextBuilderInitial<""> & {
4518
4557
  _: {
4519
4558
  tableName: "migration_state";
4520
4559
  };
4521
4560
  } & {
4522
4561
  _: {
4523
- fieldName: "cursor";
4562
+ fieldName: "direction";
4524
4563
  };
4525
4564
  };
4526
4565
  updatedAt: ConvexNumberBuilderInitial<""> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kitcn",
3
- "version": "0.17.5",
3
+ "version": "0.19.0",
4
4
  "description": "kitcn - React Query integration and CLI tools for Convex",
5
5
  "keywords": [
6
6
  "convex",
@@ -254,6 +254,13 @@ Rules:
254
254
  - `one()` with `to: ...id` → uses `db.get()` (no extra index)
255
255
  - Missing index throws unless `allowFullScan` on parent query
256
256
 
257
+ Relation `limit`/`orderBy` are pushed into the relation index when the index
258
+ already walks in that order. After the FK, `index('by_user').on(t.userId)`
259
+ orders by creation time, so `with: { posts: { limit: 5, orderBy: { createdAt:
260
+ 'desc' } } }` reads 5 posts per parent. Sorting by any other column reads the
261
+ parent's whole child partition and sorts in memory — put the sort column in the
262
+ relation index (`index('by_user_rank').on(t.userId, t.rank)`) to stay bounded.
263
+
257
264
  ## Schema Definition
258
265
 
259
266
  ```ts
@@ -713,8 +720,8 @@ const filtered = results.filter((a) => a.publishedAt >= startDate);
713
720
 
714
721
  ### Performance
715
722
 
716
- 1. **Index first** — constrain leading index fields. Compound indexes follow prefix rules.
717
- 2. **Bound scans** — use `maxScan` for predicate `where` (cursor mode only).
723
+ 1. **Index first** — constrain leading index fields. Compound indexes follow prefix rules. Put the `orderBy` column right after the constrained prefix so the scan is already sorted and `limit` bounds the read.
724
+ 2. **Bound scans** — use `maxScan` for predicate `where` (cursor mode only). A `.select()` ID-list query with `orderBy` needs a single field that an index leads with.
718
725
  3. **Limit results** — always use `limit` or cursor pagination.
719
726
  4. **Cursor stability** — keep same `where`/`orderBy` between page requests.
720
727
  5. **`allowFullScan`** — non-cursor only. Cursor mode uses `maxScan` instead.