kitcn 0.31.1 → 0.32.1

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
@@ -3,9 +3,9 @@ import { d as boolean, i as detectMigrationDrift, l as migrationExtension, n as
3
3
  import { a as pretendRequired, i as pretend, n as deprecated } from "../validators-CIoUYCqO.js";
4
4
  import { o as id } from "../schema-BFP_awgP.js";
5
5
  import { a as OrmSchemaDefinition, c as OrmSchemaExtensionTriggers, d as OrmSchemaRelations, f as OrmSchemaTriggers, g as TablePolymorphic, h as TableName, i as OrmContext, l as OrmSchemaExtensions, n as Columns, o as OrmSchemaExtensionRelations, p as RlsPolicies, r as EnableRLS, s as OrmSchemaExtensionTables, t as Brand, u as OrmSchemaOptions } from "../symbols-DDNAddkd.js";
6
- import { A as inArray, B as notBetween, C as eq, D as gt, E as filterValuesEqual, F as lt, G as startsWith, H as notInArray, I as lte, L as matchLikePattern, M as isNotNull, N as isNull, O as gte, P as like, R as ne, S as endsWith, T as filterValueInList, U as notLike, V as notIlike, W as or, _ as arrayContains, a as findVectorIndexByName, b as column, c as getRankIndexes, d as INTERNAL_CREATION_TIME_FIELD, f as PUBLIC_CREATED_AT_FIELD, g as arrayContained, h as and, i as findSearchIndexByName, j as isFieldReference, k as ilike, l as resolveIndexOrderPushdown, m as usesSystemCreatedAtAlias, n as findIndexForColumns, o as getAggregateIndexes, r as findRelationIndex, s as getIndexes, t as findExactIndexForColumns, u as CREATED_AT_MIGRATION_MESSAGE, v as arrayOverlaps, w as fieldRef, x as contains, y as between, z as not } from "../index-utils-C1DyktHe.js";
7
- import { a as indexKeyWithinBounds, c as streamIndexRange, i as getIndexFields, l as isUnsetToken, n as EmptyStream, o as mergedStream, r as QueryStream, s as stream, t as getByIdWithOrmQueryFallback, u as unsetToken } from "../query-context-DdSg3fuk.js";
8
- import { $ as patchReferencingRows, A as enforcePolymorphicWrite, B as getForeignKeys, C as collectPrimaryIdLookupRows, D as encodeUndefinedDeep, E as deserializeFilterExpression, F as evaluateCheckConstraintTriState, G as getTableColumns$2, H as getMutationCollectionLimits, I as evaluateFilter, J as getUniqueIndexes, K as getTableDeleteConfig, L as extractPrimaryIdLookup, M as ensureDefaultColumns, N as ensureNonNullValues, O as enforceCheckConstraints, P as ensureNullableColumns, Q as normalizeTemporalComparableValue, R as getChecks, S as collectMutationRowsBounded, T as decodeUndefinedDeep, U as getMutationExecutionMode, V as getMutationAsyncDelayMs, W as getOrmContext, X as hydrateDateFieldsForRead, Y as hardDeleteRow, Z as normalizeDateFieldsForWrite, _ as applyDefaults, at as splitReturningSelection, b as buildForeignKeyGraph, c as getAggregateIndexDefinitions, ct as toConvexFilter, d as COUNT_ERROR, dt as markLifecycleHookedTables, et as resolveOrmRuntimeDefaults, f as createAggregateError, ft as compileConvexFilter, g as ensureCountAllowedForRls, h as ensureAggregateAllowedForRls, ht as mapWithConcurrency, it as softDeleteRow, j as enforceUniqueIndexes, k as enforceForeignKeys, l as getRankIndexDefinitions, lt as unsetFieldsOf, mt as isConvexEnforceableFilter, nt as selectReturningRowWithHydration, o as aggregateExtension, ot as stripUnsetFields, p as createCountError, pt as convexAnd, q as getTableName, rt as serializeFilterExpression, st as takeRowsWithinByteBudget, tt as returningSelectionReadsCreationTime, u as AGGREGATE_ERROR, ut as hasLifecycleHooks, v as applyIncomingForeignKeyActionsOnDelete, w as createForeignKeyProbeMemo, x as canUsePrimaryIdLookupCursor, y as applyIncomingForeignKeyActionsOnUpdate, z as getColumnName$1 } from "../schema-DbPcDW-N.js";
6
+ import { A as inArray, B as notBetween, C as eq, D as gt, E as filterValuesEqual, F as lt, G as startsWith, H as notInArray, I as lte, L as matchLikePattern, M as isNotNull, N as isNull, O as gte, P as like, R as ne, S as endsWith, T as filterValueInList, U as notLike, V as notIlike, W as or, _ as arrayContains, a as findVectorIndexByName, b as column, c as getRankIndexes, d as INTERNAL_CREATION_TIME_FIELD, f as PUBLIC_CREATED_AT_FIELD, g as arrayContained, h as and, i as findSearchIndexByName, j as isFieldReference, k as ilike, l as resolveIndexOrderPushdown, m as usesSystemCreatedAtAlias, n as findIndexForColumns, o as getAggregateIndexes, r as findRelationIndex, s as getIndexes, t as findExactIndexForColumns, u as CREATED_AT_MIGRATION_MESSAGE, v as arrayOverlaps, w as fieldRef, x as contains, y as between, z as not } from "../index-utils-DvK7P6Q1.js";
7
+ import { a as indexKeyWithinBounds, c as streamIndexRange, i as getIndexFields, l as isUnsetToken, n as EmptyStream, o as mergedStream, r as QueryStream, s as stream, t as getByIdWithOrmQueryFallback, u as unsetToken } from "../query-context-D4z1CnDH.js";
8
+ import { $ as patchReferencingRows, A as enforcePolymorphicWrite, B as getForeignKeys, C as collectPrimaryIdLookupRows, D as encodeUndefinedDeep, E as deserializeFilterExpression, F as evaluateCheckConstraintTriState, G as getTableColumns$2, H as getMutationCollectionLimits, I as evaluateFilter, J as getUniqueIndexes, K as getTableDeleteConfig, L as extractPrimaryIdLookup, M as ensureDefaultColumns, N as ensureNonNullValues, O as enforceCheckConstraints, P as ensureNullableColumns, Q as normalizeTemporalComparableValue, R as getChecks, S as collectMutationRowsBounded, T as decodeUndefinedDeep, U as getMutationExecutionMode, V as getMutationAsyncDelayMs, W as getOrmContext, X as hydrateDateFieldsForRead, Y as hardDeleteRow, Z as normalizeDateFieldsForWrite, _ as applyDefaults, at as splitReturningSelection, b as buildForeignKeyGraph, c as getAggregateIndexDefinitions, ct as toConvexFilter, d as COUNT_ERROR, dt as markLifecycleHookedTables, et as resolveOrmRuntimeDefaults, f as createAggregateError, ft as compileConvexFilter, g as ensureCountAllowedForRls, h as ensureAggregateAllowedForRls, ht as mapWithConcurrency, it as softDeleteRow, j as enforceUniqueIndexes, k as enforceForeignKeys, l as getRankIndexDefinitions, lt as unsetFieldsOf, mt as isConvexEnforceableFilter, nt as selectReturningRowWithHydration, o as aggregateExtension, ot as stripUnsetFields, p as createCountError, pt as convexAnd, q as getTableName, rt as serializeFilterExpression, st as takeRowsWithinByteBudget, tt as returningSelectionReadsCreationTime, u as AGGREGATE_ERROR, ut as hasLifecycleHooks, v as applyIncomingForeignKeyActionsOnDelete, w as createForeignKeyProbeMemo, x as canUsePrimaryIdLookupCursor, y as applyIncomingForeignKeyActionsOnUpdate, z as getColumnName$1 } from "../schema-Bh7AmJwY.js";
9
9
  import { t as defineSchemaExtension } from "../extensions-Bp0XHanR.js";
10
10
  import { compareValues, convexToJson, jsonToConvex, v } from "convex/values";
11
11
  import { defineSchema as defineSchema$1, internalActionGeneric, internalMutationGeneric, internalQueryGeneric } from "convex/server";
@@ -703,11 +703,15 @@ async function filterSelectRows(options) {
703
703
  */
704
704
  const INDEX_ORDER_BONUS = 30;
705
705
  /**
706
- * Widest `in` list still worth turning into an index union when it is the only
707
- * indexable term. Each value becomes its own index range, so past this width
708
- * the fan-out costs more than the single scan it would replace.
706
+ * Widest index union worth opening. Each probe becomes its own index range, and
707
+ * a streamed union holds every one of them open at once, so past this width the
708
+ * fan-out costs more than the single scan it would replace.
709
+ *
710
+ * Owns both ends of that decision: whether to promote an `in` inside an AND to
711
+ * a union here, and whether the executor may read a compiled union as a merged
712
+ * stream instead of a bounded scan.
709
713
  */
710
- const MAX_PROMOTED_PROBES = 64;
714
+ const MAX_INDEX_UNION_PROBES = 64;
711
715
  var WhereClauseCompiler = class {
712
716
  /**
713
717
  * Requested sort fields for the compile in flight. Index choice has to see
@@ -785,7 +789,7 @@ var WhereClauseCompiler = class {
785
789
  for (const term of terms) {
786
790
  if (term.type !== "binary" || term.operator !== "inArray") continue;
787
791
  const probePlan = this.tryCompileInArray(term);
788
- if (!probePlan || probePlan.probeFilters.length > MAX_PROMOTED_PROBES) continue;
792
+ if (!probePlan || probePlan.probeFilters.length > MAX_INDEX_UNION_PROBES) continue;
789
793
  return {
790
794
  ...probePlan,
791
795
  postFilters: [expression]
@@ -976,9 +980,16 @@ var WhereClauseCompiler = class {
976
980
  findLeadingIndex(fieldName) {
977
981
  const candidates = this.availableIndexes.filter((index) => index.indexFields[0] === fieldName).sort((a, b) => a.indexFields.length - b.indexFields.length);
978
982
  if (this.orderFields.length > 0) {
979
- const orderField = this.orderFields[0];
980
- const serving = candidates.find((index) => index.indexFields.length > 1 && index.indexFields[1] === orderField);
981
- if (serving) return serving;
983
+ let best = null;
984
+ let bestServed = 0;
985
+ for (const index of candidates) {
986
+ const served = this.servedOrderFieldCount(index.indexFields, 1);
987
+ if (served > bestServed) {
988
+ best = index;
989
+ bestServed = served;
990
+ }
991
+ }
992
+ if (best) return best;
982
993
  }
983
994
  return candidates[0] ?? null;
984
995
  }
@@ -1155,10 +1166,42 @@ var WhereClauseCompiler = class {
1155
1166
  * Reward an index that also supplies the requested order. Large enough to
1156
1167
  * clear the 25-point exact-over-prefix premium, so `(orgId, createdAt)` beats
1157
1168
  * `(orgId)` for `where { orgId } orderBy { createdAt }`.
1169
+ *
1170
+ * The reward grows with how much of the sort the index carries, so
1171
+ * `(orgId, createdAt, title)` beats `(orgId, createdAt)` for a two-field
1172
+ * sort: only the longer one lets the scan produce the whole tuple, and
1173
+ * picking the shorter one costs the entire read bound. The extra point per
1174
+ * field is deliberately small — order coverage breaks ties between indexes
1175
+ * that already serve the filter equally well, it does not outrank filtering.
1158
1176
  */
1159
1177
  indexOrderBonus(indexFields, pinnedLength) {
1160
1178
  if (this.orderFields.length === 0 || pinnedLength >= indexFields.length) return 0;
1161
- return indexFields[pinnedLength] === this.orderFields[0] ? INDEX_ORDER_BONUS : 0;
1179
+ const served = this.servedOrderFieldCount(indexFields, pinnedLength);
1180
+ return served === 0 ? 0 : INDEX_ORDER_BONUS + (served - 1);
1181
+ }
1182
+ /**
1183
+ * How many moving sort fields this index would produce for free. Sort fields
1184
+ * inside the pinned prefix are constant and can appear anywhere in the
1185
+ * request; every other field must match the remaining index keys in order.
1186
+ */
1187
+ servedOrderFieldCount(indexFields, pinnedLength) {
1188
+ const pinnedFields = new Set(indexFields.slice(0, pinnedLength));
1189
+ let indexOffset = pinnedLength;
1190
+ let orderOffset = 0;
1191
+ let served = 0;
1192
+ while (orderOffset < this.orderFields.length) {
1193
+ const orderField = this.orderFields[orderOffset];
1194
+ if (pinnedFields.has(orderField)) {
1195
+ orderOffset += 1;
1196
+ continue;
1197
+ }
1198
+ if (indexFields[indexOffset] !== orderField) break;
1199
+ served += 1;
1200
+ indexOffset += 1;
1201
+ orderOffset += 1;
1202
+ }
1203
+ if (this.orderFields.length > 1 && orderOffset === this.orderFields.length && indexOffset < indexFields.length) return 0;
1204
+ return served;
1162
1205
  }
1163
1206
  /**
1164
1207
  * Check if referenced fields exactly match index fields in order
@@ -1279,6 +1322,20 @@ const DEFAULT_RELATION_FAN_OUT_MAX_KEYS = 1e3;
1279
1322
  * satisfies the limit.
1280
1323
  */
1281
1324
  const RELATION_FILTER_STREAM_CHUNK = 32;
1325
+ /**
1326
+ * Marks a relation config that only exists to answer "does a matching child
1327
+ * exist?" for a `where` clause. The lowered load may stop at the first
1328
+ * surviving child, but it must not widen the window it walks to find one --
1329
+ * that window is what decides the answer, so changing it would change results
1330
+ * rather than just their cost.
1331
+ *
1332
+ * A symbol, not a field: the emitted object is fed to the same relation loader
1333
+ * that serves a caller's `with`, and nothing a caller can write should be able
1334
+ * to claim this.
1335
+ */
1336
+ const RELATION_EXISTENCE_PROBE = Symbol("kitcn.relationExistenceProbe");
1337
+ /** Lowering a subtree with this set emits no existence probes anywhere in it. */
1338
+ const NO_PROBE_RELATIONS = /* @__PURE__ */ new Set();
1282
1339
  const DEFAULT_AGGREGATE_CARTESIAN_MAX_KEYS = 4096;
1283
1340
  const DEFAULT_AGGREGATE_WORK_BUDGET = 16384;
1284
1341
  const PUBLIC_ID_FIELD = "id";
@@ -1290,6 +1347,22 @@ const RELATION_COUNT_ERROR = {
1290
1347
  };
1291
1348
  const RELATION_DEPTH_ERROR = "RELATION_DEPTH_EXCEEDED";
1292
1349
  /**
1350
+ * Whether a compiled column validator admits an absent or null value.
1351
+ *
1352
+ * `v.optional(...)` is how a non-`.notNull()` column is emitted, and absent is
1353
+ * a legal stored value for every document written before the column existed, so
1354
+ * both halves matter. Unrecognized shapes answer true: the only caller uses
1355
+ * this to decide whether it may skip a sort, and guessing "never null" there
1356
+ * silently reorders rows.
1357
+ */
1358
+ const validatorAcceptsMissingOrNull = (validator) => {
1359
+ if (!validator) return true;
1360
+ if (validator.isOptional === "optional") return true;
1361
+ if (validator.kind === "null" || validator.kind === "any") return true;
1362
+ if (validator.kind === "union") return (validator.members ?? []).some(validatorAcceptsMissingOrNull);
1363
+ return false;
1364
+ };
1365
+ /**
1293
1366
  * How many levels of `with` the relation loader will follow. A `with` config is
1294
1367
  * a finite object the caller wrote, so its own nesting is the real bound; this
1295
1368
  * ceiling only stops a self-referential config from recursing forever. Reaching
@@ -1712,18 +1785,41 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
1712
1785
  return (Array.isArray(orderBy) ? orderBy : [orderBy]).filter((item) => item !== void 0).map((item) => this._normalizeOrderByValue(item));
1713
1786
  }
1714
1787
  _orderBySpecs(orderBy, tableConfig = this.tableConfig) {
1788
+ const withNullability = (spec) => ({
1789
+ ...spec,
1790
+ nullable: this._isNullableOrderField(spec.field, tableConfig)
1791
+ });
1715
1792
  if (orderBy && typeof orderBy === "object" && !Array.isArray(orderBy) && !this._isOrderByClause(orderBy) && !this._isColumnBuilder(orderBy)) return Object.entries(orderBy).map(([field, direction]) => ({
1716
1793
  field: this._normalizePublicFieldName(field, tableConfig),
1717
1794
  direction
1718
- })).filter((entry) => entry.direction === "asc" || entry.direction === "desc").map((entry) => ({
1795
+ })).filter((entry) => entry.direction === "asc" || entry.direction === "desc").map((entry) => withNullability({
1719
1796
  field: entry.field,
1720
1797
  direction: entry.direction
1721
1798
  }));
1722
- return this._normalizeOrderBy(orderBy).map((clause) => ({
1799
+ return this._normalizeOrderBy(orderBy).map((clause) => withNullability({
1723
1800
  field: clause.column.columnName,
1724
1801
  direction: clause.direction
1725
1802
  }));
1726
1803
  }
1804
+ /**
1805
+ * Whether rows can be missing this sort column or hold null in it.
1806
+ *
1807
+ * Only the declared validator can answer: a column that is not `.notNull()`
1808
+ * compiles to `v.optional(v.union(v.null(), T))`, so both absent and null are
1809
+ * legal stored values — and `timestamp().notNull().defaultNow()` on
1810
+ * `createdAt` is deliberately emitted as optional for migration safety, so
1811
+ * reading `config.notNull` alone would answer wrong for it. Anything the
1812
+ * lookup cannot resolve counts as nullable, which only costs a pushdown.
1813
+ */
1814
+ _isNullableOrderField(field, tableConfig) {
1815
+ if (field === INTERNAL_ID_FIELD || field === INTERNAL_CREATION_TIME_FIELD) return false;
1816
+ try {
1817
+ const builder = this._getColumns(tableConfig)[field];
1818
+ return validatorAcceptsMissingOrNull(builder?.build?.());
1819
+ } catch {
1820
+ return true;
1821
+ }
1822
+ }
1727
1823
  _resolveNonPaginatedLimit(config) {
1728
1824
  const explicitLimit = config.limit;
1729
1825
  const defaultLimit = getOrmContext(this.db)?.resolvedDefaults?.defaultLimit ?? this.tableConfig.defaults?.defaultLimit;
@@ -1744,8 +1840,8 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
1744
1840
  return 1;
1745
1841
  }
1746
1842
  if (bVal === null || bVal === void 0) return -1;
1747
- if (aVal < bVal) return order.direction === "asc" ? -1 : 1;
1748
- if (aVal > bVal) return order.direction === "asc" ? 1 : -1;
1843
+ const comparison = compareValues(aVal, bVal);
1844
+ if (comparison !== 0) return order.direction === "asc" ? comparison : -comparison;
1749
1845
  }
1750
1846
  return 0;
1751
1847
  }
@@ -2309,7 +2405,47 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2309
2405
  }
