kitcn 0.17.4 → 0.18.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-CJE_hYA3.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
@@ -4565,6 +4677,33 @@ var GelRankQuery = class {
4565
4677
  return await readRankRandom(this.db, plan);
4566
4678
  }
4567
4679
  };
4680
+ const CONFIGURED_INDEX_RANGE_OPERATORS = new Set([
4681
+ "eq",
4682
+ "gt",
4683
+ "gte",
4684
+ "lt",
4685
+ "lte"
4686
+ ]);
4687
+ const observeConfiguredIndexRange = (builder, operations) => new Proxy(builder, { get(target, property) {
4688
+ const value = Reflect.get(target, property, target);
4689
+ if (typeof property === "string" && CONFIGURED_INDEX_RANGE_OPERATORS.has(property) && typeof value === "function") return (field, ...args) => {
4690
+ operations.push({
4691
+ field,
4692
+ operator: property
4693
+ });
4694
+ return observeConfiguredIndexRange(Reflect.apply(value, target, [field, ...args]), operations);
4695
+ };
4696
+ return typeof value === "function" ? value.bind(target) : value;
4697
+ } });
4698
+ const countConfiguredIndexEqPrefix = (indexFields, operations) => {
4699
+ if (!indexFields) return 0;
4700
+ let count = 0;
4701
+ for (const operation of operations) {
4702
+ if (operation.operator !== "eq" || operation.field !== indexFields[count]) break;
4703
+ count += 1;
4704
+ }
4705
+ return count;
4706
+ };
4568
4707
  /**
4569
4708
  * Relational query builder with promise-based execution
4570
4709
  *
@@ -4754,8 +4893,19 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4754
4893
  }
4755
4894
  return 0;
4756
4895
  }
4896
+ /**
4897
+ * Physical table name to relational config. Called once per row on the
4898
+ * relation-count path, so the linear schema scan is indexed once per schema
4899
+ * object rather than repeated. The schema is fixed for the process lifetime.
4900
+ */
4757
4901
  _getTableConfigByDbName(dbName) {
4758
- return Object.values(this.schema).find((table) => table.name === dbName);
4902
+ let byDbName = tableConfigByDbNameCache.get(this.schema);
4903
+ if (!byDbName) {
4904
+ byDbName = /* @__PURE__ */ new Map();
4905
+ for (const table of Object.values(this.schema)) if (table?.name && !byDbName.has(table.name)) byDbName.set(table.name, table);
4906
+ tableConfigByDbNameCache.set(this.schema, byDbName);
4907
+ }
4908
+ return byDbName.get(dbName);
4759
4909
  }
4760
4910
  _matchLike(value, pattern, caseInsensitive) {
4761
4911
  return matchLikePattern(value, pattern, caseInsensitive);
@@ -6959,14 +7109,28 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
6959
7109
  return indexQuery;
6960
7110
  });
6961
7111
  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);
7112
+ const pushdownDirection = resolveIndexOrderPushdown({
7113
+ indexFields: indexConfig.fields,
7114
+ pinnedEqCount: this._indexEqPrefixCount(queryConfig),
7115
+ orderSpecs: [primaryOrder]
7116
+ });
7117
+ if (pushdownDirection) query = query.order(pushdownDirection);
6964
7118
  else needsPostFetchSortForPrimary = true;
6965
7119
  }
6966
7120
  } 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;
7121
+ const configuredIndexFields = getIndexes(this.tableConfig.table).find((idx) => idx.name === configuredIndex.name)?.fields;
7122
+ const rangeOperations = [];
7123
+ const configuredRange = configuredIndex.range;
7124
+ query = query.withIndex(configuredIndex.name, configuredRange ? (q) => configuredRange(observeConfiguredIndexRange(q, rangeOperations)) : (q) => q);
7125
+ if (primaryOrder) {
7126
+ const pushdownDirection = resolveIndexOrderPushdown({
7127
+ indexFields: configuredIndexFields,
7128
+ pinnedEqCount: countConfiguredIndexEqPrefix(configuredIndexFields, rangeOperations),
7129
+ orderSpecs: [primaryOrder]
7130
+ });
7131
+ if (pushdownDirection) query = query.order(pushdownDirection);
7132
+ else needsPostFetchSortForPrimary = true;
7133
+ }
6970
7134
  } else if (queryConfig.order && primaryOrder) {
6971
7135
  const orderField = primaryOrder.field;
6972
7136
  if (orderField === "_creationTime") query = query.order(primaryOrder.direction);
@@ -7033,32 +7197,42 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7033
7197
  return this._returnSelectedRows(selectedRows);
7034
7198
  }
