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.
package/dist/orm/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { A as vectorIndex, B as ConvexColumnBuilder, C as RlsPolicy, D as rankIndex, E as index, F as arrayOf, I as custom, L as json, M as createSystemFields, N as integer, O as searchIndex, P as id, R as objectOf, S as TablePolymorphic, T as aggregateIndex, V as entityKind, _ as OrmSchemaRelations, a as deletion, b as TableDeleteConfig, c as Columns, d as OrmSchemaDefinition, f as OrmSchemaExtensionRelations, g as OrmSchemaOptions, h as OrmSchemaExtensions, i as convexTable, j as text, k as uniqueIndex, l as EnableRLS, m as OrmSchemaExtensionTriggers, o as discriminator, p as OrmSchemaExtensionTables, s as Brand, t as DirectAggregate, u as OrmContext, v as OrmSchemaTriggers, w as rlsPolicy, x as TableName, y as RlsPolicies, z as unionOf } from "../runtime-DmVSOe24.js";
2
2
  import { a as pretendRequired, i as pretend, n as deprecated } from "../validators-C7LelqTN.js";
3
- import { A as like, B as or, C as gt, D as isFieldReference, E as inArray, F as not, I as notBetween, L as notIlike, M as lte, N as matchLikePattern, O as isNotNull, P as ne, R as notInArray, S as filterValuesEqual, T as ilike, V as startsWith, _ as contains, a as indexKeyWithinBounds, b as fieldRef, c as streamIndexRange, d as and, f as arrayContained, g as column, h as between, i as getIndexFields, j as lt, k as isNull, l as isUnsetToken, m as arrayOverlaps, n as EmptyStream, o as mergedStream, p as arrayContains, r as QueryStream, s as stream, t as getByIdWithOrmQueryFallback, u as unsetToken, v as endsWith, w as gte, x as filterValueInList, y as eq, z as notLike } from "../query-context-C90vNlc9.js";
3
+ import { A as like, B as or, C as gt, D as isFieldReference, E as inArray, F as not, I as notBetween, L as notIlike, M as lte, N as matchLikePattern, O as isNotNull, P as ne, R as notInArray, S as filterValuesEqual, T as ilike, V as startsWith, _ as contains, a as indexKeyWithinBounds, b as fieldRef, c as streamIndexRange, d as and, f as arrayContained, g as column, h as between, i as getIndexFields, j as lt, k as isNull, l as isUnsetToken, m as arrayOverlaps, n as EmptyStream, o as mergedStream, p as arrayContains, r as QueryStream, s as stream, t as getByIdWithOrmQueryFallback, u as unsetToken, v as endsWith, w as gte, x as filterValueInList, y as eq, z as notLike } from "../query-context-BzihIpnM.js";
4
4
  import { compareValues, v } from "convex/values";
5
5
  import { defineSchema as defineSchema$1, internalActionGeneric, internalMutationGeneric } from "convex/server";
6
6
 
@@ -290,6 +290,19 @@ function check(name, expression) {
290
290
  return new ConvexCheckBuilder(name, expression);
291
291
  }
292
292
 
293
+ //#endregion
294
+ //#region src/orm/timestamp-mode.ts
295
+ const PUBLIC_CREATED_AT_FIELD = "createdAt";
296
+ const INTERNAL_CREATION_TIME_FIELD = "_creationTime";
297
+ const CREATED_AT_MIGRATION_MESSAGE = "`_creationTime` is no longer public. Use `createdAt` instead.";
298
+ const hasUserCreatedAtColumn = (table) => {
299
+ if (!table || typeof table !== "object") return false;
300
+ const columns = table[Columns];
301
+ if (!columns || typeof columns !== "object") return false;
302
+ return Object.hasOwn(columns, PUBLIC_CREATED_AT_FIELD);
303
+ };
304
+ const usesSystemCreatedAtAlias = (_table) => true;
305
+
293
306
  //#endregion
294
307
  //#region src/orm/index-utils.ts