2310
2406
  }
2311
2407
  }
2408
+ /**
2409
+ * Relation keys the filter tree mentions exactly once, across every OR/AND/NOT
2410
+ * branch at this table level.
2411
+ *
2412
+ * `_mergeWithConfig` collapses the whole tree into one load per relation key,
2413
+ * so a key two branches disagree about has to be loaded the way both branches
2414
+ * can read. Only a key with a single occurrence has one predicate to satisfy,
2415
+ * and only then can that predicate be pushed into the read plan.
2416
+ *
2417
+ * Relation values are not descended into: they belong to the target table and
2418
+ * get their own count when that level is lowered.
2419
+ */
2420
+ _collectSingleOccurrenceRelations(filter, tableConfig) {
2421
+ const counts = /* @__PURE__ */ new Map();
2422
+ const walk = (node) => {
2423
+ if (!this._isRecord(node)) return;
2424
+ for (const [key, value] of Object.entries(node)) {
2425
+ if (value === void 0) continue;
2426
+ if (key === "OR" || key === "AND") {
2427
+ if (!Array.isArray(value)) continue;
2428
+ for (const sub of value) walk(sub);
2429
+ continue;
2430
+ }
2431
+ if (key === "NOT") {
2432
+ walk(value);
2433
+ continue;
2434
+ }
2435
+ if (!tableConfig.relations[key]) continue;
2436
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2437
+ }
2438
+ };
2439
+ walk(filter);
2440
+ const single = /* @__PURE__ */ new Set();
2441
+ for (const [key, count] of counts) if (count === 1) single.add(key);
2442
+ return single;
2443
+ }
2312
2444
  _buildFilterWithConfig(filter, tableConfig) {
2445
+ if (!this._isRecord(filter)) return {};
2446
+ return this._buildFilterWithConfigForLevel(filter, tableConfig, this._collectSingleOccurrenceRelations(filter, tableConfig));
2447
+ }
2448
+ _buildFilterWithConfigForLevel(filter, tableConfig, probeEligible) {
2313
2449
  if (!this._isRecord(filter)) return {};
2314
2450
  const result = {};
2315
2451
  const entries = Object.entries(filter);
@@ -2319,27 +2455,36 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2319
2455
  if (key === "OR" || key === "AND") {
2320
2456
  if (!Array.isArray(value) || value.length === 0) continue;
2321
2457
  for (const sub of value) {
2322
- const nested = this._buildFilterWithConfig(sub, tableConfig);
2458
+ const nested = this._buildFilterWithConfigForLevel(sub, tableConfig, probeEligible);
2323
2459
  this._mergeWithConfig(result, nested);
2324
2460
  }
2325
2461
  continue;
2326
2462
  }
2327
2463
  if (key === "NOT") {
2328
- const nested = this._buildFilterWithConfig(value, tableConfig);
2464
+ const nested = this._buildFilterWithConfigForLevel(value, tableConfig, probeEligible);
2329
2465
  this._mergeWithConfig(result, nested);
2330
2466
  continue;
2331
2467
  }
2332
2468
  this._assertNoLegacyPublicFieldName(key);
2333
2469
  const relation = tableConfig.relations[key];
2334
2470
  if (!relation) continue;
2471
+ const probe = relation.relationType !== "one" && probeEligible.has(key) ? { [RELATION_EXISTENCE_PROBE]: true } : void 0;
2335
2472
  if (typeof value === "boolean") {
2336
- result[key] = true;
2473
+ result[key] = probe ?? true;
2337
2474
  continue;
2338
2475
  }
2339
2476
  const targetTableConfig = this._getTableConfigByDbName(relation.targetTableName);
2340
2477
  if (!targetTableConfig) continue;
2341
- const nested = this._buildFilterWithConfig(value, targetTableConfig);
2342
- result[key] = Object.keys(nested).length > 0 ? { with: nested } : true;
2478
+ const nested = this._buildFilterWithConfigForLevel(value, targetTableConfig, probe ? this._collectSingleOccurrenceRelations(value, targetTableConfig) : NO_PROBE_RELATIONS);
2479
+ const hasNested = Object.keys(nested).length > 0;
2480
+ if (!probe) {
2481
+ result[key] = hasNested ? { with: nested } : true;
2482
+ continue;
2483
+ }
2484
+ const emitted = { ...probe };
2485
+ if (Object.keys(value).length > 0) emitted.where = value;
2486
+ if (hasNested) emitted.with = nested;
2487
+ result[key] = emitted;
2343
2488
  }