7035
7199
  if (queryConfig.strategy === "multiProbe" && queryConfig.index && !isCursorPaginated) {
7200
+ const probeOffset = config.offset ?? 0;
7201
+ if (typeof probeOffset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
7202
+ const probeLimit = this._resolveNonPaginatedLimit(config);
7203
+ const convexProbeFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
7204
+ const probeHasResidualFilter = convexProbeFilters.length !== queryConfig.postFilters.length;
7205
+ const probeHasPostFetchMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig) || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
7206
+ const probeOrderDirection = primaryOrder ? resolveIndexOrderPushdown({
7207
+ indexFields: queryConfig.index.fields,
7208
+ pinnedEqCount: this._indexEqPrefixCount(queryConfig),
7209
+ orderSpecs: [primaryOrder]
7210
+ }) : null;
7211
+ const probeBound = probeLimit !== void 0 && !probeHasResidualFilter && !probeHasPostFetchMembership && (postFetchOrders.length === 0 || probeOrderDirection !== null && !hasSecondaryOrders) ? probeOffset + probeLimit : void 0;
7036
7212
  const probeRows = await Promise.all(queryConfig.probeFilters.map(async (probeFilters) => {
7037
7213
  let probeQuery = this.db.query(queryConfig.table).withIndex(queryConfig.index.name, (q) => {
7038
7214
  let indexQuery = q;
7039
7215
  for (const filter of probeFilters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
7040
7216
  return indexQuery;
7041
7217
  });
7042
- if (queryConfig.postFilters.length > 0) probeQuery = probeQuery.filter((q) => {
7218
+ if (probeBound !== void 0 && probeOrderDirection) probeQuery = probeQuery.order(probeOrderDirection);
7219
+ if (convexProbeFilters.length > 0) probeQuery = probeQuery.filter((q) => {
7043
7220
  let result = null;
7044
- for (const filter of queryConfig.postFilters) {
7221
+ for (const filter of convexProbeFilters) {
7045
7222
  const expr = this._toConvexExpression(filter)(q);
7046
7223
  result = result ? q.and(result, expr) : expr;
7047
7224
  }
7048
7225
  return result ?? q;
7049
7226
  });
7050
- return await probeQuery.collect();
7227
+ return probeBound === void 0 ? await probeQuery.collect() : await probeQuery.take(probeBound);
7051
7228
  }));
7052
7229
  let rows = Array.from(new Map(probeRows.flat().map((row) => [String(row._id), row])).values());
7053
7230
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
7054
7231
  rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
7055
7232
  if (whereFilter) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, 3, this.config.with);
7056
7233
  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);
7234
+ if (probeOffset > 0) rows = rows.slice(probeOffset);
7235
+ if (probeLimit !== void 0) rows = rows.slice(0, probeLimit);
7062
7236
  const selectedRows = await this._finalizeRows(rows);
7063
7237
  return this._returnSelectedRows(selectedRows);
7064
7238
  }
@@ -7195,6 +7369,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7195
7369
  }
7196
7370
  const convexPostFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
7197
7371
  const hasResidualPostFilter = convexPostFilters.length !== queryConfig.postFilters.length;
7372
+ const hasPostFetchMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig) || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
7198
7373
  if (convexPostFilters.length > 0) query = query.filter((q) => {
7199
7374
  let result = null;
7200
7375
  for (const filter of convexPostFilters) {
@@ -7207,7 +7382,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7207
7382
  if (typeof offset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
7208
7383
  const limit = this._resolveNonPaginatedLimit(config);
7209
7384
  const paginateAfterPostFetchSort = usePostFetchSort && postFetchOrders.length > 0;
7210
- const sizeAfterPostFilter = hasResidualPostFilter && !paginateAfterPostFetchSort;
7385
+ const sizeAfterPostFilter = (hasResidualPostFilter || hasPostFetchMembership) && !paginateAfterPostFetchSort;
7211
7386
  const residualLimitStream = sizeAfterPostFilter && limit !== void 0 ? this._buildResidualFilterStream({
7212
7387
  queryConfig,
7213
7388
  configuredIndex,
@@ -7218,7 +7393,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7218
7393
  }) : null;
7219
7394
  let rows;
7220
7395
  if (residualLimitStream) rows = await residualLimitStream.take(offset > 0 ? offset + limit : limit);
7221
- else if (limit === void 0 || paginateAfterPostFetchSort || hasResidualPostFilter) rows = await query.collect();
7396
+ else if (limit === void 0 || paginateAfterPostFetchSort || hasResidualPostFilter || hasPostFetchMembership) rows = await query.collect();
7222
7397
  else rows = await query.take(offset > 0 ? offset + limit : limit);
7223
7398
  if (!(paginateAfterPostFetchSort || sizeAfterPostFilter) && offset > 0) rows = rows.slice(offset);
7224
7399
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
@@ -7249,9 +7424,17 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7249
7424
  indexFields: index.fields
7250
7425
  }));
7251
7426
  const compiler = new WhereClauseCompiler(this.tableConfig.table.tableName, tableIndexes);
7427
+ let orderSpecs = [];
7428
+ if (config.orderBy) {
7429
+ const orderByValue = typeof config.orderBy === "function" ? config.orderBy(this.tableConfig.table, {
7430
+ asc,
7431
+ desc
7432
+ }) : config.orderBy;
7433
+ orderSpecs = this._orderBySpecs(orderByValue);
7434
+ }
7252
7435
  let whereExpression = whereExpressionOverride;
7253
7436
  if (!whereExpression && config.where && typeof config.where !== "function") whereExpression = this._buildFilterExpression(config.where, this.tableConfig);
7254
- const planned = compiler.compile(whereExpression);
7437
+ const planned = compiler.compile(whereExpression, { orderFields: orderSpecs.map((spec) => spec.field) });
7255
7438
  const plannedUsesIndex = !!planned.selectedIndex && (planned.indexFilters.length > 0 || planned.probeFilters.length > 0);
7256
7439
  const compiled = !configuredIndex?.name || !plannedUsesIndex || planned.selectedIndex?.indexName === configuredIndex.name && !configuredIndex.range ? planned : {
7257
7440
  strategy: "none",
@@ -7268,16 +7451,10 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7268
7451
  };
7269
7452
  if (compiled.selectedIndex && (compiled.indexFilters.length > 0 || compiled.probeFilters.length > 0)) result.index = {
7270
7453
  name: compiled.selectedIndex.indexName,
7454
+ fields: compiled.selectedIndex.indexFields,
7271
7455
  filters: compiled.indexFilters
7272
7456
  };
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
- }
7457
+ if (orderSpecs.length > 0) result.order = orderSpecs;
7281
7458
  return result;