295
308
  function getIndexes(table) {
@@ -319,37 +332,53 @@ function findVectorIndexByName(table, indexName) {
319
332
  return getVectorIndexes(table).find((index) => index.name === indexName) ?? null;
320
333
  }
321
334
  function findIndexForColumns(indexes, columns) {
322
- for (const index of indexes) {
323
- if (index.fields.length < columns.length) continue;
324
- let matches = true;
325
- for (let i = 0; i < columns.length; i++) if (index.fields[i] !== columns[i]) {
326
- matches = false;
327
- break;
328
- }
329
- if (matches) return index.name;
330
- }
335
+ for (const index of indexes) if (hasColumnPrefix(index, columns)) return index.name;
331
336
  return null;
332
337
  }
333
- function findRelationIndex(table, columns, relationName, targetTableName, strict = true, allowFullScan = false) {
334
- const index = findIndexForColumns(getIndexes(table), columns);
338
+ const hasColumnPrefix = (index, columns) => {
339
+ if (index.fields.length < columns.length) return false;
340
+ for (let i = 0; i < columns.length; i += 1) if (index.fields[i] !== columns[i]) return false;
341
+ return true;
342
+ };
343
+ /**
344
+ * Answers "does scanning this index already produce the requested order?".
345
+ *
346
+ * Convex walks an index in full key order, so once a leading run of fields is
347
+ * pinned to a single value by `eq`, the remainder of the scan is already sorted
348
+ * by the next index field — and by `_creationTime` once every declared field is
349
+ * pinned, because Convex appends it as the implicit trailing key. When that
350
+ * field is the one the caller asked to sort by, `.order(dir).take(n)` returns
351
+ * the exact page and nothing has to be collected and sorted afterwards.
352
+ *
353
+ * This is the single owner of that decision. The top-level query planner and
354
+ * the relation loader both call it, so a bound one of them can push into the
355
+ * index can never be one the other silently drops.
356
+ *
357
+ * Returns the direction to hand to `.order()`, or null when the sort must run
358
+ * after the fetch.
359
+ */
360
+ function resolveIndexOrderPushdown(params) {
361
+ const { indexFields, pinnedEqCount, orderSpecs } = params;
362
+ if (!indexFields || orderSpecs.length !== 1) return null;
363
+ const primary = orderSpecs[0];
364
+ const eqCount = Math.min(Math.max(pinnedEqCount, 0), indexFields.length);
365
+ for (let i = 0; i < eqCount; i += 1) if (indexFields[i] === primary.field) return primary.direction;
366
+ const nativeField = eqCount >= indexFields.length ? INTERNAL_CREATION_TIME_FIELD : indexFields[eqCount];
367
+ return primary.field === nativeField ? primary.direction : null;
368
+ }
369
+ function findRelationIndex(table, columns, relationName, targetTableName, strict = true, allowFullScan = false, orderSpecs = []) {
370
+ const indexes = getIndexes(table);
371
+ const fallbackIndex = findIndexForColumns(indexes, columns);
372
+ const index = (orderSpecs.length === 0 ? void 0 : indexes.find((candidate) => hasColumnPrefix(candidate, columns) && resolveIndexOrderPushdown({
373
+ indexFields: candidate.fields,
374
+ pinnedEqCount: columns.length,
375
+ orderSpecs
376
+ }) !== null))?.name ?? fallbackIndex;
335
377
  if (!index && !allowFullScan) throw new Error(`Relation ${relationName} requires index on '${targetTableName}(${columns.join(", ")})'. Set allowFullScan: true to override.`);
336
378
  if (!index && strict) console.warn(`Relation ${relationName} running without index (allowFullScan: true).`);
337
379
  return index;
338
380
  }
339
381
 
340
- //#endregion
341
- //#region src/orm/timestamp-mode.ts
342
- const PUBLIC_CREATED_AT_FIELD = "createdAt";
343
- const INTERNAL_CREATION_TIME_FIELD = "_creationTime";
344
- const CREATED_AT_MIGRATION_MESSAGE = "`_creationTime` is no longer public. Use `createdAt` instead.";
345
- const hasUserCreatedAtColumn = (table) => {
346
- if (!table || typeof table !== "object") return false;
347
- const columns = table[Columns];
348
- if (!columns || typeof columns !== "object") return false;
349
- return Object.hasOwn(columns, PUBLIC_CREATED_AT_FIELD);
350
- };
351
- const usesSystemCreatedAtAlias = (_table) => true;
352
-
353
382
  //#endregion
354
383
  //#region src/orm/write-fanout.ts
355
384
  /**
@@ -3897,7 +3926,24 @@ async function filterSelectRows(options) {
3897
3926
 
3898
3927
  //#endregion
3899
3928
  //#region src/orm/where-clause-compiler.ts
3929
+ /**
3930
+ * Enough to clear the 25-point gap between an exact match and a prefix match,
3931
+ * so an index that supplies the order outranks a narrower one that does not.
3932
+ */
3933
+ const INDEX_ORDER_BONUS = 30;
3934
+ /**
3935
+ * Widest `in` list still worth turning into an index union when it is the only
3936
+ * indexable term. Each value becomes its own index range, so past this width
3937
+ * the fan-out costs more than the single scan it would replace.
3938
+ */
3939
+ const MAX_PROMOTED_PROBES = 64;
3900
3940
  var WhereClauseCompiler = class {
3941
+ /**
3942
+ * Requested sort fields for the compile in flight. Index choice has to see
3943
+ * them: an index whose next key after the pinned prefix is the sort field
3944
+ * lets Convex serve the order from the scan, which no narrower index can do.
3945
+ */
3946
+ orderFields = [];
3901
3947
  constructor(_tableName, availableIndexes) {
3902
3948
  this.availableIndexes = availableIndexes;
3903
3949
  }
@@ -3905,9 +3951,11 @@ var WhereClauseCompiler = class {
3905
3951
  * Compile a filter expression to Convex query structure
3906
3952
  *
3907
3953
  * @param expression - Filter expression tree
3954
+ * @param options.orderFields - Resolved orderBy fields, most significant first
3908
3955
  * @returns Compilation result with index and filters
3909
3956
  */
3910
- compile(expression) {
3957
+ compile(expression, options) {
3958
+ this.orderFields = options?.orderFields ?? [];
3911
3959
  if (!expression) return {
3912
3960
  strategy: "none",
3913
3961
  selectedIndex: null,
@@ -3919,6 +3967,10 @@ var WhereClauseCompiler = class {
3919
3967
  if (specialCase) return specialCase;
3920
3968
  const referencedFields = this.extractFieldReferences(expression);
3921
3969
  const selectedIndex = this.selectIndex(referencedFields);
3970
+ if (!selectedIndex) {
3971
+ const promoted = this.tryCompileAndInArray(expression);
3972
+ if (promoted) return promoted;
3973
+ }
3922
3974
  const { indexFilters, postFilters } = this.splitFilters(expression, selectedIndex);
3923
3975
  return {
3924
3976
  strategy: this.resolveStrategy(selectedIndex, indexFilters),
@@ -3937,6 +3989,39 @@ var WhereClauseCompiler = class {
3937
3989
  if (expression.type === "logical") return this.tryCompileOrSpecialCase(expression);
3938
3990
  return null;
3939
3991
  }
3992
+ /**
3993
+ * `where: { status: { in: [...] }, name: { contains: 'x' } }` compiles to an
3994
+ * AND, and `extractFieldReferences` treats `inArray` as unindexable, so index
3995
+ * selection sees nothing and the plan degrades to a full table scan — even
3996
+ * though the very same `in` compiles to an index union on its own.
3997
+ *
3998
+ * Only fires when no index was selected at all, so a working `eq`-anchored
3999
+ * plan is never traded for a probe fan-out. Every other term of the AND stays
4000
+ * in `postFilters`, which the executor enforces per probe and again in
4001
+ * JavaScript.
4002
+ */
4003
+ tryCompileAndInArray(expression) {
4004
+ if (expression.type !== "logical" || expression.operator !== "and") return null;
4005
+ const terms = [];
4006
+ const flatten = (expr) => {
4007
+ if (expr.type === "logical" && expr.operator === "and") {
4008
+ for (const operand of expr.operands) flatten(operand);
4009
+ return;
4010
+ }
4011
+ terms.push(expr);
4012
+ };
4013
+ flatten(expression);
4014
+ for (const term of terms) {
4015
+ if (term.type !== "binary" || term.operator !== "inArray") continue;
4016
+ const probePlan = this.tryCompileInArray(term);
4017
+ if (!probePlan || probePlan.probeFilters.length > MAX_PROMOTED_PROBES) continue;
4018
+ return {
4019
+ ...probePlan,
4020
+ postFilters: [expression]
4021
+ };
4022
+ }
4023
+ return null;
4024
+ }
3940
4025
  resolveStrategy(selectedIndex, indexFilters) {
3941
4026
  if (!selectedIndex || indexFilters.length === 0) return "none";
3942
4027
  return indexFilters.some((filter) => filter.type === "binary" && (filter.operator === "gt" || filter.operator === "gte" || filter.operator === "lt" || filter.operator === "lte")) ? "rangeIndex" : "singleIndex";
@@ -4118,7 +4203,13 @@ var WhereClauseCompiler = class {
4118
4203
  };
4119
4204
  }
4120
4205
  findLeadingIndex(fieldName) {
4121
- return this.availableIndexes.filter((index) => index.indexFields[0] === fieldName).sort((a, b) => a.indexFields.length - b.indexFields.length)[0] ?? null;
4206
+ const candidates = this.availableIndexes.filter((index) => index.indexFields[0] === fieldName).sort((a, b) => a.indexFields.length - b.indexFields.length);
4207
+ if (this.orderFields.length > 0) {
4208
+ const orderField = this.orderFields[0];
4209
+ const serving = candidates.find((index) => index.indexFields.length > 1 && index.indexFields[1] === orderField);
4210
+ if (serving) return serving;
4211
+ }
4212
+ return candidates[0] ?? null;
4122
4213
  }
4123
4214
  getLikePrefix(pattern) {
4124
4215
  if (!pattern || pattern.startsWith("%") || pattern.includes("_")) return null;
@@ -4276,7 +4367,7 @@ var WhereClauseCompiler = class {
4276
4367
  const prefixCount = this.getPrefixMatchCount(indexFields, referencedFields);
4277
4368
  if (prefixCount > 0) return {
4278
4369
  index,
4279
- score: 75 + prefixCount,
4370
+ score: 75 + prefixCount + this.indexOrderBonus(indexFields, prefixCount),
4280
4371
  matchType: "prefix",
4281
4372
  matchedFields: indexFields.slice(0, prefixCount)
4282
4373
  };
@@ -4290,6 +4381,15 @@ var WhereClauseCompiler = class {
4290
4381
  return null;
4291
4382
  }
4292
4383
  /**
4384
+ * Reward an index that also supplies the requested order. Large enough to
4385
+ * clear the 25-point exact-over-prefix premium, so `(orgId, createdAt)` beats
4386
+ * `(orgId)` for `where { orgId } orderBy { createdAt }`.
4387
+ */
4388
+ indexOrderBonus(indexFields, pinnedLength) {
4389
+ if (this.orderFields.length === 0 || pinnedLength >= indexFields.length) return 0;
4390
+ return indexFields[pinnedLength] === this.orderFields[0] ? INDEX_ORDER_BONUS : 0;
4391
+ }
4392
+ /**
4293
4393
  * Check if referenced fields exactly match index fields in order
4294
4394
  */
4295
4395
  isExactMatch(indexFields, referencedFields) {
@@ -4390,7 +4490,7 @@ var WhereClauseCompiler = class {
4390
4490
  takeRange(fieldName);
4391
4491
  break;
4392
4492
  }
4393
- for (const binary of binaryFilters) if (!consumed.has(binary) && !postFilters.includes(binary)) postFilters.push(binary);
4493
+ for (const filters of binariesByField.values()) for (const binary of filters) if (!consumed.has(binary)) postFilters.push(binary);
4394
4494
  return {
4395
4495
  indexFilters,
4396
4496
  postFilters
@@ -4419,6 +4519,13 @@ const POST_FETCH_ONLY_OPERATORS = new Set([
4419
4519
  "arrayOverlaps"
4420
4520
  ]);
4421
4521
  const DEFAULT_RELATION_FAN_OUT_MAX_KEYS = 1e3;
4522
+ /**
4523
+ * How many child rows the bounded relation stream filters at a time. Batching
4524
+ * lets the sub-relation loader de-duplicate foreign keys and run its reads
4525
+ * concurrently; the cost is reading at most `chunk - 1` rows past the one that
4526
+ * satisfies the limit.
4527
+ */
4528
+ const RELATION_FILTER_STREAM_CHUNK = 32;
4422
4529
  const DEFAULT_AGGREGATE_CARTESIAN_MAX_KEYS = 4096;
4423
4530
  const DEFAULT_AGGREGATE_WORK_BUDGET = 16384;
4424
4531
  const PUBLIC_ID_FIELD = "id";
@@ -4429,6 +4536,11 @@ const RELATION_COUNT_ERROR = {
4429
4536
  FILTER_UNSUPPORTED: "RELATION_COUNT_FILTER_UNSUPPORTED"
4430
4537
  };
4431
4538
  /**
4539
+ * Physical-table-name lookup, keyed on schema identity. The schema is a
4540
+ * module-level immutable, so the index outlives any single request.
4541
+ */
4542
+ const tableConfigByDbNameCache = /* @__PURE__ */ new WeakMap();
4543
+ /**
4432
4544
  * Replays an already-read run of `[doc | null, indexKey]` entries.
4433
4545
  *
4434
4546
  * `narrow()` filters the buffer instead of re-issuing the read, so a stream
@@ -4473,6 +4585,42 @@ var BufferedQueryStream = class BufferedQueryStream extends QueryStream {
4473
4585
  return this.equalityIndexFilter;
4474
4586
  }
4475
4587
  };
4588
+ const ID_LIST_POSITION_FIELD = "__kitcn_id_list_position";
4589
+ /**
4590
+ * Reads an `id` / `id in [...]` where one document at a time, in the order the
4591
+ * ids were given.
4592
+ *
4593
+ * The index key is the position in the de-duplicated id list, so a cursor names
4594
+ * a position and `narrow` drops entries without reading them. A page reads only
4595
+ * the listed positions it visits, not every id in the list on every page.
4596
+ */
4597
+ var LazyIdListQueryStream = class LazyIdListQueryStream extends QueryStream {
4598
+ constructor(readId, entries, order) {
4599
+ super();
4600
+ this.readId = readId;
4601
+ this.entries = entries;
4602
+ this.order = order;
4603
+ }
4604
+ iterWithKeys() {
4605
+ const entries = this.entries;
4606
+ const readId = this.readId;
4607
+ return { async *[Symbol.asyncIterator]() {
4608
+ for (const [position, id] of entries) yield [await readId(id), [position]];
4609
+ } };
4610
+ }
4611
+ narrow(indexBounds) {
4612
+ return new LazyIdListQueryStream(this.readId, this.entries.filter(([position]) => indexKeyWithinBounds([position], indexBounds)), this.order);
4613
+ }
4614
+ getOrder() {
4615
+ return this.order;
4616
+ }
4617
+ getIndexFields() {
4618
+ return [ID_LIST_POSITION_FIELD];
4619
+ }
4620
+ getEqualityIndexFilter() {
4621
+ return [];
4622
+ }
4623
+ };
4476
4624
  const PIPELINE_LIMIT_ORDINAL_FIELD = "__kitcn_limit_ordinal";
4477
4625
  /** Cap a stream at its first `limit` matching documents without eager reads. */
4478
4626
  var LimitedMatchesQueryStream = class LimitedMatchesQueryStream extends QueryStream {
@@ -4565,6 +4713,33 @@ var GelRankQuery = class {
4565
4713
  return await readRankRandom(this.db, plan);
4566
4714
  }
4567
4715
  };
4716
+ const CONFIGURED_INDEX_RANGE_OPERATORS = new Set([
4717
+ "eq",
4718
+ "gt",
4719
+ "gte",
4720
+ "lt",
4721
+ "lte"
4722
+ ]);
4723
+ const observeConfiguredIndexRange = (builder, operations) => new Proxy(builder, { get(target, property) {
4724
+ const value = Reflect.get(target, property, target);
4725
+ if (typeof property === "string" && CONFIGURED_INDEX_RANGE_OPERATORS.has(property) && typeof value === "function") return (field, ...args) => {
4726
+ operations.push({
4727
+ field,
4728
+ operator: property
4729
+ });
4730
+ return observeConfiguredIndexRange(Reflect.apply(value, target, [field, ...args]), operations);
4731
+ };
4732
+ return typeof value === "function" ? value.bind(target) : value;
4733
+ } });
4734
+ const countConfiguredIndexEqPrefix = (indexFields, operations) => {
4735
+ if (!indexFields) return 0;
4736
+ let count = 0;
4737
+ for (const operation of operations) {
4738
+ if (operation.operator !== "eq" || operation.field !== indexFields[count]) break;
4739
+ count += 1;
4740
+ }
4741
+ return count;
4742
+ };
4568
4743
  /**
4569
4744
  * Relational query builder with promise-based execution
4570
4745
  *
@@ -4754,8 +4929,19 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4754
4929
  }
4755
4930
  return 0;
4756
4931
  }
4932
+ /**
4933
+ * Physical table name to relational config. Called once per row on the
4934
+ * relation-count path, so the linear schema scan is indexed once per schema
4935
+ * object rather than repeated. The schema is fixed for the process lifetime.
4936
+ */
4757
4937
  _getTableConfigByDbName(dbName) {
4758
- return Object.values(this.schema).find((table) => table.name === dbName);
4938
+ let byDbName = tableConfigByDbNameCache.get(this.schema);
4939
+ if (!byDbName) {
4940
+ byDbName = /* @__PURE__ */ new Map();
4941
+ for (const table of Object.values(this.schema)) if (table?.name && !byDbName.has(table.name)) byDbName.set(table.name, table);
4942
+ tableConfigByDbNameCache.set(this.schema, byDbName);
4943
+ }
4944
+ return byDbName.get(dbName);
4759
4945
  }
4760
4946
  _matchLike(value, pattern, caseInsensitive) {
4761
4947
  return matchLikePattern(value, pattern, caseInsensitive);
@@ -5637,9 +5823,12 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5637
5823
  * The where-clause compiler is built from declared indexes only, and `_id` is
5638
5824
  * never one of them, so an `id` filter can never be index-selected: it lands
5639
5825
  * in the post-filters and the stream walks the creation-time index until it
5640
- * happens on the row. `db.get()` reads exactly the rows asked for, so the ids
5641
- * are fetched directly and replayed as a creation-time-ordered stream — the
5642
- * order the scan would have produced, so stage order and cursors are the same.
5826
+ * happens on the row. `db.get()` reads exactly the rows asked for.
5827
+ *
5828
+ * Rows come back in the order the ids were given, one read at a time. Missing
5829
+ * or policy-filtered ids still cost a read, but a page does not reread the
5830
+ * complete list. An `orderBy` on creation time is the exception: an id carries
5831
+ * no creation time, so every id must be read before the first row is placed.
5643
5832
  *
5644
5833
  * Returns null when something else already owns the read: a pinned index, an
5645
5834
  * index the compiler did select, a `where(predicate)`, or an `orderBy` that
@@ -5651,13 +5840,26 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5651
5840
  const primaryOrder = queryConfig.order?.[0];
5652
5841
  if (primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD) return null;
5653
5842
  const ids = idLookup.kind === "in" ? Array.from(new Map(idLookup.ids.map((id) => [String(id), id])).values()) : [idLookup.id];
5654
- const rows = (await this._mapWithConcurrency(ids, async (id) => {
5655
- return this._getById(this.tableConfig.name, id);
5656
- })).filter((row) => !!row);
5843
+ const readId = (id) => this._getById(this.tableConfig.name, id);
5844
+ if (!primaryOrder) {
5845
+ const entries = ids.map((id, position) => [position, id]);
5846
+ if (order === "desc") entries.reverse();
5847
+ return new LazyIdListQueryStream(readId, entries, order);
5848
+ }
5849
+ const rows = (await this._mapWithConcurrency(ids, readId)).filter((row) => !!row);
5657
5850
  rows.sort((a, b) => compareValues(a[INTERNAL_CREATION_TIME_FIELD], b[INTERNAL_CREATION_TIME_FIELD]) || compareValues(a[INTERNAL_ID_FIELD], b[INTERNAL_ID_FIELD]));
5658
5851
  if (order === "desc") rows.reverse();
5659
5852
  return new BufferedQueryStream(rows.map((row) => [row, [row[INTERNAL_CREATION_TIME_FIELD], row[INTERNAL_ID_FIELD]]]), order, [INTERNAL_CREATION_TIME_FIELD, INTERNAL_ID_FIELD], []);
5660
5853
  }
5854
+ /**
5855
+ * The declared index a stream read can walk to emit `field` in order.
5856
+ *
5857
+ * A stream orders by the index it scans, so only an index that leads with
5858
+ * the field produces that order.
5859
+ */
5860
+ _findStreamOrderIndex(field) {
5861
+ return getIndexes(this.tableConfig.table).find((index) => index.fields[0] === field);
5862
+ }
5661
5863
  _buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex) {
5662
5864
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
5663
5865
  let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
@@ -5669,8 +5871,8 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5669
5871
  return indexQuery;
5670
5872
  });
5671
5873
  else if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
5672
- else if (primaryOrder && primaryOrder.field !== "_creationTime") {
5673
- const orderIndex = getIndexes(this.tableConfig.table).find((idx) => idx.fields[0] === primaryOrder.field);
5874
+ else if (primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD) {
5875
+ const orderIndex = this._findStreamOrderIndex(primaryOrder.field);
5674
5876
  if (orderIndex) streamQuery = streamQuery.withIndex(orderIndex.name, (q) => q);
5675
5877
  }
5676
5878
  streamQuery = streamQuery.order(primaryOrderDirection);
@@ -6828,7 +7030,11 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
6828
7030
  streamQuery = mergedStream(streams, pipeline.interleaveBy.map((field) => this._normalizePublicFieldName(field)));
6829
7031
  }
6830
7032
  } else {
6831
- if (isCursorPaginated && maxScan !== void 0 && idLookup?.kind === "in") throw new Error("An id IN pipeline cannot use maxScan because creation-order replay requires reading the complete ID list.");
7033
+ const orderedIdList = isCursorPaginated && maxScan !== void 0 && idLookup?.kind === "in" ? queryConfig.order?.[0] : void 0;
7034
+ if (orderedIdList) {
7035
+ if (orderedIdList.field === INTERNAL_CREATION_TIME_FIELD) throw new Error("An id IN pipeline cannot combine orderBy on createdAt with maxScan, because ordering an id list by creation time requires reading every id in the list. Drop maxScan, or drop orderBy to page in id-list order at one read per row.");
7036
+ if (queryConfig.order?.length !== 1 || configuredIndex?.name || queryConfig.index || !this._findStreamOrderIndex(orderedIdList.field)) throw new Error(`An id IN pipeline cannot combine orderBy on ${this._toPublicFilterFieldName(orderedIdList.field)} with maxScan, because the scan cannot produce that order. It needs a single orderBy field that an index leads with, and no index pinned by withIndex().`);
7037
+ }
6832
7038
  streamQuery = await this._buildIdLookupStream({
6833
7039
  configuredIndex,
6834
7040
  idLookup,
@@ -6959,14 +7165,28 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
6959
7165
  return indexQuery;
6960
7166
  });
6961
7167
  if (primaryOrder) {
6962
- const orderField = primaryOrder.field;
6963
- if (queryConfig.index.filters.map((f) => f.operands[0].fieldName).includes(orderField) || orderField === "_creationTime") query = query.order(primaryOrder.direction);
7168
+ const pushdownDirection = resolveIndexOrderPushdown({
7169
+ indexFields: indexConfig.fields,
7170
+ pinnedEqCount: this._indexEqPrefixCount(queryConfig),
7171
+ orderSpecs: [primaryOrder]
7172
+ });
7173
+ if (pushdownDirection) query = query.order(pushdownDirection);
6964
7174
  else needsPostFetchSortForPrimary = true;
6965
7175
  }
6966
7176
  } else if (configuredIndex?.name) {
6967
- query = query.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
6968
- if (primaryOrder) if (primaryOrder.field === "_creationTime") query = query.order(primaryOrder.direction);
6969
- else needsPostFetchSortForPrimary = true;
7177
+ const configuredIndexFields = getIndexes(this.tableConfig.table).find((idx) => idx.name === configuredIndex.name)?.fields;
7178
+ const rangeOperations = [];
7179
+ const configuredRange = configuredIndex.range;
7180
+ query = query.withIndex(configuredIndex.name, configuredRange ? (q) => configuredRange(observeConfiguredIndexRange(q, rangeOperations)) : (q) => q);
7181
+ if (primaryOrder) {
7182
+ const pushdownDirection = resolveIndexOrderPushdown({
7183
+ indexFields: configuredIndexFields,
7184
+ pinnedEqCount: countConfiguredIndexEqPrefix(configuredIndexFields, rangeOperations),
7185
+ orderSpecs: [primaryOrder]
7186
+ });
7187
+ if (pushdownDirection) query = query.order(pushdownDirection);
7188
+ else needsPostFetchSortForPrimary = true;
7189
+ }
6970
7190
  } else if (queryConfig.order && primaryOrder) {
6971
7191
  const orderField = primaryOrder.field;
6972
7192
  if (orderField === "_creationTime") query = query.order(primaryOrder.direction);
@@ -7033,32 +7253,42 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7033
7253
  return this._returnSelectedRows(selectedRows);
7034
7254
  }
7035
7255
  if (queryConfig.strategy === "multiProbe" && queryConfig.index && !isCursorPaginated) {
7256
+ const probeOffset = config.offset ?? 0;
7257
+ if (typeof probeOffset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
7258
+ const probeLimit = this._resolveNonPaginatedLimit(config);
7259
+ const convexProbeFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
7260
+ const probeHasResidualFilter = convexProbeFilters.length !== queryConfig.postFilters.length;
7261
+ const probeHasPostFetchMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig) || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
7262
+ const probeOrderDirection = primaryOrder ? resolveIndexOrderPushdown({
7263
+ indexFields: queryConfig.index.fields,
7264
+ pinnedEqCount: this._indexEqPrefixCount(queryConfig),
7265
+ orderSpecs: [primaryOrder]
7266
+ }) : null;
7267
+ const probeBound = probeLimit !== void 0 && !probeHasResidualFilter && !probeHasPostFetchMembership && (postFetchOrders.length === 0 || probeOrderDirection !== null && !hasSecondaryOrders) ? probeOffset + probeLimit : void 0;
7036
7268
  const probeRows = await Promise.all(queryConfig.probeFilters.map(async (probeFilters) => {
7037
7269
  let probeQuery = this.db.query(queryConfig.table).withIndex(queryConfig.index.name, (q) => {
7038
7270
  let indexQuery = q;
7039
7271
  for (const filter of probeFilters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
7040
7272
  return indexQuery;
7041
7273
  });
7042
- if (queryConfig.postFilters.length > 0) probeQuery = probeQuery.filter((q) => {
7274
+ if (probeBound !== void 0 && probeOrderDirection) probeQuery = probeQuery.order(probeOrderDirection);
7275
+ if (convexProbeFilters.length > 0) probeQuery = probeQuery.filter((q) => {
7043
7276
  let result = null;
7044
- for (const filter of queryConfig.postFilters) {
7277
+ for (const filter of convexProbeFilters) {
7045
7278
  const expr = this._toConvexExpression(filter)(q);
7046
7279
  result = result ? q.and(result, expr) : expr;
7047
7280
  }
7048
7281
  return result ?? q;
7049
7282
  });
7050
- return await probeQuery.collect();
7283
+ return probeBound === void 0 ? await probeQuery.collect() : await probeQuery.take(probeBound);
7051
7284
  }));
7052
7285
  let rows = Array.from(new Map(probeRows.flat().map((row) => [String(row._id), row])).values());
7053
7286
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
7054
7287
  rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
7055
7288
  if (whereFilter) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, 3, this.config.with);
7056
7289
  if (postFetchOrders.length > 0) rows = rows.sort((a, b) => this._compareByOrderSpecs(a, b, postFetchOrders));
7057
- const offset = config.offset ?? 0;
7058
- if (typeof offset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
7059
- const limit = this._resolveNonPaginatedLimit(config);
7060
- if (offset > 0) rows = rows.slice(offset);
7061
- if (limit !== void 0) rows = rows.slice(0, limit);
7290
+ if (probeOffset > 0) rows = rows.slice(probeOffset);
7291
+ if (probeLimit !== void 0) rows = rows.slice(0, probeLimit);
7062
7292
  const selectedRows = await this._finalizeRows(rows);
7063
7293
  return this._returnSelectedRows(selectedRows);
7064
7294
  }
@@ -7195,6 +7425,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7195
7425
  }
7196
7426
  const convexPostFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
7197
7427
  const hasResidualPostFilter = convexPostFilters.length !== queryConfig.postFilters.length;
7428
+ const hasPostFetchMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig) || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
7198
7429
  if (convexPostFilters.length > 0) query = query.filter((q) => {
7199
7430
  let result = null;
7200
7431
  for (const filter of convexPostFilters) {
@@ -7207,7 +7438,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7207
7438
  if (typeof offset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
7208
7439
  const limit = this._resolveNonPaginatedLimit(config);
7209
7440
  const paginateAfterPostFetchSort = usePostFetchSort && postFetchOrders.length > 0;
7210
- const sizeAfterPostFilter = hasResidualPostFilter && !paginateAfterPostFetchSort;
7441
+ const sizeAfterPostFilter = (hasResidualPostFilter || hasPostFetchMembership) && !paginateAfterPostFetchSort;
7211
7442
  const residualLimitStream = sizeAfterPostFilter && limit !== void 0 ? this._buildResidualFilterStream({
7212
7443
  queryConfig,
7213
7444
  configuredIndex,
@@ -7218,7 +7449,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7218
7449
  }) : null;
7219
7450
  let rows;
7220
7451
  if (residualLimitStream) rows = await residualLimitStream.take(offset > 0 ? offset + limit : limit);
7221
- else if (limit === void 0 || paginateAfterPostFetchSort || hasResidualPostFilter) rows = await query.collect();
7452
+ else if (limit === void 0 || paginateAfterPostFetchSort || hasResidualPostFilter || hasPostFetchMembership) rows = await query.collect();
7222
7453
  else rows = await query.take(offset > 0 ? offset + limit : limit);
7223
7454
  if (!(paginateAfterPostFetchSort || sizeAfterPostFilter) && offset > 0) rows = rows.slice(offset);
7224
7455
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
@@ -7249,9 +7480,17 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7249
7480
  indexFields: index.fields
7250
7481
  }));
7251
7482
  const compiler = new WhereClauseCompiler(this.tableConfig.table.tableName, tableIndexes);
7483
+ let orderSpecs = [];
7484
+ if (config.orderBy) {
7485
+ const orderByValue = typeof config.orderBy === "function" ? config.orderBy(this.tableConfig.table, {
7486
+ asc,
7487
+ desc
7488
+ }) : config.orderBy;
7489
+ orderSpecs = this._orderBySpecs(orderByValue);
7490
+ }
7252
7491
  let whereExpression = whereExpressionOverride;
7253
7492
  if (!whereExpression && config.where && typeof config.where !== "function") whereExpression = this._buildFilterExpression(config.where, this.tableConfig);
7254
- const planned = compiler.compile(whereExpression);
7493
+ const planned = compiler.compile(whereExpression, { orderFields: orderSpecs.map((spec) => spec.field) });
7255
7494
  const plannedUsesIndex = !!planned.selectedIndex && (planned.indexFilters.length > 0 || planned.probeFilters.length > 0);
7256
7495
  const compiled = !configuredIndex?.name || !plannedUsesIndex || planned.selectedIndex?.indexName === configuredIndex.name && !configuredIndex.range ? planned : {
7257
7496
  strategy: "none",
@@ -7268,16 +7507,10 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7268
7507
  };
7269
7508
  if (compiled.selectedIndex && (compiled.indexFilters.length > 0 || compiled.probeFilters.length > 0)) result.index = {
7270
7509
  name: compiled.selectedIndex.indexName,
7510
+ fields: compiled.selectedIndex.indexFields,
7271
7511
  filters: compiled.indexFilters
7272
7512
  };
7273
- if (config.orderBy) {
7274
- const orderByValue = typeof config.orderBy === "function" ? config.orderBy(this.tableConfig.table, {
7275
- asc,
7276
- desc
7277
- }) : config.orderBy;
7278
- const orderSpecs = this._orderBySpecs(orderByValue);
7279
- if (orderSpecs.length > 0) result.order = orderSpecs;
7280
- }
7513
+ if (orderSpecs.length > 0) result.order = orderSpecs;
7281
7514
  return result;
7282
7515
  }
7283
7516
  _buildRelationKey(row, fields) {
@@ -7286,6 +7519,30 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7286
7519
  if (values.some((value) => value === null || value === void 0)) return null;
7287
7520
  return JSON.stringify(values);
7288
7521
  }
7522
+ /**
7523
+ * How many leading fields of the scanned index are pinned to a single value.
7524
+ *
7525
+ * `splitFilters` emits index filters in index-key order — a run of `eq`, then
7526
+ * at most one range on the first unpinned field — so the leading `eq` run is
7527
+ * the prefix Convex holds constant. A multi-probe plan carries no index
7528
+ * filters; each probe supplies its own bound instead, and the union is only
7529
+ * as pinned as its least pinned probe.
7530
+ */
7531
+ _indexEqPrefixCount(queryConfig) {
7532
+ const countEqPrefix = (filters) => {
7533
+ let count = 0;
7534
+ for (const filter of filters) {
7535
+ if (filter.type !== "binary" || filter.operator !== "eq") break;
7536
+ count += 1;
7537
+ }
7538
+ return count;
7539
+ };
7540
+ if (queryConfig.index && queryConfig.index.filters.length > 0) return countEqPrefix(queryConfig.index.filters);
7541
+ if (queryConfig.probeFilters.length === 0) return 0;
7542
+ let pinned = Number.POSITIVE_INFINITY;
7543
+ for (const probe of queryConfig.probeFilters) pinned = Math.min(pinned, countEqPrefix(probe));
7544
+ return Number.isFinite(pinned) ? pinned : 0;
7545
+ }
7289
7546
  _buildIndexPredicate(q, fields, values) {
7290
7547
  let builder = q.eq(fields[0], values[0]);
7291
7548
  for (let i = 1; i < fields.length; i += 1) builder = builder.eq(fields[i], values[i]);
@@ -7494,16 +7751,6 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7494
7751
  if (typeof record.where === "function") throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `with._count.${relationName}.where callback is unsupported in v1`);
7495
7752
  return record.where;
7496
7753
  }
7497
- _normalizeRelationCountCacheValue(value) {
7498
- if (Array.isArray(value)) return value.map((entry) => this._normalizeRelationCountCacheValue(entry));
7499
- if (value && typeof value === "object") {
7500
- const normalized = {};
7501
- const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
7502
- for (const [key, entry] of entries) normalized[key] = this._normalizeRelationCountCacheValue(entry);
7503
- return normalized;
7504
- }
7505
- return value;
7506
- }
7507
7754
  _getRelationCountParentKey(row, edge) {
7508
7755
  const sourceFields = edge.sourceFields.length > 0 ? edge.sourceFields : [edge.fieldName];
7509
7756
  const values = [];
@@ -7514,26 +7761,20 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7514
7761
  }
7515
7762
  return JSON.stringify(values);
7516
7763
  }
7517
- _buildRelationCountExecutionKey(relationName, where, parentKey) {
7518
- return JSON.stringify({
7519
- relationName,
7520
- where: this._normalizeRelationCountCacheValue(where ?? null),
7521
- parentKey
7522
- });
7523
- }
7524
- async _readIndexedRelationCount(tableConfig, where, relationPath) {
7764
+ async _readIndexedRelationCount(tableConfig, where, relationPath, bucketCache) {
7525
7765
  ensureCountAllowedForRls(tableConfig, this.rls?.mode);
7526
7766
  try {
7527
7767
  const plan = compileCountQueryPlan(tableConfig, where);
7528
7768
  if (isIndexCountZero(plan)) return 0;
7529
7769
  await this._ensureCountIndexReadyOnce(plan.tableName, plan.indexName);
7530
- return await readCountFromBuckets(this.db, plan);
7770
+ return await readCountFromBuckets(this.db, plan, bucketCache);
7531
7771
  } catch (error) {
7532
7772
  throw this._remapRelationCountError(error, relationPath);
7533
7773
  }
7534
7774
  }
7535
- async _countRelationForRow(row, relationName, edge, where, tableConfig) {
7775
+ async _countRelationForRow(row, relationName, edge, where, tableConfig, caches) {
7536
7776
  const relationPath = `${tableConfig.name}.${relationName}`;
7777
+ const bucketCache = caches?.buckets;
7537
7778
  if (edge.through) {
7538
7779
  const throughTableConfig = this._getTableConfigByDbName(edge.through.table);
7539
7780
  if (!throughTableConfig) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `${relationPath} through table '${edge.through.table}' is not registered`);
@@ -7548,7 +7789,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7548
7789
  throughWhere[throughField] = value;
7549
7790
  sourceValues.push(value);
7550
7791
  }
7551
- if (this._isEmptyWhere(where) || where === void 0) return await this._readIndexedRelationCount(throughTableConfig, throughWhere, relationPath);
7792
+ if (this._isEmptyWhere(where) || where === void 0) return await this._readIndexedRelationCount(throughTableConfig, throughWhere, relationPath, bucketCache);
7552
7793
  const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
7553
7794
  if (!targetTableConfig) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `${relationPath} target table '${edge.targetTable}' is not registered`);
7554
7795
  ensureCountAllowedForRls(targetTableConfig, this.rls?.mode);
@@ -7583,13 +7824,28 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7583
7824
  if (targetKeyCounts.size === 0) return 0;
7584
7825
  const useGetById = targetFields.length === 1 && targetFields[0] === "_id";
7585
7826
  const targetIndexName = useGetById ? null : findRelationIndex(targetTableConfig.table, targetFields, relationPath, edge.targetTable, strict, this.allowFullScan);
7586
- const targetEntries = Array.from(targetKeyCounts.values());
7587
- return (await this._mapWithConcurrency(targetEntries, async ({ values, occurrences }) => {
7827
+ const resolveTargetMatch = async (values) => {
7588
7828
  let target = null;
7589
7829
  if (useGetById) target = await this._getById(edge.targetTable, values[0]);
7590
7830
  else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, targetIndexName).first();
7591
- if (!target) return 0;
7592
- return this._evaluateTableFilter(target, targetTableConfig, whereRecord) ? occurrences : 0;
7831
+ if (!target) return false;
7832
+ return this._evaluateTableFilter(target, targetTableConfig, whereRecord);
7833
+ };
7834
+ const targetMatchCache = caches?.throughTargetMatches;
7835
+ const targetEntries = Array.from(targetKeyCounts.entries());
7836
+ return (await this._mapWithConcurrency(targetEntries, async ([targetKey, { values, occurrences }]) => {
7837
+ if (!targetMatchCache) return await resolveTargetMatch(values) ? occurrences : 0;
7838
+ let pending = targetMatchCache.get(targetKey);
7839
+ if (!pending) {
7840
+ pending = resolveTargetMatch(values);
7841
+ targetMatchCache.set(targetKey, pending);
7842
+ }
7843
+ try {
7844
+ return await pending ? occurrences : 0;
7845
+ } catch (error) {
7846
+ targetMatchCache.delete(targetKey);
7847
+ throw error;
7848
+ }
7593
7849
  })).reduce((sum, value) => sum + value, 0);
7594
7850
  }
7595
7851
  const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
@@ -7605,7 +7861,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7605
7861
  relationWhere[targetField] = value;
7606
7862
  }
7607
7863
  const mergedWhere = this._isEmptyWhere(where) || where === void 0 ? relationWhere : { AND: [relationWhere, where] };
7608
- return await this._readIndexedRelationCount(targetTableConfig, mergedWhere, relationPath);
7864
+ return await this._readIndexedRelationCount(targetTableConfig, mergedWhere, relationPath, bucketCache);
7609
7865
  }
7610
7866
  async _loadRelationCounts(rows, relationCountConfig, targetTableEdges, tableConfig) {
7611
7867
  if (!relationCountConfig || typeof relationCountConfig !== "object" || Array.isArray(relationCountConfig)) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `with._count on '${tableConfig.name}' requires an object of relation names`);
@@ -7617,18 +7873,21 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7617
7873
  if (!edge) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `with._count.${relationName} is not a relation on '${tableConfig.name}'`);
7618
7874
  const where = this._coerceRelationCountWhere(relationName, relationSelection);
7619
7875
  const relationCountExecutionCache = /* @__PURE__ */ new Map();
7876
+ const caches = {
7877
+ buckets: /* @__PURE__ */ new Map(),
7878
+ throughTargetMatches: /* @__PURE__ */ new Map()
7879
+ };
7620
7880
  const counts = await this._mapWithConcurrency(rows, async (row) => {
7621
7881
  const parentKey = this._getRelationCountParentKey(row, edge);
7622
7882
  if (parentKey === null) return 0;
7623
- const executionKey = this._buildRelationCountExecutionKey(relationName, where, parentKey);
7624
- const existing = relationCountExecutionCache.get(executionKey);
7883
+ const existing = relationCountExecutionCache.get(parentKey);
7625
7884
  if (existing) return await existing;
7626
- const pending = this._countRelationForRow(row, relationName, edge, where, tableConfig);
7627
- relationCountExecutionCache.set(executionKey, pending);
7885
+ const pending = this._countRelationForRow(row, relationName, edge, where, tableConfig, caches);
7886
+ relationCountExecutionCache.set(parentKey, pending);
7628
7887
  try {
7629
7888
  return await pending;
7630
7889
  } catch (error) {
7631
- relationCountExecutionCache.delete(executionKey);
7890
+ relationCountExecutionCache.delete(parentKey);
7632
7891
  throw error;
7633
7892
  }
7634
7893
  });
@@ -7708,6 +7967,108 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7708
7967
  }
7709
7968
  }
7710
7969
  /**
7970
+ * Junction links per parent, stopped once each parent holds `fetchLimit`
7971
+ * links whose target actually reaches the page.
7972
+ *
7973
+ * A link only contributes if its target exists and survives target RLS and
7974
+ * the relation `where` — all of which run after the junction read. Sizing the
7975
+ * read on links alone therefore under-fills: three dangling or filtered links
7976
+ * first and `{ limit: 3 }` returns nothing. So the read is refilled in rounds,
7977
+ * each round resolving only the targets it newly needs and asking again for
7978
+ * whatever the survivors did not cover.
7979
+ *
7980
+ * Rounds are the unit rather than single links because both the target fetch
7981
+ * and the relation `where` de-duplicate and batch their own reads across the
7982
+ * parents in the round.
7983
+ */
7984
+ async _readBoundedThroughLinks(params) {
7985
+ const { applyTargetFilters, edge, enforceTargetKeyCap, entries, fetchLimit, fetchTargets, targetFields, throughIndexName, throughTableConfig } = params;
7986
+ const throughTargetFields = edge.through.targetFields;
7987
+ const cursors = entries.map(([key, values]) => ({
7988
+ buffered: [],
7989
+ exhausted: false,
7990
+ iterator: this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName)[Symbol.asyncIterator](),
7991
+ key,
7992
+ links: []
7993
+ }));
7994
+ const bufferVisibleLinks = async (cursor, count) => {
7995
+ let batch = [];
7996
+ const drain = async () => {
7997
+ const visible = await this._applyRlsSelectFilter(batch, throughTableConfig);
7998
+ batch = [];
7999
+ cursor.buffered.push(...visible);
8000
+ };
8001
+ while (!cursor.exhausted && cursor.buffered.length + batch.length < count) {
8002
+ const next = await cursor.iterator.next();
8003
+ if (next.done) {
8004
+ cursor.exhausted = true;
8005
+ break;
8006
+ }
8007
+ batch.push(next.value);
8008
+ if (batch.length >= RELATION_FILTER_STREAM_CHUNK) await drain();
8009
+ }
8010
+ await drain();
8011
+ };
8012
+ /** Target key -> the surviving document, absent when it did not survive. */
8013
+ const survivorByKey = /* @__PURE__ */ new Map();
8014
+ const resolvedKeys = /* @__PURE__ */ new Set();
8015
+ const survivors = [];
8016
+ while (true) {
8017
+ const active = cursors.filter((cursor) => cursor.links.length < fetchLimit && !(cursor.exhausted && cursor.buffered.length === 0));
8018
+ if (active.length === 0) break;
8019
+ const candidatesPerCursor = await this._mapWithConcurrency(active, async (cursor) => {
8020
+ const need = fetchLimit - cursor.links.length;
8021
+ await bufferVisibleLinks(cursor, need);
8022
+ return cursor.buffered.splice(0, need);
8023
+ });
8024
+ const newKeys = /* @__PURE__ */ new Map();
8025
+ for (const candidates of candidatesPerCursor) for (const link of candidates) {
8026
+ const values = throughTargetFields.map((field) => link[field]);
8027
+ if (values.some((value) => value === null || value === void 0)) continue;
8028
+ const key = JSON.stringify(values);
8029
+ if (resolvedKeys.has(key) || newKeys.has(key)) continue;
8030
+ newKeys.set(key, values);
8031
+ }
8032
+ if (newKeys.size > 0) {
8033
+ enforceTargetKeyCap(resolvedKeys.size + newKeys.size);
8034
+ const fetched = await fetchTargets(Array.from(newKeys.entries()));
8035
+ for (const key of newKeys.keys()) resolvedKeys.add(key);
8036
+ const surviving = await applyTargetFilters(fetched.map((entry) => entry.target).filter((target) => !!target));
8037
+ for (const target of surviving) {
8038
+ const key = this._buildRelationKey(target, targetFields);
8039
+ if (!key || survivorByKey.has(key)) continue;
8040
+ survivorByKey.set(key, target);
8041
+ survivors.push(target);
8042
+ }
8043
+ }
8044
+ for (let i = 0; i < active.length; i += 1) {
8045
+ const cursor = active[i];
8046
+ for (const link of candidatesPerCursor[i]) {
8047
+ if (cursor.links.length >= fetchLimit) break;
8048
+ const key = this._buildRelationKey(link, throughTargetFields);
8049
+ if (!key || !survivorByKey.has(key)) continue;
8050
+ cursor.links.push(link);
8051
+ }
8052
+ }
8053
+ }
8054
+ const linksBySourceKey = /* @__PURE__ */ new Map();
8055
+ const usedKeys = /* @__PURE__ */ new Set();
8056
+ for (const cursor of cursors) {
8057
+ linksBySourceKey.set(cursor.key, cursor.links);
8058
+ for (const link of cursor.links) {
8059
+ const key = this._buildRelationKey(link, throughTargetFields);
8060
+ if (key) usedKeys.add(key);
8061
+ }
8062
+ }
8063
+ return {
8064
+ linksBySourceKey,
8065
+ targets: survivors.filter((target) => {
8066
+ const key = this._buildRelationKey(target, targetFields);
8067
+ return key !== null && usedKeys.has(key);
8068
+ })
8069
+ };
8070
+ }
8071
+ /**
7711
8072
  * Load many() relation (one-to-many)
7712
8073
  * Example: users.posts where posts.authorId → users.id
7713
8074
  *
@@ -7781,65 +8142,108 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7781
8142
  if (!throughTableConfig) throw new Error(`Relation '${relationName}' through table '${edge.through.table}' not found.`);
7782
8143
  const throughIndexName = findRelationIndex(throughTableConfig.table, edge.through.sourceFields, `${tableConfig.name}.${relationName}`, edge.through.table, strict, this.allowFullScan);
7783
8144
  const entries = Array.from(sourceKeyMap.entries());
7784
- const throughRowsPerSource = await this._mapWithConcurrency(entries, async ([key, values]) => {
7785
- const query = this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName);
7786
- return {
7787
- key,
7788
- rows: await this._applyRlsSelectFilter(await query.collect(), throughTableConfig)
7789
- };
7790
- });
7791
- throughBySourceKey = /* @__PURE__ */ new Map();
7792
- const targetKeyMap = /* @__PURE__ */ new Map();
7793
- for (const entry of throughRowsPerSource) {
7794
- throughBySourceKey.set(entry.key, entry.rows);
7795
- for (const row of entry.rows) {
7796
- const values = edge.through.targetFields.map((field) => row[field]);
7797
- if (values.some((value) => value === null || value === void 0)) continue;
7798
- const key = JSON.stringify(values);
7799
- if (!targetKeyMap.has(key)) targetKeyMap.set(key, values);
7800
- }
7801
- }
7802
- this._enforceRelationFanOutKeyCap({
8145
+ const enforceTargetKeyCap = (keyCount) => this._enforceRelationFanOutKeyCap({
7803
8146
  tableConfig,
7804
8147
  relationName,
7805
- keyCount: targetKeyMap.size,
8148
+ keyCount,
7806
8149
  scope: "through-target"
7807
8150
  });
7808
- if (targetKeyMap.size > 0) {
7809
- const useGetById = targetFields.length === 1 && targetFields[0] === "_id";
7810
- const targetIndexName = useGetById ? null : findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
7811
- const targetEntries = Array.from(targetKeyMap.entries());
7812
- targets = (await this._mapWithConcurrency(targetEntries, async ([key, values]) => {
8151
+ let targetLookup = null;
8152
+ const fetchThroughTargets = async (keyEntries) => {
8153
+ if (!targetLookup) {
8154
+ const useGetById = targetFields.length === 1 && targetFields[0] === "_id";
8155
+ targetLookup = {
8156
+ useGetById,
8157
+ indexName: useGetById ? null : findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan)
8158
+ };
8159
+ }
8160
+ const { useGetById, indexName } = targetLookup;
8161
+ return await this._mapWithConcurrency(keyEntries, async ([key, values]) => {
7813
8162
  let target = null;
7814
8163
  if (useGetById) target = await this._getById(edge.targetTable, values[0]);
7815
- else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, targetIndexName).first();
8164
+ else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName).first();
7816
8165
  return {
7817
8166
  key,
7818
8167
  target
7819
8168
  };
7820
- })).map((entry) => entry.target).filter((value) => !!value);
8169
+ });
8170
+ };
8171
+ if (orderSpecs.length === 0 && effectivePerParentLimit !== void 0) {
8172
+ const bounded = await this._readBoundedThroughLinks({
8173
+ applyTargetFilters: applyPostFetchTargetFilters,
8174
+ edge,
8175
+ enforceTargetKeyCap,
8176
+ entries,
8177
+ fetchLimit: Math.max(perParentOffset ?? 0, 0) + effectivePerParentLimit,
8178
+ fetchTargets: fetchThroughTargets,
8179
+ targetFields,
8180
+ throughIndexName,
8181
+ throughTableConfig
8182
+ });
8183
+ throughBySourceKey = bounded.linksBySourceKey;
8184
+ targets = bounded.targets;
8185
+ targetFiltersApplied = true;
8186
+ } else {
8187
+ const throughRowsPerSource = await this._mapWithConcurrency(entries, async ([key, values]) => {
8188
+ const query = this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName);
8189
+ return {
8190
+ key,
8191
+ rows: await this._applyRlsSelectFilter(await query.collect(), throughTableConfig)
8192
+ };
8193
+ });
8194
+ throughBySourceKey = /* @__PURE__ */ new Map();
8195
+ const targetKeyMap = /* @__PURE__ */ new Map();
8196
+ for (const entry of throughRowsPerSource) {
8197
+ throughBySourceKey.set(entry.key, entry.rows);
8198
+ for (const row of entry.rows) {
8199
+ const values = edge.through.targetFields.map((field) => row[field]);
8200
+ if (values.some((value) => value === null || value === void 0)) continue;
8201
+ const key = JSON.stringify(values);
8202
+ if (!targetKeyMap.has(key)) targetKeyMap.set(key, values);
8203
+ }
8204
+ }
8205
+ enforceTargetKeyCap(targetKeyMap.size);
8206
+ if (targetKeyMap.size > 0) targets = (await fetchThroughTargets(Array.from(targetKeyMap.entries()))).map((entry) => entry.target).filter((value) => !!value);
7821
8207
  }
7822
8208
  } else {
7823
- const indexName = findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
8209
+ const indexName = findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan, orderSpecs);
7824
8210
  const entries = Array.from(sourceKeyMap.entries());
7825
- const streamPostFetchTargetFilters = orderSpecs.length === 0 && hasPostFetchTargetFilter && effectivePerParentLimit !== void 0;
8211
+ const orderPushdownDirection = resolveIndexOrderPushdown({
8212
+ indexFields: indexName ? getIndexes(targetTableConfig.table).find((idx) => idx.name === indexName)?.fields ?? null : null,
8213
+ pinnedEqCount: targetFields.length,
8214
+ orderSpecs
8215
+ });
8216
+ const orderServedByIndex = orderSpecs.length === 0 || orderPushdownDirection !== null;
8217
+ const applyPushdownOrder = (query) => orderPushdownDirection ? query.order(orderPushdownDirection) : query;
8218
+ const streamPostFetchTargetFilters = orderServedByIndex && hasPostFetchTargetFilter && effectivePerParentLimit !== void 0;
7826
8219
  targetFiltersApplied = streamPostFetchTargetFilters;
7827
8220
  targets = (await this._mapWithConcurrency(entries, async ([, values]) => {
7828
8221
  const query = this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName);
7829
- if (orderSpecs.length === 0 && !hasPostFetchTargetFilter && effectivePerParentLimit !== void 0) {
8222
+ if (orderServedByIndex && !hasPostFetchTargetFilter && effectivePerParentLimit !== void 0) {
7830
8223
  const fetchLimit = (perParentOffset ?? 0) + (effectivePerParentLimit ?? 0);
7831
- return await query.take(fetchLimit);
8224
+ return await applyPushdownOrder(query).take(fetchLimit);
7832
8225
  }
7833
8226
  if (streamPostFetchTargetFilters) {
7834
8227
  const visibleTargets = [];
7835
8228
  const fetchLimit = Math.max(perParentOffset ?? 0, 0) + (effectivePerParentLimit ?? 0);
7836
- for await (const target of query) {
7837
- const filtered = await applyPostFetchTargetFilters([target]);
7838
- if (filtered.length > 0) {
7839
- visibleTargets.push(filtered[0]);
7840
- if (visibleTargets.length >= fetchLimit) break;
8229
+ let batch = [];
8230
+ const drain = async () => {
8231
+ if (batch.length === 0) return;
8232
+ const filtered = await applyPostFetchTargetFilters(batch);
8233
+ batch = [];
8234
+ for (const row of filtered) {
8235
+ if (visibleTargets.length >= fetchLimit) return;
8236
+ visibleTargets.push(row);
7841
8237
  }
8238
+ };
8239
+ for await (const target of applyPushdownOrder(query)) {
8240
+ batch.push(target);
8241
+ const chunk = Math.min(RELATION_FILTER_STREAM_CHUNK, fetchLimit - visibleTargets.length);
8242
+ if (batch.length < chunk) continue;
8243
+ await drain();
8244
+ if (visibleTargets.length >= fetchLimit) break;
7842
8245
  }
8246
+ if (visibleTargets.length < fetchLimit) await drain();
7843
8247
  return visibleTargets;
7844
8248
  }
7845
8249
  return await query.collect();
@@ -7847,6 +8251,19 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7847
8251
  }
7848
8252
  if (!targetFiltersApplied) targets = await applyPostFetchTargetFilters(targets);
7849
8253
  if (orderSpecs.length > 0) targets.sort((a, b) => this._compareByOrderSpecs(a, b, orderSpecs));
8254
+ if (!edge.through && (perParentOffset !== void 0 || effectivePerParentLimit !== void 0)) {
8255
+ const groupedTargets = /* @__PURE__ */ new Map();
8256
+ for (const target of targets) {
8257
+ const parentKey = this._buildRelationKey(target, targetFields);
8258
+ if (!parentKey) continue;
8259
+ const group = groupedTargets.get(parentKey);
8260
+ if (group) group.push(target);
8261
+ else groupedTargets.set(parentKey, [target]);
8262
+ }
8263
+ const trimmed = [];
8264
+ for (const children of groupedTargets.values()) for (const child of applyOffsetAndLimit(children)) trimmed.push(child);
8265
+ targets = trimmed;
8266
+ }
7850
8267
  if (relationConfig && typeof relationConfig === "object" && "with" in relationConfig) {
7851
8268
  const targetTableEdges = this._getTargetTableEdges(edge.targetTable);
7852
8269
  await this._loadRelations(targets, relationConfig.with, depth + 1, maxDepth, targetTableEdges, targetTableConfig);
@@ -7891,7 +8308,6 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7891
8308
  if (!byParentKey.has(parentKey)) byParentKey.set(parentKey, []);
7892
8309
  byParentKey.get(parentKey).push(mappedTarget);
7893
8310
  }
7894
- if (perParentOffset !== void 0 || effectivePerParentLimit !== void 0) for (const [parentKey, children] of byParentKey.entries()) byParentKey.set(parentKey, applyOffsetAndLimit(children));
7895
8311
  for (const row of rows) {
7896
8312
  const rowKey = this._buildRelationKey(row, sourceFields);
7897
8313
  row[relationName] = rowKey ? byParentKey.get(rowKey) ?? [] : [];
@@ -9531,6 +9947,38 @@ var ConvexUpdateBuilder = class extends QueryPromise {
9531
9947
 
9532
9948
  //#endregion
9533
9949
  //#region src/orm/database.ts
9950
+ /**
9951
+ * `createDatabase` runs on every Convex query and mutation entry, but the
9952
+ * foreign-key graph and the per-table edge partition are pure functions of the
9953
+ * schema and edge metadata, both fixed for the process lifetime. Caching them
9954
+ * on the identity of those objects keeps the per-request work proportional to
9955
+ * the table count instead of `tables x edges`.
9956
+ *
9957
+ * Both caches are only sound because their inputs are module-level immutables:
9958
+ * nothing in the ORM mutates a schema or an edge list after construction.
9959
+ */
9960
+ const foreignKeyGraphCache = /* @__PURE__ */ new WeakMap();
9961
+ const edgesBySourceTableCache = /* @__PURE__ */ new WeakMap();
9962
+ const NO_EDGES = [];
9963
+ function getForeignKeyGraph(schema) {
9964
+ const cached = foreignKeyGraphCache.get(schema);
9965
+ if (cached) return cached;
9966
+ const graph = buildForeignKeyGraph(schema);
9967
+ foreignKeyGraphCache.set(schema, graph);
9968
+ return graph;
9969
+ }
9970
+ function getEdgesBySourceTable(edgeMetadata) {
9971
+ const cached = edgesBySourceTableCache.get(edgeMetadata);
9972
+ if (cached) return cached;
9973
+ const grouped = /* @__PURE__ */ new Map();
9974
+ for (const edge of edgeMetadata) {
9975
+ const existing = grouped.get(edge.sourceTable);
9976
+ if (existing) existing.push(edge);
9977
+ else grouped.set(edge.sourceTable, [edge]);
9978
+ }
9979
+ edgesBySourceTableCache.set(edgeMetadata, grouped);
9980
+ return grouped;
9981
+ }
9534
9982
  function createDatabase(db, schema, edgeMetadata, options) {
9535
9983
  const schemaOptions = schema[OrmSchemaOptions];
9536
9984
  const strict = schemaOptions?.strict ?? true;
@@ -9541,7 +9989,7 @@ function createDatabase(db, schema, edgeMetadata, options) {
9541
9989
  scheduledMutationBatch: options?.scheduledMutationBatch
9542
9990
  });
9543
9991
  const ormContext = {
9544
- foreignKeyGraph: buildForeignKeyGraph(schema),
9992
+ foreignKeyGraph: getForeignKeyGraph(schema),
9545
9993
  schema,
9546
9994
  edgeMetadata,
9547
9995
  relationLoading: options?.relationLoading,
@@ -9555,7 +10003,8 @@ function createDatabase(db, schema, edgeMetadata, options) {
9555
10003
  };
9556
10004
  const baseDb = Object.assign(Object.create(db), { [OrmContext]: ormContext });
9557
10005
  const query = {};
9558
- for (const [tableName, tableConfig] of Object.entries(schema)) query[tableName] = new RelationalQueryBuilder(schema, tableConfig, edgeMetadata.filter((edge) => edge.sourceTable === tableConfig.name), baseDb, edgeMetadata, rls, options?.relationLoading, options?.vectorSearch);
10006
+ const edgesBySourceTable = getEdgesBySourceTable(edgeMetadata);
10007
+ for (const [tableName, tableConfig] of Object.entries(schema)) query[tableName] = new RelationalQueryBuilder(schema, tableConfig, edgesBySourceTable.get(tableConfig.name) ?? NO_EDGES, baseDb, edgeMetadata, rls, options?.relationLoading, options?.vectorSearch);
9559
10008
  const isWriter = typeof db.insert === "function" && typeof db.patch === "function";
9560
10009
  const isConvexTable = (value) => !!value && typeof value === "object" && value[Brand] === "ConvexTable";
9561
10010
  const insert = (table) => {
@@ -9600,14 +10049,18 @@ function createDatabase(db, schema, edgeMetadata, options) {
9600
10049
  return built;
9601
10050
  };
9602
10051
  const table = buildDatabase(options?.rls);
9603
- const skipRulesTable = buildDatabase({
9604
- ...options?.rls ?? {},
9605
- mode: "skip"
10052
+ let skipRulesTable;
10053
+ return Object.defineProperty({ ...table }, "skipRules", {
10054
+ enumerable: true,
10055
+ configurable: true,
10056
+ get() {
10057
+ skipRulesTable ??= buildDatabase({
10058
+ ...options?.rls ?? {},
10059
+ mode: "skip"
10060
+ });
10061
+ return skipRulesTable;
10062
+ }
9606
10063
  });
9607
- return {
9608
- ...table,
9609
- skipRules: skipRulesTable
9610
- };
9611
10064
  }
9612
10065
 
9613
10066
  //#endregion
@@ -11176,8 +11629,7 @@ function resolveOrmSchemaConfig(schemaInput) {
11176
11629
  triggers: getSchemaTriggers(schemaInput)
11177
11630
  };
11178
11631
  }
11179
- function createDbFactory(schema, dbLifecycle, ormFunctions) {
11180
- const edgeMetadata = extractRelationsConfig(schema);
11632
+ function createDbFactory(schema, edgeMetadata, dbLifecycle, ormFunctions) {
11181
11633
  return ((source, options) => {
11182
11634
  const ctxSource = isOrmCtx(source) ? source : void 0;
11183
11635
  const rawDb = ctxSource ? ctxSource.db : source;
@@ -11203,7 +11655,7 @@ function createOrm(config) {
11203
11655
  const { schema: resolvedSchema, triggers } = resolveOrmSchemaConfig(config.schema);
11204
11656
  const dbLifecycle = createOrmDbLifecycle(resolvedSchema, triggers);
11205
11657
  const edgeMetadata = extractRelationsConfig(resolvedSchema);
11206
- const db = createDbFactory(resolvedSchema, dbLifecycle, config.ormFunctions);
11658
+ const db = createDbFactory(resolvedSchema, edgeMetadata, dbLifecycle, config.ormFunctions);
11207
11659
  const withContext = (ctx, options) => {
11208
11660
  const lifecycleCtx = { ...ctx };
11209
11661
  const wrappedCtx = dbLifecycle.wrapDB(lifecycleCtx);