2344
2489
  return result;
2345
2490
  }
@@ -2675,28 +2820,99 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2675
2820
  _findStreamOrderIndex(field) {
2676
2821
  return getIndexes(this.tableConfig.table).find((index) => index.fields[0] === field);
2677
2822
  }
2678
- _buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex) {
2679
- const schemaDefinition = this._getSchemaDefinitionOrThrow();
2680
- let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
2681
- const primaryOrder = queryConfig.order?.[0];
2682
- const primaryOrderDirection = primaryOrder?.direction ?? "asc";
2683
- if (queryConfig.index) streamQuery = streamQuery.withIndex(queryConfig.index.name, (q) => {
2823
+ /**
2824
+ * The compiled index union, as one ordered stream.
2825
+ *
2826
+ * Each probe is its own index range, so the union is only usable where the
2827
+ * merged order is the order the read has to produce. `mergedStream` orders by
2828
+ * a *suffix* of the index key, and a suffix may only drop key components the
2829
+ * probes all pin to a single value — so the requested field has to sit inside
2830
+ * the pinned run or immediately after it. Returns null when it does not, and
2831
+ * the caller falls back to the plan's plain index range or a bounded scan.
2832
+ */
2833
+ _buildProbeUnionStream(params) {
2834
+ const { probeFilters } = params;
2835
+ if (probeFilters.length === 0 || probeFilters.length > MAX_INDEX_UNION_PROBES) return null;
2836
+ const streamIndexFields = getIndexFields(this.tableConfig.name, params.indexName, params.schemaDefinition);
2837
+ const pinned = this._indexEqPrefixCount({ probeFilters });
2838
+ let mergeOffset = -1;
2839
+ for (let i = 0; i <= pinned && i < streamIndexFields.length; i += 1) if (streamIndexFields[i] === params.orderField) {
2840
+ mergeOffset = i;
2841
+ break;
2842
+ }
2843
+ if (mergeOffset === -1) return null;
2844
+ return mergedStream(probeFilters.map((filters) => stream(this.db, params.schemaDefinition).query(this.tableConfig.name).withIndex(params.indexName, (q) => {
2845
+ let indexQuery = q;
2846
+ for (const filter of filters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
2847
+ return indexQuery;
2848
+ }).order(params.order)), streamIndexFields.slice(mergeOffset));
2849
+ }
2850
+ /**
2851
+ * The read the compiled plan describes, as a stream, with nothing filtered
2852
+ * yet.
2853
+ *
2854
+ * Precedence: the compiled index union, then the compiled index range, then
2855
+ * the caller's pinned `.withIndex(...)`, then an index that supplies the
2856
+ * requested order, then a full scan. The first two rungs can only ever refine
2857
+ * what the caller pinned — `_toConvexQuery` discards a compiled plan that
2858
+ * would displace a caller's index or its bounds before it gets here.
2859
+ *
2860
+ * `probeUnion` tells the caller the read is bounded by index ranges rather
2861
+ * than by scan length, which is what makes a scan budget unnecessary. A
2862
+ * rejected union stays unanchored; its original predicate remains in
2863
+ * `queryConfig.postFilters` for the caller to apply while pulling the scan.
2864
+ */
2865
+ _buildPlanStream(params) {
2866
+ const { queryConfig, configuredIndex, order, primaryOrder } = params;
2867
+ const probeIndex = queryConfig.probeFilters.length > 0 ? queryConfig.index : void 0;
2868
+ const hasProbeUnionPlan = !!probeIndex;
2869
+ if (hasProbeUnionPlan) {
2870
+ const probeUnion = this._buildProbeUnionStream({
2871
+ schemaDefinition: params.schemaDefinition,
2872
+ indexName: probeIndex.name,
2873
+ probeFilters: queryConfig.probeFilters,
2874
+ order,
2875
+ orderField: primaryOrder?.field ?? probeIndex.fields[0]
2876
+ });
2877
+ if (probeUnion) return {
2878
+ stream: probeUnion,
2879
+ probeUnion: true
2880
+ };
2881
+ }
2882
+ let streamQuery = stream(this.db, params.schemaDefinition).query(this.tableConfig.name);
2883
+ if (queryConfig.index && !hasProbeUnionPlan) streamQuery = streamQuery.withIndex(queryConfig.index.name, (q) => {
2684
2884
  let indexQuery = q;
2685
2885
  for (const filter of queryConfig.index.filters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
2686
2886
  return indexQuery;
2687
2887
  });
2688
- else if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
2689
- else if (primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD) {
2690
- const orderIndex = this._findStreamOrderIndex(primaryOrder.field);
2691
- if (orderIndex) streamQuery = streamQuery.withIndex(orderIndex.name, (q) => q);
2692
- }
2693
- streamQuery = streamQuery.order(primaryOrderDirection);
2888
+ else if (!hasProbeUnionPlan && configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
2889
+ else if (!hasProbeUnionPlan && params.orderIndexName) streamQuery = streamQuery.withIndex(params.orderIndexName, (q) => q);
2890
+ return {
2891
+ stream: streamQuery.order(order),
2892
+ probeUnion: false
2893
+ };
2894
+ }
2895
+ _buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex, fallbackOrder) {
2896
+ const schemaDefinition = this._getSchemaDefinitionOrThrow();
2897
+ const primaryOrder = queryConfig.order?.[0];
2898
+ const planStream = this._buildPlanStream({
2899
+ queryConfig,
2900
+ configuredIndex,
2901
+ order: primaryOrder?.direction ?? fallbackOrder,
2902
+ primaryOrder,
2903
+ orderIndexName: primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD ? this._findStreamOrderIndex(primaryOrder.field)?.name ?? null : null,
2904
+ schemaDefinition
2905
+ });
2906
+ let streamQuery = planStream.stream;
2694
2907
  if (queryConfig.postFilters.length > 0 || wherePredicate) streamQuery = streamQuery.filterWith(async (row) => {
2695
2908
  for (const filter of queryConfig.postFilters) if (!this._evaluatePostFetchFilter(row, filter)) return false;
2696
2909
  if (wherePredicate) return await wherePredicate(row);
2697
2910
  return true;
2698
2911
  });
2699
- return streamQuery;
2912
+ return {
2913
+ stream: streamQuery,
2914
+ probeUnion: planStream.probeUnion
2915
+ };
2700
2916
  }
2701
2917
  /**
2702
2918
  * Stream equivalent of the `db.query(...)` chain, used when a post-fetch
@@ -2714,16 +2930,15 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2714
2930
  _buildResidualFilterStream(params) {
2715
2931
  const schemaDefinition = this.schema[OrmSchemaDefinition];
2716
2932
  if (!schemaDefinition) return null;
2717
- const { queryConfig, configuredIndex, membershipFilter, orderIndexName, primaryOrder, fallbackOrder } = params;
2718
- let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
2719
- if (queryConfig.index) streamQuery = streamQuery.withIndex(queryConfig.index.name, (q) => {
2720
- let indexQuery = q;
2721
- for (const filter of queryConfig.index.filters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
2722
- return indexQuery;
2723
- });
2724
- else if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
2725
- else if (orderIndexName) streamQuery = streamQuery.withIndex(orderIndexName, (q) => q);
2726
- streamQuery = streamQuery.order(primaryOrder ? primaryOrder.direction : fallbackOrder);
2933
+ const { queryConfig, configuredIndex, membershipFilter, orderIndexName, primaryOrder, pushdownDirection, fallbackOrder } = params;
2934
+ let streamQuery = this._buildPlanStream({
2935
+ queryConfig,
2936
+ configuredIndex,
2937
+ order: pushdownDirection ?? (primaryOrder ? primaryOrder.direction : fallbackOrder),
2938
+ primaryOrder,
2939
+ orderIndexName,
2940
+ schemaDefinition
2941
+ }).stream;
2727
2942
  streamQuery = streamQuery.filterWith(async (row) => {
2728
2943
  for (const filter of queryConfig.postFilters) if (!this._evaluatePostFetchFilter(row, filter)) return false;
2729
2944
  return membershipFilter ? await membershipFilter(row) : true;
@@ -3876,7 +4091,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
3876
4091
  if (endCursor !== void 0 && searchConfig) throw new Error("endCursor is not supported with search in findMany().");
3877
4092
  if (endCursor !== void 0 && vectorSearchConfig) throw new Error("endCursor is not supported with vectorSearch in findMany().");
3878
4093
  if (useAdvancedStreamPath) {
3879
- const fallbackOrder = (queryConfig.order?.[0])?.direction ?? "asc";
4094
+ const fallbackOrder = (queryConfig.order?.[0])?.direction ?? (endCursor !== void 0 && !pipeline ? "desc" : "asc");
3880
4095
  let streamQuery;
3881
4096
  const unionSources = pipeline?.union ?? [];
3882
4097
  if (unionSources.length > 0) {
@@ -3892,13 +4107,22 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
3892
4107
  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.");
3893
4108
  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().`);
3894
4109
  }
3895
- streamQuery = await this._buildIdLookupStream({
4110
+ const idLookupStream = await this._buildIdLookupStream({
3896
4111
  configuredIndex,
3897
4112
  idLookup,
3898
4113
  order: fallbackOrder,
3899
4114
  queryConfig,
3900
4115
  wherePredicate
3901
- }) ?? this._buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex);
4116
+ });
4117
+ if (idLookupStream) streamQuery = idLookupStream;
4118
+ else {
4119
+ const baseStream = this._buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex, fallbackOrder);
4120
+ if (isCursorPaginated && queryConfig.strategy === "multiProbe" && !!queryConfig.index && !baseStream.probeUnion && maxScan === void 0) {
4121
+ if (strict) throw new Error("Pagination with multi-probe index-union filters requires maxScan when strict=true. Add maxScan, order by a field the union can serve, or make the query indexable.");
4122
+ console.warn("Pagination with multi-probe index-union filters is running without maxScan because strict: false.");
4123
+ }
4124
+ streamQuery = baseStream.stream;
4125
+ }
3902
4126
  }
3903
4127
  const rootRlsEnabled = isRlsEnabled(this.tableConfig.table);
3904
4128
  if (rootRlsEnabled) {
@@ -4014,6 +4238,37 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4014
4238
  const primaryOrder = postFetchOrders[0];
4015
4239
  const hasSecondaryOrders = postFetchOrders.length > 1;
4016
4240
  let orderIndexName = null;
4241
+ /**
4242
+ * Direction the scanned index can be walked in to produce the *whole*
4243
+ * requested sort, or null when some of it still has to run in JavaScript.
4244
+ * This is the read bound: a non-null answer means `take(limit)` is the
4245
+ * exact page.
4246
+ */
4247
+ let orderPushdownDirection = null;
4248
+ /**
4249
+ * Ask the one owner both questions an index has to answer.
4250
+ *
4251
+ * `whole` decides the read bound. `primary` is narrower and only feeds the
4252
+ * cursor-pagination diagnostics, which are about the leading sort field
4253
+ * having an index at all — an index that anchors the primary but not the
4254
+ * tie-break is still the right index to scan, and must not start reporting
4255
+ * itself as unindexed.
4256
+ */
4257
+ const resolveOrderForIndex = (indexFields, pinnedEqCount) => {
4258
+ const whole = resolveIndexOrderPushdown({
4259
+ indexFields,
4260
+ pinnedEqCount,
4261
+ orderSpecs: postFetchOrders
4262
+ });
4263
+ return {
4264
+ whole,
4265
+ primary: whole ?? (primaryOrder ? resolveIndexOrderPushdown({
4266
+ indexFields,
4267
+ pinnedEqCount,
4268
+ orderSpecs: [primaryOrder]
4269
+ }) : null)
4270
+ };
4271
+ };
4017
4272
  if (queryConfig.index) {
4018
4273
  const indexConfig = queryConfig.index;
4019
4274
  query = query.withIndex(indexConfig.name, (q) => {
@@ -4022,12 +4277,9 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4022
4277
  return indexQuery;
4023
4278
  });
4024
4279
  if (primaryOrder) {
4025
- const pushdownDirection = resolveIndexOrderPushdown({
4026
- indexFields: indexConfig.fields,
4027
- pinnedEqCount: this._indexEqPrefixCount(queryConfig),
4028
- orderSpecs: [primaryOrder]
4029
- });
4030
- if (pushdownDirection) query = query.order(pushdownDirection);
4280
+ const resolved = resolveOrderForIndex(indexConfig.fields, this._indexEqPrefixCount(queryConfig));
4281
+ orderPushdownDirection = resolved.whole;
4282
+ if (resolved.primary) query = query.order(resolved.primary);
4031
4283
  else needsPostFetchSortForPrimary = true;
4032
4284
  }
4033
4285
  } else if (configuredIndex?.name) {
@@ -4036,27 +4288,51 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4036
4288
  const configuredRange = configuredIndex.range;
4037
4289
  query = query.withIndex(configuredIndex.name, configuredRange ? (q) => configuredRange(observeConfiguredIndexRange(q, rangeOperations)) : (q) => q);
4038
4290
  if (primaryOrder) {
4039
- const pushdownDirection = resolveIndexOrderPushdown({
4040
- indexFields: configuredIndexFields,
4041
- pinnedEqCount: countConfiguredIndexEqPrefix(configuredIndexFields, rangeOperations),
4042
- orderSpecs: [primaryOrder]
4043
- });
4044
- if (pushdownDirection) query = query.order(pushdownDirection);
4291
+ const resolved = resolveOrderForIndex(configuredIndexFields, countConfiguredIndexEqPrefix(configuredIndexFields, rangeOperations));
4292
+ orderPushdownDirection = resolved.whole;
4293
+ if (resolved.primary) query = query.order(resolved.primary);
4045
4294
  else needsPostFetchSortForPrimary = true;
4046
4295
  }
4047
4296
  } else if (queryConfig.order && primaryOrder) {
4048
4297
  const orderField = primaryOrder.field;
4049
- if (orderField === "_creationTime") query = query.order(primaryOrder.direction);
4050
- else {
4051
- const orderIndex = getIndexes(this.tableConfig.table).find((idx) => idx.fields[0] === orderField) ?? this.edgeMetadata.find((idx) => idx.indexFields[0] === orderField);
4052
- if (orderIndex) {
4053
- orderIndexName = "indexName" in orderIndex ? orderIndex.indexName : orderIndex.name;
4298
+ if (orderField === INTERNAL_CREATION_TIME_FIELD) {
4299
+ query = query.order(primaryOrder.direction);
4300
+ orderPushdownDirection = resolveIndexOrderPushdown({
4301
+ indexFields: [],
4302
+ pinnedEqCount: 0,
4303
+ orderSpecs: postFetchOrders
4304
+ });
4305
+ } else {
4306
+ const candidates = [...getIndexes(this.tableConfig.table).map((idx) => ({
4307
+ fields: idx.fields,
4308
+ name: idx.name
4309
+ })), ...this.edgeMetadata.map((idx) => ({
4310
+ fields: idx.indexFields,
4311
+ name: idx.indexName
4312
+ }))];
4313
+ let selected;
4314
+ for (const candidate of candidates) {
4315
+ const direction = resolveIndexOrderPushdown({
4316
+ indexFields: candidate.fields,
4317
+ pinnedEqCount: 0,
4318
+ orderSpecs: postFetchOrders
4319
+ });
4320
+ if (direction) {
4321
+ selected = candidate;
4322
+ orderPushdownDirection = direction;
4323
+ break;
4324
+ }
4325
+ }
4326
+ selected ??= candidates.find((candidate) => candidate.fields[0] === orderField);
4327
+ if (selected) {
4328
+ orderIndexName = selected.name;
4054
4329
  query = query.withIndex(orderIndexName, (q) => q);
4055
- query = query.order(primaryOrder.direction);
4330
+ query = query.order(orderPushdownDirection ?? primaryOrder.direction);
4056
4331
  } else needsPostFetchSortForPrimary = true;
4057
4332
  }
4058
4333
  }
4059
- usePostFetchSort = needsPostFetchSortForPrimary || hasSecondaryOrders;
4334
+ usePostFetchSort = postFetchOrders.length > 0 && orderPushdownDirection === null;
4335
+ const hasUnservedSecondaryOrders = hasSecondaryOrders && orderPushdownDirection === null;
4060
4336
  if (wherePredicate) {
4061
4337
  const predicateIndex = configuredIndex;
4062
4338
  if (!predicateIndex?.name) throw new Error("predicate(...) requires .withIndex(name, range?) on the query.");
@@ -4068,9 +4344,9 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4068
4344
  if (strict) throw new Error(`Pagination: Field '${primaryOrder?.field}' has no index. Add an index or disable strict.`);
4069
4345
  console.warn(`Pagination: Field '${primaryOrder?.field}' has no index. Falling back to _creationTime ordering.`);
4070
4346
  }
4071
- if (hasSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are applied per page and may be unstable across pages.");
4347
+ if (hasUnservedSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are ignored because no index serves the whole sort.");
4072
4348
  }
4073
- if (primaryOrder && !needsPostFetchSortForPrimary) streamQuery = streamQuery.order(primaryOrder.direction);
4349
+ if (primaryOrder && !needsPostFetchSortForPrimary) streamQuery = streamQuery.order(orderPushdownDirection ?? primaryOrder.direction);
4074
4350
  else if (isCursorPaginated) streamQuery = streamQuery.order("desc");
4075
4351
  streamQuery = streamQuery.filterWith(async (row) => {
4076
4352
  for (const filter of queryConfig.postFilters) if (!this._evaluatePostFetchFilter(row, filter)) return false;
@@ -4116,12 +4392,8 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4116
4392
  const convexProbeFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
4117
4393
  const probeHasResidualFilter = convexProbeFilters.length !== queryConfig.postFilters.length;
4118
4394
  const probeHasPostFetchMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig) || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
4119
- const probeOrderDirection = primaryOrder ? resolveIndexOrderPushdown({
4120
- indexFields: queryConfig.index.fields,
4121
- pinnedEqCount: this._indexEqPrefixCount(queryConfig),
4122
- orderSpecs: [primaryOrder]
4123
- }) : null;
4124
- const probeBound = probeLimit !== void 0 && !probeHasResidualFilter && !probeHasPostFetchMembership && (postFetchOrders.length === 0 || probeOrderDirection !== null && !hasSecondaryOrders) ? probeOffset + probeLimit : void 0;
4395
+ const probeOrderDirection = orderPushdownDirection;
4396
+ const probeBound = probeLimit !== void 0 && !probeHasResidualFilter && !probeHasPostFetchMembership && (postFetchOrders.length === 0 || probeOrderDirection !== null) ? probeOffset + probeLimit : void 0;
4125
4397
  const probeRows = await Promise.all(queryConfig.probeFilters.map(async (probeFilters) => {
4126
4398
  let probeQuery = this.db.query(queryConfig.table).withIndex(queryConfig.index.name, (q) => {
4127
4399
  let indexQuery = q;
@@ -4149,69 +4421,53 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4149
4421
  return (await this._applyRelationsFilterToRows(visibleRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with)).length > 0;
4150
4422
  };
4151
4423
  if (isCursorPaginated) {
4152
- if (queryConfig.strategy === "multiProbe") if (maxScan === void 0) {
4153
- if (strict) throw new Error("Pagination with multi-probe index-union filters requires maxScan when strict=true. Add maxScan or make the query indexable.");
4154
- console.warn("Pagination with multi-probe index-union filters is running without maxScan because strict: false.");
4155
- } else {
4424
+ const isMultiProbePlan = queryConfig.strategy === "multiProbe" && !!queryConfig.index;
4425
+ const isScanFallbackPlan = !queryConfig.index && queryConfig.postFilters.length > 0;
4426
+ if (isMultiProbePlan || isScanFallbackPlan) {
4156
4427
  const schemaDefinition = this.schema[OrmSchemaDefinition];
4157
- if (!schemaDefinition) throw new Error("Pagination with maxScan requires defineSchema(). Ensure defineSchema(tables) was used with the same tables object passed to defineRelations.");
4158
- let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
4159
- if (queryConfig.order && primaryOrder) {
4160
- if (needsPostFetchSortForPrimary) {
4161
- if (strict) throw new Error(`Pagination: Field '${primaryOrder.field}' has no index. Add an index or disable strict.`);
4162
- console.warn(`Pagination: Field '${primaryOrder.field}' has no index. Falling back to _creationTime ordering.`);
4428
+ const probeUnion = isMultiProbePlan && schemaDefinition ? this._buildProbeUnionStream({
4429
+ schemaDefinition,
4430
+ indexName: queryConfig.index.name,
4431
+ probeFilters: queryConfig.probeFilters,
4432
+ order: primaryOrder?.direction ?? "desc",
4433
+ orderField: primaryOrder?.field ?? queryConfig.index.fields[0]
4434
+ }) : null;
4435
+ if (!probeUnion && maxScan === void 0) {
4436
+ if (strict) throw new Error(isMultiProbePlan ? "Pagination with multi-probe index-union filters requires maxScan when strict=true. Add maxScan, order by a field the union can serve, or make the query indexable." : "Cursor pagination with scan fallback requires maxScan when strict=true. Add maxScan or make the query indexable.");
4437
+ if (isMultiProbePlan) console.warn("Pagination with multi-probe index-union filters is running without maxScan because strict: false.");
4438
+ }
4439
+ if (probeUnion || maxScan !== void 0) {
4440
+ if (!schemaDefinition) throw new Error("Pagination with maxScan requires defineSchema(). Ensure defineSchema(tables) was used with the same tables object passed to defineRelations.");
4441
+ let streamQuery = probeUnion;
4442
+ if (!streamQuery) {
4443
+ streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
4444
+ if (isScanFallbackPlan && configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
4163
4445
  }
4164
- if (hasSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are applied per page and may be unstable across pages.");
4165
- streamQuery = streamQuery.order(primaryOrder.direction);
4166
- } else streamQuery = streamQuery.order("desc");
4167
- if (queryConfig.postFilters.length > 0) streamQuery = streamQuery.filterWith(async (row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
4168
- const paginationResult = await streamQuery.paginate({
4169
- cursor: cursor ?? null,
4170
- limit: config.limit,
4171
- maxScan
4172
- });
4173
- let pageRows = paginationResult.page;
4174
- pageRows = await this._applyRlsSelectFilter(pageRows, this.tableConfig);
4175
- if (whereFilter) pageRows = await this._applyRelationsFilterToRows(pageRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with);
4176
- return {
4177
- page: await this._finalizeRows(pageRows),
4178
- continueCursor: paginationResult.continueCursor,
4179
- isDone: paginationResult.isDone,
4180
- pageStatus: paginationResult.pageStatus,
4181
- splitCursor: paginationResult.splitCursor
4182
- };
4183
- }
4184
- if (!queryConfig.index && queryConfig.postFilters.length > 0) if (maxScan === void 0) {
4185
- if (strict) throw new Error("Cursor pagination with scan fallback requires maxScan when strict=true. Add maxScan or make the query indexable.");
4186
- } else {
4187
- const schemaDefinition = this.schema[OrmSchemaDefinition];
4188
- if (!schemaDefinition) throw new Error("Pagination with maxScan requires defineSchema(). Ensure defineSchema(tables) was used with the same tables object passed to defineRelations.");
4189
- let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
4190
- if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
4191
- if (queryConfig.order && primaryOrder) {
4192
- if (needsPostFetchSortForPrimary) {
4193
- if (strict) throw new Error(`Pagination: Field '${primaryOrder.field}' has no index. Add an index or disable strict.`);
4194
- console.warn(`Pagination: Field '${primaryOrder.field}' has no index. Falling back to _creationTime ordering.`);
4446
+ if (queryConfig.order && primaryOrder) {
4447
+ if (needsPostFetchSortForPrimary) {
4448
+ if (strict) throw new Error(`Pagination: Field '${primaryOrder.field}' has no index. Add an index or disable strict.`);
4449
+ console.warn(`Pagination: Field '${primaryOrder.field}' has no index. Falling back to _creationTime ordering.`);
4450
+ }
4451
+ if (hasSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are ignored because this page is not read through an index that orders by them.");
4195
4452
  }
4196
- if (hasSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are applied per page and may be unstable across pages.");
4197
- streamQuery = streamQuery.order(primaryOrder.direction);
4198
- } else streamQuery = streamQuery.order("desc");
4199
- streamQuery = streamQuery.filterWith(async (row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
4200
- const paginationResult = await streamQuery.paginate({
4201
- cursor: cursor ?? null,
4202
- limit: config.limit,
4203
- maxScan
4204
- });
4205
- let pageRows = paginationResult.page;
4206
- pageRows = await this._applyRlsSelectFilter(pageRows, this.tableConfig);
4207
- if (whereFilter) pageRows = await this._applyRelationsFilterToRows(pageRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with);
4208
- return {
4209
- page: await this._finalizeRows(pageRows),
4210
- continueCursor: paginationResult.continueCursor,
4211
- isDone: paginationResult.isDone,
4212
- pageStatus: paginationResult.pageStatus,
4213
- splitCursor: paginationResult.splitCursor
4214
- };
4453
+ if (!probeUnion) streamQuery = streamQuery.order(queryConfig.order && primaryOrder ? primaryOrder.direction : "desc");
4454
+ if (queryConfig.postFilters.length > 0) streamQuery = streamQuery.filterWith(async (row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
4455
+ const paginationResult = await streamQuery.paginate({
4456
+ cursor: cursor ?? null,
4457
+ limit: config.limit,
4458
+ maxScan
4459
+ });
4460
+ let pageRows = paginationResult.page;
4461
+ pageRows = await this._applyRlsSelectFilter(pageRows, this.tableConfig);
4462
+ if (whereFilter) pageRows = await this._applyRelationsFilterToRows(pageRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with);
4463
+ return {
4464
+ page: await this._finalizeRows(pageRows),
4465
+ continueCursor: paginationResult.continueCursor,
4466
+ isDone: paginationResult.isDone,
4467
+ pageStatus: paginationResult.pageStatus,
4468
+ splitCursor: paginationResult.splitCursor
4469
+ };
4470
+ }
4215
4471
  }
4216
4472
  const convexPostFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
4217
4473
  const hasResidualPostFilter = convexPostFilters.length !== queryConfig.postFilters.length;
@@ -4221,6 +4477,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4221
4477
  membershipFilter: matchesPostFetchMembership,
4222
4478
  orderIndexName,
4223
4479
  primaryOrder,
4480
+ pushdownDirection: orderPushdownDirection,
4224
4481
  fallbackOrder: "desc"
4225
4482
  }) : null;
4226
4483
  if (hasResidualPostFilter && !residualPageStream) this._getSchemaDefinitionOrThrow();
@@ -4228,7 +4485,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4228
4485
  if (queryConfig.order && primaryOrder) {
4229
4486
  if (needsPostFetchSortForPrimary && strict) throw new Error(`Pagination: Field '${primaryOrder.field}' has no index. Add an index or disable strict.`);
4230
4487
  if (needsPostFetchSortForPrimary) console.warn(`Pagination: Field '${primaryOrder.field}' has no index. Falling back to _creationTime ordering.`);
4231
- if (hasSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are applied per page and may be unstable across pages.");
4488
+ if (hasUnservedSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are ignored because no index serves the whole sort.");
4232
4489
  }
4233
4490
  const paginationResult = await residualPageStream.paginate({
4234
4491
  cursor: cursor ?? null,
@@ -4251,7 +4508,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4251
4508
  console.warn(`Pagination: Field '${primaryOrder.field}' has no index. Falling back to _creationTime ordering.`);
4252
4509
  query = query.order(primaryOrder.direction === "asc" ? "asc" : "desc");
4253
4510
  }
4254
- if (hasSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are applied per page and may be unstable across pages.");
4511
+ if (hasUnservedSecondaryOrders) console.warn("Pagination: Only the first orderBy field is used for cursor ordering. Secondary orderBy fields are ignored because no index serves the whole sort.");
4255
4512
  } else query = query.order("desc");
4256
4513
  const paginationResult = await query.paginate({
4257
4514
  cursor: cursor ?? null,
@@ -4281,6 +4538,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4281
4538
  membershipFilter: matchesPostFetchMembership,
4282
4539
  orderIndexName,
4283
4540
  primaryOrder,
4541
+ pushdownDirection: orderPushdownDirection,
4284
4542
  fallbackOrder: "asc"
4285
4543
  }) : null;
4286
4544
  let rows;
@@ -4760,9 +5018,10 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4760
5018
  * parents in the round.
4761
5019
  */
4762
5020
  async _readBoundedThroughLinks(params) {
4763
- const { applyTargetFilters, edge, enforceTargetKeyCap, entries, fetchLimit, fetchTargets, targetFields, throughIndexName, throughTableConfig } = params;
5021
+ const { applyAmbientTargetFilters, applyConfigTargetFilter, edge, enforceTargetKeyCap, entries, fetchLimit, fetchTargets, scanLimit, targetFields, throughIndexName, throughTableConfig } = params;
4764
5022
  const throughTargetFields = edge.through.targetFields;
4765
5023
  const cursors = entries.map(([key, values]) => ({
5024
+ ambientLinks: 0,
4766
5025
  buffered: [],
4767
5026
  exhausted: false,
4768
5027
  iterator: this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName)[Symbol.asyncIterator](),
@@ -4789,10 +5048,12 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4789
5048
  };
4790
5049
  /** Target key -> the surviving document, absent when it did not survive. */
4791
5050
  const survivorByKey = /* @__PURE__ */ new Map();
5051
+ /** Target keys that cleared the ambient filters, survivors or not. */
5052
+ const ambientKeys = /* @__PURE__ */ new Set();
4792
5053
  const resolvedKeys = /* @__PURE__ */ new Set();
4793
5054
  const survivors = [];
4794
5055
  while (true) {
4795
- const active = cursors.filter((cursor) => cursor.links.length < fetchLimit && !(cursor.exhausted && cursor.buffered.length === 0));
5056
+ const active = cursors.filter((cursor) => cursor.links.length < fetchLimit && (scanLimit === void 0 || cursor.ambientLinks < scanLimit) && !(cursor.exhausted && cursor.buffered.length === 0));
4796
5057
  if (active.length === 0) break;
4797
5058
  const candidatesPerCursor = await this._mapWithConcurrency(active, async (cursor) => {
4798
5059
  const need = fetchLimit - cursor.links.length;
@@ -4811,7 +5072,12 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4811
5072
  enforceTargetKeyCap(resolvedKeys.size + newKeys.size);
4812
5073
  const fetched = await fetchTargets(Array.from(newKeys.entries()));
4813
5074
  for (const key of newKeys.keys()) resolvedKeys.add(key);
4814
- const surviving = await applyTargetFilters(fetched.map((entry) => entry.target).filter((target) => !!target));
5075
+ const ambientTargets = await applyAmbientTargetFilters(fetched.map((entry) => entry.target).filter((target) => !!target));
5076
+ for (const target of ambientTargets) {
5077
+ const key = this._buildRelationKey(target, targetFields);
5078
+ if (key) ambientKeys.add(key);
5079
+ }
5080
+ const surviving = await applyConfigTargetFilter(ambientTargets);
4815
5081
  for (const target of surviving) {
4816
5082
  const key = this._buildRelationKey(target, targetFields);
4817
5083
  if (!key || survivorByKey.has(key)) continue;
@@ -4823,8 +5089,11 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4823
5089
  const cursor = active[i];
4824
5090
  for (const link of candidatesPerCursor[i]) {
4825
5091
  if (cursor.links.length >= fetchLimit) break;
5092
+ if (scanLimit !== void 0 && cursor.ambientLinks >= scanLimit) break;
4826
5093
  const key = this._buildRelationKey(link, throughTargetFields);
4827
- if (!key || !survivorByKey.has(key)) continue;
5094
+ if (!key) continue;
5095
+ if (ambientKeys.has(key)) cursor.ambientLinks += 1;
5096
+ if (!survivorByKey.has(key)) continue;
4828
5097
  cursor.links.push(link);
4829
5098
  }
4830
5099
  }
@@ -4877,6 +5146,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4877
5146
  const relationDefinition = tableConfig.relations[relationName];
4878
5147
  const strict = tableConfig.strict !== false;
4879
5148
  const hasPostFetchTargetFilter = this.rls?.mode !== "skip" && isRlsEnabled(targetTableConfig.table) || Boolean(relationDefinition?.where) || Boolean(relationConfig && typeof relationConfig === "object" && "where" in relationConfig && relationConfig.where);
5149
+ const isExistenceProbe = Boolean(relationConfig) && typeof relationConfig === "object" && relationConfig[RELATION_EXISTENCE_PROBE] === true;
4880
5150
  let orderSpecs = [];
4881
5151
  if (relationConfig && typeof relationConfig === "object" && "orderBy" in relationConfig) {
4882
5152
  let orderByValue = relationConfig.orderBy;
@@ -4891,15 +5161,22 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4891
5161
  if (effectivePerParentLimit === void 0 && !this.allowFullScan) throw new Error(`Relation "${tableConfig.name}.${relationName}" requires limit, allowFullScan: true, or defineSchema(..., { defaults: { defaultLimit } }).`);
4892
5162
  const perParentOffset = relationConfig && typeof relationConfig === "object" && "offset" in relationConfig ? relationConfig.offset : void 0;
4893
5163
  if (perParentOffset !== void 0 && typeof perParentOffset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
5164
+ const probeFetchLimit = isExistenceProbe ? 1 : void 0;
5165
+ const probeScanLimit = isExistenceProbe ? effectivePerParentLimit : void 0;
5166
+ const canBoundPerParentRead = effectivePerParentLimit !== void 0 || isExistenceProbe;
4894
5167
  const applyOffsetAndLimit = (items) => {
4895
5168
  let result = items;
4896
5169
  if (perParentOffset !== void 0 && perParentOffset > 0) result = result.slice(perParentOffset);
4897
5170
  if (effectivePerParentLimit !== void 0) result = result.slice(0, effectivePerParentLimit);
4898
5171
  return result;
4899
5172
  };
4900
- const applyPostFetchTargetFilters = async (candidateTargets) => {
4901
- let filteredTargets = await this._applyRlsSelectFilter(candidateTargets, targetTableConfig);
4902
- if (relationDefinition?.where) filteredTargets = filteredTargets.filter((target) => this._evaluateTableFilter(target, targetTableConfig, relationDefinition.where));
5173
+ const applyAmbientTargetFilters = async (candidateTargets) => {
5174
+ const visibleTargets = await this._applyRlsSelectFilter(candidateTargets, targetTableConfig);
5175
+ if (!relationDefinition?.where) return visibleTargets;
5176
+ return visibleTargets.filter((target) => this._evaluateTableFilter(target, targetTableConfig, relationDefinition.where));
5177
+ };
5178
+ const applyConfigTargetFilter = async (ambientTargets) => {
5179
+ let filteredTargets = ambientTargets;
4903
5180
  if (relationConfig && typeof relationConfig === "object" && "where" in relationConfig) {
4904
5181
  const whereFilter = relationConfig.where;
4905
5182
  if (typeof whereFilter === "function") {
@@ -4912,6 +5189,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4912
5189
  }
4913
5190
  return filteredTargets;
4914
5191
  };
5192
+ const applyPostFetchTargetFilters = async (candidateTargets) => applyConfigTargetFilter(await applyAmbientTargetFilters(candidateTargets));
4915
5193
  let targets = [];
4916
5194
  let throughBySourceKey;
4917
5195
  let targetFiltersApplied = false;
@@ -4946,14 +5224,16 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4946
5224
  };
4947
5225
  });
4948
5226
  };
4949
- if (orderSpecs.length === 0 && effectivePerParentLimit !== void 0) {
5227
+ if (orderSpecs.length === 0 && canBoundPerParentRead) {
4950
5228
  const bounded = await this._readBoundedThroughLinks({
4951
- applyTargetFilters: applyPostFetchTargetFilters,
5229
+ applyAmbientTargetFilters,
5230
+ applyConfigTargetFilter,
4952
5231
  edge,
4953
5232
  enforceTargetKeyCap,
4954
5233
  entries,
4955
- fetchLimit: Math.max(perParentOffset ?? 0, 0) + effectivePerParentLimit,
5234
+ fetchLimit: probeFetchLimit ?? Math.max(perParentOffset ?? 0, 0) + effectivePerParentLimit,
4956
5235
  fetchTargets: fetchThroughTargets,
5236
+ scanLimit: probeScanLimit,
4957
5237
  targetFields,
4958
5238
  throughIndexName,
4959
5239
  throughTableConfig
@@ -4993,22 +5273,25 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4993
5273
  });
4994
5274
  const orderServedByIndex = orderSpecs.length === 0 || orderPushdownDirection !== null;
4995
5275
  const applyPushdownOrder = (query) => orderPushdownDirection ? query.order(orderPushdownDirection) : query;
4996
- const streamPostFetchTargetFilters = orderServedByIndex && hasPostFetchTargetFilter && effectivePerParentLimit !== void 0;
5276
+ const streamPostFetchTargetFilters = orderServedByIndex && hasPostFetchTargetFilter && canBoundPerParentRead;
4997
5277
  targetFiltersApplied = streamPostFetchTargetFilters;
4998
5278
  targets = (await this._mapWithConcurrency(entries, async ([, values]) => {
4999
5279
  const query = this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName);
5000
- if (orderServedByIndex && !hasPostFetchTargetFilter && effectivePerParentLimit !== void 0) {
5001
- const fetchLimit = (perParentOffset ?? 0) + (effectivePerParentLimit ?? 0);
5280
+ if (orderServedByIndex && !hasPostFetchTargetFilter && canBoundPerParentRead) {
5281
+ const fetchLimit = probeFetchLimit ?? (perParentOffset ?? 0) + (effectivePerParentLimit ?? 0);
5002
5282
  return await applyPushdownOrder(query).take(fetchLimit);
5003
5283
  }
5004
5284
  if (streamPostFetchTargetFilters) {
5005
5285
  const visibleTargets = [];
5006
- const fetchLimit = Math.max(perParentOffset ?? 0, 0) + (effectivePerParentLimit ?? 0);
5286
+ const fetchLimit = probeFetchLimit ?? Math.max(perParentOffset ?? 0, 0) + (effectivePerParentLimit ?? 0);
5007
5287
  let batch = [];
5288
+ let ambientSeen = 0;
5008
5289
  const drain = async () => {
5009
5290
  if (batch.length === 0) return;
5010
- const filtered = await applyPostFetchTargetFilters(batch);
5291
+ const ambientTargets = await applyAmbientTargetFilters(batch);
5011
5292
  batch = [];
5293
+ ambientSeen += ambientTargets.length;
5294
+ const filtered = await applyConfigTargetFilter(ambientTargets);
5012
5295
  for (const row of filtered) {
5013
5296
  if (visibleTargets.length >= fetchLimit) return;
5014
5297
  visibleTargets.push(row);
@@ -5020,6 +5303,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5020
5303
  if (batch.length < chunk) continue;
5021
5304
  await drain();
5022
5305
  if (visibleTargets.length >= fetchLimit) break;
5306
+ if (probeScanLimit !== void 0 && ambientSeen >= probeScanLimit) break;
5023
5307
  }
5024
5308
  if (visibleTargets.length < fetchLimit) await drain();
5025
5309
  return visibleTargets;