7282
7459
  }
7283
7460
  _buildRelationKey(row, fields) {
@@ -7286,6 +7463,30 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7286
7463
  if (values.some((value) => value === null || value === void 0)) return null;
7287
7464
  return JSON.stringify(values);
7288
7465
  }
7466
+ /**
7467
+ * How many leading fields of the scanned index are pinned to a single value.
7468
+ *
7469
+ * `splitFilters` emits index filters in index-key order — a run of `eq`, then
7470
+ * at most one range on the first unpinned field — so the leading `eq` run is
7471
+ * the prefix Convex holds constant. A multi-probe plan carries no index
7472
+ * filters; each probe supplies its own bound instead, and the union is only
7473
+ * as pinned as its least pinned probe.
7474
+ */
7475
+ _indexEqPrefixCount(queryConfig) {
7476
+ const countEqPrefix = (filters) => {
7477
+ let count = 0;
7478
+ for (const filter of filters) {
7479
+ if (filter.type !== "binary" || filter.operator !== "eq") break;
7480
+ count += 1;
7481
+ }
7482
+ return count;
7483
+ };
7484
+ if (queryConfig.index && queryConfig.index.filters.length > 0) return countEqPrefix(queryConfig.index.filters);
7485
+ if (queryConfig.probeFilters.length === 0) return 0;
7486
+ let pinned = Number.POSITIVE_INFINITY;
7487
+ for (const probe of queryConfig.probeFilters) pinned = Math.min(pinned, countEqPrefix(probe));
7488
+ return Number.isFinite(pinned) ? pinned : 0;
7489
+ }
7289
7490
  _buildIndexPredicate(q, fields, values) {
7290
7491
  let builder = q.eq(fields[0], values[0]);
7291
7492
  for (let i = 1; i < fields.length; i += 1) builder = builder.eq(fields[i], values[i]);
@@ -7494,16 +7695,6 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7494
7695
  if (typeof record.where === "function") throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `with._count.${relationName}.where callback is unsupported in v1`);
7495
7696
  return record.where;
7496
7697
  }
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
7698
  _getRelationCountParentKey(row, edge) {
7508
7699
  const sourceFields = edge.sourceFields.length > 0 ? edge.sourceFields : [edge.fieldName];
7509
7700
  const values = [];
@@ -7514,26 +7705,20 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7514
7705
  }
7515
7706
  return JSON.stringify(values);
7516
7707
  }
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) {
7708
+ async _readIndexedRelationCount(tableConfig, where, relationPath, bucketCache) {
7525
7709
  ensureCountAllowedForRls(tableConfig, this.rls?.mode);
7526
7710
  try {
7527
7711
  const plan = compileCountQueryPlan(tableConfig, where);
7528
7712
  if (isIndexCountZero(plan)) return 0;
7529
7713
  await this._ensureCountIndexReadyOnce(plan.tableName, plan.indexName);
7530
- return await readCountFromBuckets(this.db, plan);
7714
+ return await readCountFromBuckets(this.db, plan, bucketCache);
7531
7715
  } catch (error) {
7532
7716
  throw this._remapRelationCountError(error, relationPath);
7533
7717
  }
7534
7718
  }
7535
- async _countRelationForRow(row, relationName, edge, where, tableConfig) {
7719
+ async _countRelationForRow(row, relationName, edge, where, tableConfig, caches) {
7536
7720
  const relationPath = `${tableConfig.name}.${relationName}`;
7721
+ const bucketCache = caches?.buckets;
7537
7722
  if (edge.through) {
7538
7723
  const throughTableConfig = this._getTableConfigByDbName(edge.through.table);
7539
7724
  if (!throughTableConfig) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `${relationPath} through table '${edge.through.table}' is not registered`);
@@ -7548,7 +7733,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7548
7733
  throughWhere[throughField] = value;
7549
7734
  sourceValues.push(value);
7550
7735
  }
7551
- if (this._isEmptyWhere(where) || where === void 0) return await this._readIndexedRelationCount(throughTableConfig, throughWhere, relationPath);
7736
+ if (this._isEmptyWhere(where) || where === void 0) return await this._readIndexedRelationCount(throughTableConfig, throughWhere, relationPath, bucketCache);
7552
7737
  const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
7553
7738
  if (!targetTableConfig) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `${relationPath} target table '${edge.targetTable}' is not registered`);
7554
7739
  ensureCountAllowedForRls(targetTableConfig, this.rls?.mode);
@@ -7583,13 +7768,28 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7583
7768
  if (targetKeyCounts.size === 0) return 0;
7584
7769
  const useGetById = targetFields.length === 1 && targetFields[0] === "_id";
7585
7770
  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 }) => {
7771
+ const resolveTargetMatch = async (values) => {
7588
7772
  let target = null;
7589
7773
  if (useGetById) target = await this._getById(edge.targetTable, values[0]);
7590
7774
  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;
7775
+ if (!target) return false;
7776
+ return this._evaluateTableFilter(target, targetTableConfig, whereRecord);
7777
+ };
7778
+ const targetMatchCache = caches?.throughTargetMatches;
7779
+ const targetEntries = Array.from(targetKeyCounts.entries());
7780
+ return (await this._mapWithConcurrency(targetEntries, async ([targetKey, { values, occurrences }]) => {
7781
+ if (!targetMatchCache) return await resolveTargetMatch(values) ? occurrences : 0;
7782
+ let pending = targetMatchCache.get(targetKey);
7783
+ if (!pending) {
7784
+ pending = resolveTargetMatch(values);
7785
+ targetMatchCache.set(targetKey, pending);
7786
+ }
7787
+ try {
7788
+ return await pending ? occurrences : 0;
7789
+ } catch (error) {
7790
+ targetMatchCache.delete(targetKey);
7791
+ throw error;
7792
+ }
7593
7793
  })).reduce((sum, value) => sum + value, 0);
7594
7794
  }
7595
7795
  const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
@@ -7605,7 +7805,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7605
7805
  relationWhere[targetField] = value;
7606
7806
  }
7607
7807
  const mergedWhere = this._isEmptyWhere(where) || where === void 0 ? relationWhere : { AND: [relationWhere, where] };
7608
- return await this._readIndexedRelationCount(targetTableConfig, mergedWhere, relationPath);
7808
+ return await this._readIndexedRelationCount(targetTableConfig, mergedWhere, relationPath, bucketCache);
7609
7809
  }
7610
7810
  async _loadRelationCounts(rows, relationCountConfig, targetTableEdges, tableConfig) {
7611
7811
  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 +7817,21 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7617
7817
  if (!edge) throw this._createRelationCountError(RELATION_COUNT_ERROR.FILTER_UNSUPPORTED, `with._count.${relationName} is not a relation on '${tableConfig.name}'`);
7618
7818
  const where = this._coerceRelationCountWhere(relationName, relationSelection);
7619
7819
  const relationCountExecutionCache = /* @__PURE__ */ new Map();
7820
+ const caches = {
7821
+ buckets: /* @__PURE__ */ new Map(),
7822
+ throughTargetMatches: /* @__PURE__ */ new Map()
7823
+ };
7620
7824
  const counts = await this._mapWithConcurrency(rows, async (row) => {
7621
7825
  const parentKey = this._getRelationCountParentKey(row, edge);
7622
7826
  if (parentKey === null) return 0;
7623
- const executionKey = this._buildRelationCountExecutionKey(relationName, where, parentKey);
7624
- const existing = relationCountExecutionCache.get(executionKey);
7827
+ const existing = relationCountExecutionCache.get(parentKey);
7625
7828
  if (existing) return await existing;
7626
- const pending = this._countRelationForRow(row, relationName, edge, where, tableConfig);
7627
- relationCountExecutionCache.set(executionKey, pending);
7829
+ const pending = this._countRelationForRow(row, relationName, edge, where, tableConfig, caches);
7830
+ relationCountExecutionCache.set(parentKey, pending);
7628
7831
  try {
7629
7832
  return await pending;
7630
7833
  } catch (error) {
7631
- relationCountExecutionCache.delete(executionKey);
7834
+ relationCountExecutionCache.delete(parentKey);
7632
7835
  throw error;
7633
7836
  }
7634
7837
  });
@@ -7708,6 +7911,108 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7708
7911
  }
7709
7912
  }
7710
7913
  /**
7914
+ * Junction links per parent, stopped once each parent holds `fetchLimit`
7915
+ * links whose target actually reaches the page.
7916
+ *
7917
+ * A link only contributes if its target exists and survives target RLS and
7918
+ * the relation `where` — all of which run after the junction read. Sizing the
7919
+ * read on links alone therefore under-fills: three dangling or filtered links
7920
+ * first and `{ limit: 3 }` returns nothing. So the read is refilled in rounds,
7921
+ * each round resolving only the targets it newly needs and asking again for
7922
+ * whatever the survivors did not cover.
7923
+ *
7924
+ * Rounds are the unit rather than single links because both the target fetch
7925
+ * and the relation `where` de-duplicate and batch their own reads across the
7926
+ * parents in the round.
7927
+ */
7928
+ async _readBoundedThroughLinks(params) {
7929
+ const { applyTargetFilters, edge, enforceTargetKeyCap, entries, fetchLimit, fetchTargets, targetFields, throughIndexName, throughTableConfig } = params;
7930
+ const throughTargetFields = edge.through.targetFields;
7931
+ const cursors = entries.map(([key, values]) => ({
7932
+ buffered: [],
7933
+ exhausted: false,
7934
+ iterator: this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName)[Symbol.asyncIterator](),
7935
+ key,
7936
+ links: []
7937
+ }));
7938
+ const bufferVisibleLinks = async (cursor, count) => {
7939
+ let batch = [];
7940
+ const drain = async () => {
7941
+ const visible = await this._applyRlsSelectFilter(batch, throughTableConfig);
7942
+ batch = [];
7943
+ cursor.buffered.push(...visible);
7944
+ };
7945
+ while (!cursor.exhausted && cursor.buffered.length + batch.length < count) {
7946
+ const next = await cursor.iterator.next();
7947
+ if (next.done) {
7948
+ cursor.exhausted = true;
7949
+ break;
7950
+ }
7951
+ batch.push(next.value);
7952
+ if (batch.length >= RELATION_FILTER_STREAM_CHUNK) await drain();
7953
+ }
7954
+ await drain();
7955
+ };
7956
+ /** Target key -> the surviving document, absent when it did not survive. */
7957
+ const survivorByKey = /* @__PURE__ */ new Map();
7958
+ const resolvedKeys = /* @__PURE__ */ new Set();
7959
+ const survivors = [];
7960
+ while (true) {
7961
+ const active = cursors.filter((cursor) => cursor.links.length < fetchLimit && !(cursor.exhausted && cursor.buffered.length === 0));
7962
+ if (active.length === 0) break;
7963
+ const candidatesPerCursor = await this._mapWithConcurrency(active, async (cursor) => {
7964
+ const need = fetchLimit - cursor.links.length;
7965
+ await bufferVisibleLinks(cursor, need);
7966
+ return cursor.buffered.splice(0, need);
7967
+ });
7968
+ const newKeys = /* @__PURE__ */ new Map();
7969
+ for (const candidates of candidatesPerCursor) for (const link of candidates) {
7970
+ const values = throughTargetFields.map((field) => link[field]);
7971
+ if (values.some((value) => value === null || value === void 0)) continue;
7972
+ const key = JSON.stringify(values);
7973
+ if (resolvedKeys.has(key) || newKeys.has(key)) continue;
7974
+ newKeys.set(key, values);
7975
+ }
7976
+ if (newKeys.size > 0) {
7977
+ enforceTargetKeyCap(resolvedKeys.size + newKeys.size);
7978
+ const fetched = await fetchTargets(Array.from(newKeys.entries()));
7979
+ for (const key of newKeys.keys()) resolvedKeys.add(key);
7980
+ const surviving = await applyTargetFilters(fetched.map((entry) => entry.target).filter((target) => !!target));
7981
+ for (const target of surviving) {
7982
+ const key = this._buildRelationKey(target, targetFields);
7983
+ if (!key || survivorByKey.has(key)) continue;
7984
+ survivorByKey.set(key, target);
7985
+ survivors.push(target);
7986
+ }
7987
+ }
7988
+ for (let i = 0; i < active.length; i += 1) {
7989
+ const cursor = active[i];
7990
+ for (const link of candidatesPerCursor[i]) {
7991
+ if (cursor.links.length >= fetchLimit) break;
7992
+ const key = this._buildRelationKey(link, throughTargetFields);
7993
+ if (!key || !survivorByKey.has(key)) continue;
7994
+ cursor.links.push(link);
7995
+ }
7996
+ }
7997
+ }
7998
+ const linksBySourceKey = /* @__PURE__ */ new Map();
7999
+ const usedKeys = /* @__PURE__ */ new Set();
8000
+ for (const cursor of cursors) {
8001
+ linksBySourceKey.set(cursor.key, cursor.links);
8002
+ for (const link of cursor.links) {
8003
+ const key = this._buildRelationKey(link, throughTargetFields);
8004
+ if (key) usedKeys.add(key);
8005
+ }
8006
+ }
8007
+ return {
8008
+ linksBySourceKey,
8009
+ targets: survivors.filter((target) => {
8010
+ const key = this._buildRelationKey(target, targetFields);
8011
+ return key !== null && usedKeys.has(key);
8012
+ })
8013
+ };
8014
+ }
8015
+ /**
7711
8016
  * Load many() relation (one-to-many)
7712
8017
  * Example: users.posts where posts.authorId → users.id
7713
8018
  *
@@ -7781,65 +8086,108 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7781
8086
  if (!throughTableConfig) throw new Error(`Relation '${relationName}' through table '${edge.through.table}' not found.`);
7782
8087
  const throughIndexName = findRelationIndex(throughTableConfig.table, edge.through.sourceFields, `${tableConfig.name}.${relationName}`, edge.through.table, strict, this.allowFullScan);
7783
8088
  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({
8089
+ const enforceTargetKeyCap = (keyCount) => this._enforceRelationFanOutKeyCap({
7803
8090
  tableConfig,
7804
8091
  relationName,
7805
- keyCount: targetKeyMap.size,
8092
+ keyCount,
7806
8093
  scope: "through-target"
7807
8094
  });
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]) => {
8095
+ let targetLookup = null;
8096
+ const fetchThroughTargets = async (keyEntries) => {
8097
+ if (!targetLookup) {
8098
+ const useGetById = targetFields.length === 1 && targetFields[0] === "_id";
8099
+ targetLookup = {
8100
+ useGetById,
8101
+ indexName: useGetById ? null : findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan)
8102
+ };
8103
+ }
8104
+ const { useGetById, indexName } = targetLookup;
8105
+ return await this._mapWithConcurrency(keyEntries, async ([key, values]) => {
7813
8106
  let target = null;
7814
8107
  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();
8108
+ else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName).first();
7816
8109
  return {
7817
8110
  key,
7818
8111
  target
7819
8112
  };
7820
- })).map((entry) => entry.target).filter((value) => !!value);
8113
+ });
8114
+ };
8115
+ if (orderSpecs.length === 0 && effectivePerParentLimit !== void 0) {
8116
+ const bounded = await this._readBoundedThroughLinks({
8117
+ applyTargetFilters: applyPostFetchTargetFilters,
8118
+ edge,
8119
+ enforceTargetKeyCap,
8120
+ entries,
8121
+ fetchLimit: Math.max(perParentOffset ?? 0, 0) + effectivePerParentLimit,
8122
+ fetchTargets: fetchThroughTargets,
8123
+ targetFields,
8124
+ throughIndexName,
8125
+ throughTableConfig
8126
+ });
8127
+ throughBySourceKey = bounded.linksBySourceKey;
8128
+ targets = bounded.targets;
8129
+ targetFiltersApplied = true;
8130
+ } else {
8131
+ const throughRowsPerSource = await this._mapWithConcurrency(entries, async ([key, values]) => {
8132
+ const query = this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName);
8133
+ return {
8134
+ key,
8135
+ rows: await this._applyRlsSelectFilter(await query.collect(), throughTableConfig)
8136
+ };
8137
+ });
8138
+ throughBySourceKey = /* @__PURE__ */ new Map();
8139
+ const targetKeyMap = /* @__PURE__ */ new Map();
8140
+ for (const entry of throughRowsPerSource) {
8141
+ throughBySourceKey.set(entry.key, entry.rows);
8142
+ for (const row of entry.rows) {
8143
+ const values = edge.through.targetFields.map((field) => row[field]);
8144
+ if (values.some((value) => value === null || value === void 0)) continue;
8145
+ const key = JSON.stringify(values);
8146
+ if (!targetKeyMap.has(key)) targetKeyMap.set(key, values);
8147
+ }
8148
+ }
8149
+ enforceTargetKeyCap(targetKeyMap.size);
8150
+ if (targetKeyMap.size > 0) targets = (await fetchThroughTargets(Array.from(targetKeyMap.entries()))).map((entry) => entry.target).filter((value) => !!value);
7821
8151
  }
7822
8152
  } else {
7823
- const indexName = findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
8153
+ const indexName = findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan, orderSpecs);
7824
8154
  const entries = Array.from(sourceKeyMap.entries());
7825
- const streamPostFetchTargetFilters = orderSpecs.length === 0 && hasPostFetchTargetFilter && effectivePerParentLimit !== void 0;
8155
+ const orderPushdownDirection = resolveIndexOrderPushdown({
8156
+ indexFields: indexName ? getIndexes(targetTableConfig.table).find((idx) => idx.name === indexName)?.fields ?? null : null,
8157
+ pinnedEqCount: targetFields.length,
8158
+ orderSpecs
8159
+ });
8160
+ const orderServedByIndex = orderSpecs.length === 0 || orderPushdownDirection !== null;
8161
+ const applyPushdownOrder = (query) => orderPushdownDirection ? query.order(orderPushdownDirection) : query;
8162
+ const streamPostFetchTargetFilters = orderServedByIndex && hasPostFetchTargetFilter && effectivePerParentLimit !== void 0;
7826
8163
  targetFiltersApplied = streamPostFetchTargetFilters;
7827
8164
  targets = (await this._mapWithConcurrency(entries, async ([, values]) => {
7828
8165
  const query = this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName);
7829
- if (orderSpecs.length === 0 && !hasPostFetchTargetFilter && effectivePerParentLimit !== void 0) {
8166
+ if (orderServedByIndex && !hasPostFetchTargetFilter && effectivePerParentLimit !== void 0) {
7830
8167
  const fetchLimit = (perParentOffset ?? 0) + (effectivePerParentLimit ?? 0);
7831
- return await query.take(fetchLimit);
8168
+ return await applyPushdownOrder(query).take(fetchLimit);
7832
8169
  }
7833
8170
  if (streamPostFetchTargetFilters) {
7834
8171
  const visibleTargets = [];
7835
8172
  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;
8173
+ let batch = [];
8174
+ const drain = async () => {
8175
+ if (batch.length === 0) return;
8176
+ const filtered = await applyPostFetchTargetFilters(batch);
8177
+ batch = [];
8178
+ for (const row of filtered) {
8179
+ if (visibleTargets.length >= fetchLimit) return;
8180
+ visibleTargets.push(row);
7841
8181
  }
8182
+ };
8183
+ for await (const target of applyPushdownOrder(query)) {
8184
+ batch.push(target);
8185
+ const chunk = Math.min(RELATION_FILTER_STREAM_CHUNK, fetchLimit - visibleTargets.length);
8186
+ if (batch.length < chunk) continue;
8187
+ await drain();
8188
+ if (visibleTargets.length >= fetchLimit) break;
7842
8189
  }
8190
+ if (visibleTargets.length < fetchLimit) await drain();
7843
8191
  return visibleTargets;
7844
8192
  }
7845
8193
  return await query.collect();
@@ -7847,6 +8195,19 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7847
8195
  }
7848
8196
  if (!targetFiltersApplied) targets = await applyPostFetchTargetFilters(targets);
7849
8197
  if (orderSpecs.length > 0) targets.sort((a, b) => this._compareByOrderSpecs(a, b, orderSpecs));
8198
+ if (!edge.through && (perParentOffset !== void 0 || effectivePerParentLimit !== void 0)) {
8199
+ const groupedTargets = /* @__PURE__ */ new Map();
8200
+ for (const target of targets) {
8201
+ const parentKey = this._buildRelationKey(target, targetFields);
8202
+ if (!parentKey) continue;
8203
+ const group = groupedTargets.get(parentKey);
8204
+ if (group) group.push(target);
8205
+ else groupedTargets.set(parentKey, [target]);
8206
+ }
8207
+ const trimmed = [];
8208
+ for (const children of groupedTargets.values()) for (const child of applyOffsetAndLimit(children)) trimmed.push(child);
8209
+ targets = trimmed;
8210
+ }
7850
8211
  if (relationConfig && typeof relationConfig === "object" && "with" in relationConfig) {
7851
8212
  const targetTableEdges = this._getTargetTableEdges(edge.targetTable);
7852
8213
  await this._loadRelations(targets, relationConfig.with, depth + 1, maxDepth, targetTableEdges, targetTableConfig);
@@ -7891,7 +8252,6 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
7891
8252
  if (!byParentKey.has(parentKey)) byParentKey.set(parentKey, []);
7892
8253
  byParentKey.get(parentKey).push(mappedTarget);
7893
8254
  }
7894
- if (perParentOffset !== void 0 || effectivePerParentLimit !== void 0) for (const [parentKey, children] of byParentKey.entries()) byParentKey.set(parentKey, applyOffsetAndLimit(children));
7895
8255
  for (const row of rows) {
7896
8256
  const rowKey = this._buildRelationKey(row, sourceFields);
7897
8257
  row[relationName] = rowKey ? byParentKey.get(rowKey) ?? [] : [];
@@ -9531,6 +9891,38 @@ var ConvexUpdateBuilder = class extends QueryPromise {
9531
9891
 
9532
9892
  //#endregion
9533
9893
  //#region src/orm/database.ts
9894
+ /**
9895
+ * `createDatabase` runs on every Convex query and mutation entry, but the
9896
+ * foreign-key graph and the per-table edge partition are pure functions of the
9897
+ * schema and edge metadata, both fixed for the process lifetime. Caching them
9898
+ * on the identity of those objects keeps the per-request work proportional to
9899
+ * the table count instead of `tables x edges`.
9900
+ *
9901
+ * Both caches are only sound because their inputs are module-level immutables:
9902
+ * nothing in the ORM mutates a schema or an edge list after construction.
9903
+ */
9904
+ const foreignKeyGraphCache = /* @__PURE__ */ new WeakMap();
9905
+ const edgesBySourceTableCache = /* @__PURE__ */ new WeakMap();
9906
+ const NO_EDGES = [];
9907
+ function getForeignKeyGraph(schema) {
9908
+ const cached = foreignKeyGraphCache.get(schema);
9909
+ if (cached) return cached;
9910
+ const graph = buildForeignKeyGraph(schema);
9911
+ foreignKeyGraphCache.set(schema, graph);
9912
+ return graph;
9913
+ }
9914
+ function getEdgesBySourceTable(edgeMetadata) {
9915
+ const cached = edgesBySourceTableCache.get(edgeMetadata);
9916
+ if (cached) return cached;
9917
+ const grouped = /* @__PURE__ */ new Map();
9918
+ for (const edge of edgeMetadata) {
9919
+ const existing = grouped.get(edge.sourceTable);
9920
+ if (existing) existing.push(edge);
9921
+ else grouped.set(edge.sourceTable, [edge]);
9922
+ }
9923
+ edgesBySourceTableCache.set(edgeMetadata, grouped);
9924
+ return grouped;
9925
+ }
9534
9926
  function createDatabase(db, schema, edgeMetadata, options) {
9535
9927
  const schemaOptions = schema[OrmSchemaOptions];
9536
9928
  const strict = schemaOptions?.strict ?? true;
@@ -9541,7 +9933,7 @@ function createDatabase(db, schema, edgeMetadata, options) {
9541
9933
  scheduledMutationBatch: options?.scheduledMutationBatch
9542
9934
  });
9543
9935
  const ormContext = {
9544
- foreignKeyGraph: buildForeignKeyGraph(schema),
9936
+ foreignKeyGraph: getForeignKeyGraph(schema),
9545
9937
  schema,
9546
9938
  edgeMetadata,
9547
9939
  relationLoading: options?.relationLoading,
@@ -9555,7 +9947,8 @@ function createDatabase(db, schema, edgeMetadata, options) {
9555
9947
  };
9556
9948
  const baseDb = Object.assign(Object.create(db), { [OrmContext]: ormContext });
9557
9949
  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);
9950
+ const edgesBySourceTable = getEdgesBySourceTable(edgeMetadata);
9951
+ 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
9952
  const isWriter = typeof db.insert === "function" && typeof db.patch === "function";
9560
9953
  const isConvexTable = (value) => !!value && typeof value === "object" && value[Brand] === "ConvexTable";
9561
9954
  const insert = (table) => {
@@ -9600,14 +9993,18 @@ function createDatabase(db, schema, edgeMetadata, options) {
9600
9993
  return built;
9601
9994
  };
9602
9995
  const table = buildDatabase(options?.rls);
9603
- const skipRulesTable = buildDatabase({
9604
- ...options?.rls ?? {},
9605
- mode: "skip"
9996
+ let skipRulesTable;
9997
+ return Object.defineProperty({ ...table }, "skipRules", {
9998
+ enumerable: true,
9999
+ configurable: true,
10000
+ get() {
10001
+ skipRulesTable ??= buildDatabase({
10002
+ ...options?.rls ?? {},
10003
+ mode: "skip"
10004
+ });
10005
+ return skipRulesTable;
10006
+ }
9606
10007
  });
9607
- return {
9608
- ...table,
9609
- skipRules: skipRulesTable
9610
- };
9611
10008
  }
9612
10009
 
9613
10010
  //#endregion
@@ -11176,8 +11573,7 @@ function resolveOrmSchemaConfig(schemaInput) {
11176
11573
  triggers: getSchemaTriggers(schemaInput)
11177
11574
  };
11178
11575
  }
11179
- function createDbFactory(schema, dbLifecycle, ormFunctions) {
11180
- const edgeMetadata = extractRelationsConfig(schema);
11576
+ function createDbFactory(schema, edgeMetadata, dbLifecycle, ormFunctions) {
11181
11577
  return ((source, options) => {
11182
11578
  const ctxSource = isOrmCtx(source) ? source : void 0;
11183
11579
  const rawDb = ctxSource ? ctxSource.db : source;
@@ -11203,7 +11599,7 @@ function createOrm(config) {
11203
11599
  const { schema: resolvedSchema, triggers } = resolveOrmSchemaConfig(config.schema);
11204
11600
  const dbLifecycle = createOrmDbLifecycle(resolvedSchema, triggers);
11205
11601
  const edgeMetadata = extractRelationsConfig(resolvedSchema);
11206
- const db = createDbFactory(resolvedSchema, dbLifecycle, config.ormFunctions);
11602
+ const db = createDbFactory(resolvedSchema, edgeMetadata, dbLifecycle, config.ormFunctions);
11207
11603
  const withContext = (ctx, options) => {
11208
11604
  const lifecycleCtx = { ...ctx };
11209
11605
  const wrappedCtx = dbLifecycle.wrapDB(lifecycleCtx);