kitcn 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/aggregate/index.d.ts +1 -1
  2. package/dist/auth/client/index.js +1 -1
  3. package/dist/auth/index.js +19 -21
  4. package/dist/auth/nextjs/index.d.ts +1 -1
  5. package/dist/auth/nextjs/index.js +4 -4
  6. package/dist/{auth-store-ssZDPa37.js → auth-store-BnGZxmnY.js} +4 -1
  7. package/dist/{backend-core-DqPydYyx.mjs → backend-core-BsKP1LVg.mjs} +204 -164
  8. package/dist/{builder-DBgto1yn.js → builder-f4F_NRvK.js} +245 -153
  9. package/dist/{caller-factory-NEfgD5E0.js → caller-factory-DHywSoGZ.js} +7 -5
  10. package/dist/cli.mjs +14 -7
  11. package/dist/crpc/index.js +1 -127
  12. package/dist/{middleware-Bg-PdtrI.js → middleware-Cgrv2jIu.js} +1 -1
  13. package/dist/orm/index.d.ts +1 -1
  14. package/dist/orm/index.js +486 -121
  15. package/dist/plugins/index.js +1 -1
  16. package/dist/{procedure-caller-9m6NBxQu.js → procedure-caller-Rj6z3ai7.js} +1 -1
  17. package/dist/{procedure-name-Cy1AxayA.d.ts → procedure-name-Bo5KMcqc.d.ts} +15 -3
  18. package/dist/{query-context-ydn9kb6P.js → query-context-C90vNlc9.js} +131 -30
  19. package/dist/query-options-C_eBSIXG.js +247 -0
  20. package/dist/ratelimit/index.d.ts +26 -6
  21. package/dist/ratelimit/index.js +427 -100
  22. package/dist/ratelimit/react/index.d.ts +14 -0
  23. package/dist/ratelimit/react/index.js +149 -16
  24. package/dist/react/index.d.ts +3 -1
  25. package/dist/react/index.js +48 -15
  26. package/dist/rsc/index.js +22 -33
  27. package/dist/server/index.d.ts +1 -1
  28. package/dist/server/index.js +3 -3
  29. package/dist/solid/index.js +19 -5
  30. package/dist/watcher.mjs +2 -2
  31. package/dist/{where-clause-compiler-WF9UcrAB.d.ts → where-clause-compiler-BRhLW1dp.d.ts} +51 -0
  32. package/package.json +1 -1
  33. package/skills/kitcn/SKILL.md +1 -0
  34. package/skills/kitcn/references/features/create-plugins.md +1 -1
  35. package/skills/kitcn/references/features/orm.md +11 -1
  36. package/skills/kitcn/references/features/ratelimit.md +105 -0
  37. package/skills/kitcn/references/setup/server.md +1 -1
  38. package/dist/query-options-C96zLANM.js +0 -121
package/dist/orm/index.js CHANGED
@@ -1,7 +1,7 @@
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 ne, C as inArray, D as like, E as isNull, F as notLike, I as or, L as startsWith, M as notBetween, N as notIlike, O as lt, P as notInArray, S as ilike, T as isNotNull, _ as endsWith, a as mergedStream, b as gt, c as isUnsetToken, d as arrayContained, f as arrayContains, g as contains, h as column, i as getIndexFields, j as not, k as lte, l as unsetToken, m as between, n as EmptyStream, o as stream, p as arrayOverlaps, r as QueryStream, s as streamIndexRange, t as getByIdWithOrmQueryFallback, u as and, v as eq, w as isFieldReference, x as gte, y as fieldRef } from "../query-context-ydn9kb6P.js";
4
- import { v } from "convex/values";
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";
4
+ import { compareValues, v } from "convex/values";
5
5
  import { defineSchema as defineSchema$1, internalActionGeneric, internalMutationGeneric } from "convex/server";
6
6
 
7
7
  //#region src/orm/builders/bigint.ts
@@ -1188,23 +1188,7 @@ function splitReturningSelection(fields) {
1188
1188
  countSelection
1189
1189
  };
1190
1190
  }
1191
- function matchLike(value, pattern, caseInsensitive) {
1192
- const targetValue = caseInsensitive ? value.toLowerCase() : value;
1193
- const targetPattern = caseInsensitive ? pattern.toLowerCase() : pattern;
1194
- if (targetPattern.startsWith("%") && targetPattern.endsWith("%")) {
1195
- const substring = targetPattern.slice(1, -1);
1196
- return targetValue.includes(substring);
1197
- }
1198
- if (targetPattern.startsWith("%")) {
1199
- const suffix = targetPattern.slice(1);
1200
- return targetValue.endsWith(suffix);
1201
- }
1202
- if (targetPattern.endsWith("%")) {
1203
- const prefix = targetPattern.slice(0, -1);
1204
- return targetValue.startsWith(prefix);
1205
- }
1206
- return targetValue === targetPattern;
1207
- }
1191
+ const matchLike = matchLikePattern;
1208
1192
  function evaluateFilter(row, filter) {
1209
1193
  if (filter.type === "binary") {
1210
1194
  const [field, value] = filter.operands;
@@ -1240,14 +1224,14 @@ function evaluateFilter(row, filter) {
1240
1224
  case "contains":
1241
1225
  if (typeof fieldValue !== "string") return false;
1242
1226
  return fieldValue.includes(value);
1243
- case "eq": return fieldValue === value;
1244
- case "ne": return fieldValue !== value;
1227
+ case "eq": return filterValuesEqual(fieldValue, value);
1228
+ case "ne": return !filterValuesEqual(fieldValue, value);
1245
1229
  case "gt": return fieldValue > value;
1246
1230
  case "gte": return fieldValue >= value;
1247
1231
  case "lt": return fieldValue < value;
1248
1232
  case "lte": return fieldValue <= value;
1249
- case "inArray": return value.includes(fieldValue);
1250
- case "notInArray": return !value.includes(fieldValue);
1233
+ case "inArray": return filterValueInList(fieldValue, value);
1234
+ case "notInArray": return !filterValueInList(fieldValue, value);
1251
1235
  case "arrayContains":
1252
1236
  if (!Array.isArray(fieldValue)) return false;
1253
1237
  return value.every((item) => fieldValue.includes(item));
@@ -1335,12 +1319,14 @@ function evaluateCheckConstraintTriState(row, filter) {
1335
1319
  case "inArray": {
1336
1320
  const arr = compareValue;
1337
1321
  if (!Array.isArray(arr)) return false;
1338
- return arr.includes(fieldValue);
1322
+ if (arr.includes(fieldValue)) return true;
1323
+ return arr.some(nullish) ? "unknown" : false;
1339
1324
  }
1340
1325
  case "notInArray": {
1341
1326
  const arr = compareValue;
1342
1327
  if (!Array.isArray(arr)) return false;
1343
- return !arr.includes(fieldValue);
1328
+ if (arr.includes(fieldValue)) return false;
1329
+ return arr.some(nullish) ? "unknown" : true;
1344
1330
  }
1345
1331
  case "arrayContains": {
1346
1332
  if (!Array.isArray(fieldValue)) return false;
@@ -3629,6 +3615,17 @@ function policyApplies(policy, operation) {
3629
3615
  const target = policy.for ?? "all";
3630
3616
  return target === "all" || target === operation;
3631
3617
  }
3618
+ /**
3619
+ * SQL pseudo-roles always resolve to the calling user, so they apply to every
3620
+ * caller and never need a role resolver. Only `rlsRole()` values and bare role
3621
+ * names are matched against resolved roles.
3622
+ */
3623
+ const PSEUDO_ROLES = new Set([
3624
+ "public",
3625
+ "current_role",
3626
+ "current_user",
3627
+ "session_user"
3628
+ ]);
3632
3629
  function flattenRoles(target) {
3633
3630
  if (!target) return "public";
3634
3631
  const roles = [];
@@ -3638,7 +3635,7 @@ function flattenRoles(target) {
3638
3635
  value.forEach(visit);
3639
3636
  return;
3640
3637
  }
3641
- if (value === "public") {
3638
+ if (typeof value === "string" && PSEUDO_ROLES.has(value)) {
3642
3639
  hasPublic = true;
3643
3640
  return;
3644
3641
  }
@@ -3651,14 +3648,42 @@ function flattenRoles(target) {
3651
3648
  visit(target);
3652
3649
  return hasPublic ? "public" : roles;
3653
3650
  }
3651
+ function roleResolverRequiredError(policy, table, targetRoles) {
3652
+ const roleList = targetRoles.map((role) => `'${role}'`).join(", ");
3653
+ return /* @__PURE__ */ new Error(`RLS_ROLE_RESOLVER_REQUIRED: policy '${policy.name}' on table '${getTableName(table)}' targets role(s) ${roleList}, but no 'roleResolver' is configured. Pass 'rls.roleResolver' when creating the ORM database, or remove the policy's 'to' clause.`);
3654
+ }
3655
+ /**
3656
+ * A named-role policy cannot be evaluated without a role resolver. Validate
3657
+ * that from the table's policy set alone so the failure depends only on
3658
+ * configuration, never on whether the table currently holds matching rows.
3659
+ */
3660
+ function assertRlsRolesResolvable(options) {
3661
+ const { table, operation, rls } = options;
3662
+ if (!isRlsEnabled(table)) return;
3663
+ if (rls?.mode === "skip") return;
3664
+ if (rls?.roleResolver) return;
3665
+ for (const policy of getRlsPolicies(table)) {
3666
+ if (!policyApplies(policy, operation)) continue;
3667
+ const targetRoles = flattenRoles(policy.to);
3668
+ if (targetRoles === "public") continue;
3669
+ throw roleResolverRequiredError(policy, table, targetRoles);
3670
+ }
3671
+ }
3654
3672
  function roleMatches(policy, rls) {
3655
- const resolver = rls?.roleResolver;
3656
- if (!resolver) return true;
3657
- const roles = resolver(rls?.ctx ?? {}) ?? [];
3658
3673
  const targetRoles = flattenRoles(policy.to);
3659
3674
  if (targetRoles === "public") return true;
3675
+ const resolver = rls?.roleResolver;
3676
+ const roles = resolver ? resolver(rls?.ctx ?? {}) ?? [] : [];
3660
3677
  return targetRoles.some((role) => roles.includes(role));
3661
3678
  }
3679
+ /**
3680
+ * Policy expressions follow SQL RLS semantics: an expression that evaluates to
3681
+ * NULL/unknown is treated as false. A nullish document field or a nullish
3682
+ * context value therefore never satisfies a policy.
3683
+ */
3684
+ function policyExpressionPasses(row, expression) {
3685
+ return evaluateCheckConstraintTriState(row, expression) === true;
3686
+ }
3662
3687
  async function resolveExpression(policy, checkType, ctx, table) {
3663
3688
  const candidate = checkType === "withCheck" ? policy.withCheck ?? policy.using : policy.using;
3664
3689
  if (!candidate) return;
@@ -3668,6 +3693,11 @@ async function resolveExpression(policy, checkType, ctx, table) {
3668
3693
  async function evaluatePolicySet({ table, operation, checkType, row, rls }) {
3669
3694
  if (!isRlsEnabled(table)) return true;
3670
3695
  if (rls?.mode === "skip") return true;
3696
+ assertRlsRolesResolvable({
3697
+ table,
3698
+ operation,
3699
+ rls
3700
+ });
3671
3701
  const ctx = rls?.ctx ?? {};
3672
3702
  const policies = getRlsPolicies(table).filter((policy) => policyApplies(policy, operation) && roleMatches(policy, rls));
3673
3703
  if (policies.length === 0) return false;
@@ -3676,7 +3706,7 @@ async function evaluatePolicySet({ table, operation, checkType, row, rls }) {
3676
3706
  let permissivePasses = false;
3677
3707
  for (const policy of permissive) {
3678
3708
  const expression = await resolveExpression(policy, checkType, ctx, table);
3679
- if (!expression || evaluateFilter(row, expression)) {
3709
+ if (!expression || policyExpressionPasses(row, expression)) {
3680
3710
  permissivePasses = true;
3681
3711
  break;
3682
3712
  }
@@ -3686,7 +3716,7 @@ async function evaluatePolicySet({ table, operation, checkType, row, rls }) {
3686
3716
  for (const policy of restrictive) {
3687
3717
  const expression = await resolveExpression(policy, checkType, ctx, table);
3688
3718
  if (!expression) continue;
3689
- if (!evaluateFilter(row, expression)) return false;
3719
+ if (!policyExpressionPasses(row, expression)) return false;
3690
3720
  }
3691
3721
  return true;
3692
3722
  }
@@ -3741,6 +3771,11 @@ async function evaluateUpdateDecision(options) {
3741
3771
  async function filterSelectRows(options) {
3742
3772
  if (!isRlsEnabled(options.table)) return options.rows;
3743
3773
  if (options.rls?.mode === "skip") return options.rows;
3774
+ assertRlsRolesResolvable({
3775
+ table: options.table,
3776
+ operation: "select",
3777
+ rls: options.rls
3778
+ });
3744
3779
  const rows = [];
3745
3780
  for (const row of options.rows) if (await canSelectRow({
3746
3781
  table: options.table,
@@ -3849,11 +3884,11 @@ var WhereClauseCompiler = class {
3849
3884
  const selectedIndex = this.findLeadingIndex(operand.fieldName);
3850
3885
  if (!selectedIndex) return null;
3851
3886
  return {
3852
- strategy: "singleIndex",
3887
+ strategy: "rangeIndex",
3853
3888
  selectedIndex,
3854
- indexFilters: [eq(fieldRef(operand.fieldName), null)],
3889
+ indexFilters: [lte(fieldRef(operand.fieldName), null)],
3855
3890
  probeFilters: [],
3856
- postFilters: []
3891
+ postFilters: [expression]
3857
3892
  };
3858
3893
  }
3859
3894
  tryCompileIsNotNull(expression) {
@@ -4255,6 +4290,24 @@ var WhereClauseCompiler = class {
4255
4290
 
4256
4291
  //#endregion
4257
4292
  //#region src/orm/query.ts
4293
+ /**
4294
+ * Operators that Convex's `.filter()` cannot express, so `_toConvexExpression`
4295
+ * compiles them to a `() => true` placeholder and `_evaluatePostFetchFilter`
4296
+ * applies the real predicate in JavaScript after the rows are read.
4297
+ * Keep in sync with the string/array cases in `_toConvexExpression`.
4298
+ */
4299
+ const POST_FETCH_ONLY_OPERATORS = new Set([
4300
+ "like",
4301
+ "ilike",
4302
+ "notLike",
4303
+ "notIlike",
4304
+ "startsWith",
4305
+ "endsWith",
4306
+ "contains",
4307
+ "arrayContains",
4308
+ "arrayContained",
4309
+ "arrayOverlaps"
4310
+ ]);
4258
4311
  const DEFAULT_RELATION_FAN_OUT_MAX_KEYS = 1e3;
4259
4312
  const DEFAULT_AGGREGATE_CARTESIAN_MAX_KEYS = 4096;
4260
4313
  const DEFAULT_AGGREGATE_WORK_BUDGET = 16384;
@@ -4265,37 +4318,90 @@ const RELATION_COUNT_ERROR = {
4265
4318
  NOT_INDEXED: "RELATION_COUNT_NOT_INDEXED",
4266
4319
  FILTER_UNSUPPORTED: "RELATION_COUNT_FILTER_UNSUPPORTED"
4267
4320
  };
4268
- var LimitedQueryStream = class LimitedQueryStream extends QueryStream {
4269
- constructor(inner, limit) {
4321
+ /**
4322
+ * Replays an already-read run of `[doc | null, indexKey]` entries.
4323
+ *
4324
+ * `narrow()` filters the buffer instead of re-issuing the read, so a stream
4325
+ * that had to be consumed to be sized is not consumed twice.
4326
+ */
4327
+ var BufferedQueryStream = class BufferedQueryStream extends QueryStream {
4328
+ constructor(entries, order, indexFields, equalityIndexFilter) {
4270
4329
  super();
4271
- this.inner = inner;
4272
- this.limit = limit;
4330
+ this.entries = entries;
4331
+ this.order = order;
4332
+ this.indexFields = indexFields;
4333
+ this.equalityIndexFilter = equalityIndexFilter;
4273
4334
  }
4274
4335
  iterWithKeys() {
4275
- const iterable = this.inner.iterWithKeys();
4276
- const max = this.limit;
4336
+ const entries = this.entries;
4277
4337
  return { [Symbol.asyncIterator]() {
4278
- const iterator = iterable[Symbol.asyncIterator]();
4279
- let seen = 0;
4280
- return { async next() {
4281
- if (seen >= max) return {
4338
+ let index = 0;
4339
+ return { next() {
4340
+ if (index >= entries.length) return Promise.resolve({
4282
4341
  done: true,
4283
4342
  value: void 0
4284
- };
4285
- const next = await iterator.next();
4286
- if (!next.done) seen += 1;
4287
- return next;
4343
+ });
4344
+ const value = entries[index];
4345
+ index += 1;
4346
+ return Promise.resolve({
4347
+ done: false,
4348
+ value
4349
+ });
4288
4350
  } };
4289
4351
  } };
4290
4352
  }
4291
4353
  narrow(indexBounds) {
4292
- return new LimitedQueryStream(this.inner.narrow(indexBounds), this.limit);
4354
+ return new BufferedQueryStream(this.entries.filter(([, indexKey]) => indexKeyWithinBounds(indexKey, indexBounds)), this.order, this.indexFields, this.equalityIndexFilter);
4355
+ }
4356
+ getOrder() {
4357
+ return this.order;
4358
+ }
4359
+ getIndexFields() {
4360
+ return this.indexFields;
4361
+ }
4362
+ getEqualityIndexFilter() {
4363
+ return this.equalityIndexFilter;
4364
+ }
4365
+ };
4366
+ const PIPELINE_LIMIT_ORDINAL_FIELD = "__kitcn_limit_ordinal";
4367
+ /** Cap a stream at its first `limit` matching documents without eager reads. */
4368
+ var LimitedMatchesQueryStream = class LimitedMatchesQueryStream extends QueryStream {
4369
+ constructor(inner, limit, priorMatches = 0) {
4370
+ super();
4371
+ this.inner = inner;
4372
+ this.limit = limit;
4373
+ this.priorMatches = priorMatches;
4374
+ }
4375
+ iterWithKeys() {
4376
+ const inner = this.inner;
4377
+ const limit = this.limit;
4378
+ const priorMatches = this.priorMatches;
4379
+ return { async *[Symbol.asyncIterator]() {
4380
+ let matched = priorMatches;
4381
+ if (matched >= limit) return;
4382
+ for await (const [doc, indexKey] of inner.iterWithKeys()) {
4383
+ if (doc !== null) matched += 1;
4384
+ yield [doc, [...indexKey, matched]];
4385
+ if (matched >= limit) return;
4386
+ }
4387
+ } };
4388
+ }
4389
+ narrow(indexBounds) {
4390
+ const innerFieldCount = this.inner.getIndexFields().length;
4391
+ const ordinal = (this.getOrder() === "asc" ? indexBounds.lowerBound : indexBounds.upperBound)[innerFieldCount];
4392
+ const priorMatches = typeof ordinal === "number" && Number.isInteger(ordinal) && ordinal >= 0 ? Math.max(this.priorMatches, ordinal) : this.priorMatches;
4393
+ return new LimitedMatchesQueryStream(this.inner.narrow({
4394
+ lowerBound: indexBounds.lowerBound.slice(0, innerFieldCount),
4395
+ lowerBoundInclusive: indexBounds.lowerBoundInclusive,
4396
+ upperBound: indexBounds.upperBound.slice(0, innerFieldCount),
4397
+ upperBoundInclusive: indexBounds.upperBoundInclusive
4398
+ }), this.limit, priorMatches);
4293
4399
  }
4294
4400
  getOrder() {
4295
4401
  return this.inner.getOrder();
4296
4402
  }
4297
4403
  getIndexFields() {
4298
- return this.inner.getIndexFields();
4404
+ return [...this.inner.getIndexFields(), PIPELINE_LIMIT_ORDINAL_FIELD];
4299
4405
  }
4300
4406
  getEqualityIndexFilter() {
4301
4407
  return this.inner.getEqualityIndexFilter();
@@ -4450,7 +4556,7 @@ var GelRelationalQuery = class extends QueryPromise {
4450
4556
  return first ?? null;
4451
4557
  }
4452
4558
  async _applyRlsSelectFilter(rows, tableConfig) {
4453
- if (!rows.length || !tableConfig) return rows;
4559
+ if (!tableConfig) return rows;
4454
4560
  return await filterSelectRows({
4455
4561
  table: tableConfig.table,
4456
4562
  rows,
@@ -4514,21 +4620,29 @@ var GelRelationalQuery = class extends QueryPromise {
4514
4620
  return Object.values(this.schema).find((table) => table.name === dbName);
4515
4621
  }
4516
4622
  _matchLike(value, pattern, caseInsensitive) {
4517
- const targetValue = caseInsensitive ? value.toLowerCase() : value;
4518
- const targetPattern = caseInsensitive ? pattern.toLowerCase() : pattern;
4519
- if (targetPattern.startsWith("%") && targetPattern.endsWith("%")) {
4520
- const substring = targetPattern.slice(1, -1);
4521
- return targetValue.includes(substring);
4522
- }
4523
- if (targetPattern.startsWith("%")) {
4524
- const suffix = targetPattern.slice(1);
4525
- return targetValue.endsWith(suffix);
4526
- }
4527
- if (targetPattern.endsWith("%")) {
4528
- const prefix = targetPattern.slice(0, -1);
4529
- return targetValue.startsWith(prefix);
4623
+ return matchLikePattern(value, pattern, caseInsensitive);
4624
+ }
4625
+ /**
4626
+ * True when the expression can be fully enforced by Convex's own `.filter()`.
4627
+ *
4628
+ * `_toConvexExpression` compiles the string/array operators to `() => true`
4629
+ * because Convex filters cannot run JavaScript string methods; those are
4630
+ * evaluated later by `_evaluatePostFetchFilter`. Pushing such an expression
4631
+ * into `.filter()` is not merely useless, it is wrong in two ways: a `take()`
4632
+ * downstream spends its budget on rows that have not been filtered yet, and a
4633
+ * surrounding `NOT` turns the `true` placeholder into `q.not(true)`, which
4634
+ * matches nothing. So the caller must know whether Convex can carry the whole
4635
+ * expression before relying on it.
4636
+ */
4637
+ _isConvexEnforceableFilter(filter) {
4638
+ if (filter.type === "binary") return !POST_FETCH_ONLY_OPERATORS.has(filter.operator);
4639
+ if (filter.type === "unary") {
4640
+ const [operand] = filter.operands;
4641
+ if (isFieldReference(operand)) return true;
4642
+ return this._isConvexEnforceableFilter(operand);
4530
4643
  }
4531
- return targetValue === targetPattern;
4644
+ if (filter.type === "logical") return filter.operands.every((operand) => this._isConvexEnforceableFilter(operand));
4645
+ return true;
4532
4646
  }
4533
4647
  /**
4534
4648
  * Evaluate a filter expression against a fetched row
@@ -4572,14 +4686,14 @@ var GelRelationalQuery = class extends QueryPromise {
4572
4686
  case "contains":
4573
4687
  if (typeof fieldValue !== "string") return false;
4574
4688
  return fieldValue.includes(normalizedValue);
4575
- case "eq": return fieldValue === normalizedValue;
4576
- case "ne": return fieldValue !== normalizedValue;
4689
+ case "eq": return filterValuesEqual(fieldValue, normalizedValue);
4690
+ case "ne": return !filterValuesEqual(fieldValue, normalizedValue);
4577
4691
  case "gt": return fieldValue > comparableValue;
4578
4692
  case "gte": return fieldValue >= comparableValue;
4579
4693
  case "lt": return fieldValue < comparableValue;
4580
4694
  case "lte": return fieldValue <= comparableValue;
4581
- case "inArray": return normalizedValue.includes(fieldValue);
4582
- case "notInArray": return !normalizedValue.includes(fieldValue);
4695
+ case "inArray": return filterValueInList(fieldValue, normalizedValue);
4696
+ case "notInArray": return !filterValueInList(fieldValue, normalizedValue);
4583
4697
  case "arrayContains":
4584
4698
  if (!Array.isArray(fieldValue)) return false;
4585
4699
  return normalizedValue.every((item) => fieldValue.includes(item));
@@ -4627,8 +4741,8 @@ var GelRelationalQuery = class extends QueryPromise {
4627
4741
  if (this._isPlaceholder(filter) || this._isSQLWrapper(filter)) throw new Error("SQL placeholders are not supported in Convex filters.");
4628
4742
  if (filter instanceof Date) return fieldValue === filter.getTime();
4629
4743
  if (filter === null || typeof filter !== "object" || Array.isArray(filter)) {
4630
- if (fieldName) return fieldValue === this._normalizeComparableValue(fieldName, filter);
4631
- return fieldValue === filter;
4744
+ if (fieldName) return filterValuesEqual(fieldValue, this._normalizeComparableValue(fieldName, filter));
4745
+ return filterValuesEqual(fieldValue, filter);
4632
4746
  }
4633
4747
  const entries = Object.entries(filter);
4634
4748
  if (!entries.length) return true;
@@ -4661,7 +4775,7 @@ var GelRelationalQuery = class extends QueryPromise {
4661
4775
  continue;
4662
4776
  }
4663
4777
  const normalized = fieldName ? this._normalizeComparableValue(fieldName, value) : value;
4664
- results.push(normalized.includes(fieldValue));
4778
+ results.push(filterValueInList(fieldValue, normalized));
4665
4779
  continue;
4666
4780
  }
4667
4781
  case "notIn": {
@@ -4670,7 +4784,7 @@ var GelRelationalQuery = class extends QueryPromise {
4670
4784
  continue;
4671
4785
  }
4672
4786
  const normalized = fieldName ? this._normalizeComparableValue(fieldName, value) : value;
4673
- results.push(!normalized.includes(fieldValue));
4787
+ results.push(!filterValueInList(fieldValue, normalized));
4674
4788
  continue;
4675
4789
  }
4676
4790
  case "arrayContains":
@@ -4744,10 +4858,10 @@ var GelRelationalQuery = class extends QueryPromise {
4744
4858
  results.push(fieldValue.includes(value));
4745
4859
  continue;
4746
4860
  case "eq":
4747
- results.push(fieldValue === (fieldName ? this._normalizeComparableValue(fieldName, value) : value));
4861
+ results.push(filterValuesEqual(fieldValue, fieldName ? this._normalizeComparableValue(fieldName, value) : value));
4748
4862
  continue;
4749
4863
  case "ne":
4750
- results.push(fieldValue !== (fieldName ? this._normalizeComparableValue(fieldName, value) : value));
4864
+ results.push(!filterValuesEqual(fieldValue, fieldName ? this._normalizeComparableValue(fieldName, value) : value));
4751
4865
  continue;
4752
4866
  case "gt":
4753
4867
  results.push(fieldValue > (fieldName ? this._normalizeComparableValue(fieldName, value) : value));
@@ -5215,6 +5329,67 @@ var GelRelationalQuery = class extends QueryPromise {
5215
5329
  row[config.alias] = nested;
5216
5330
  }
5217
5331
  }
5332
+ /**
5333
+ * Validate role-scoped policies for every table this read plan touches before
5334
+ * relations are loaded. Relation loaders skip work when a parent page is
5335
+ * empty, so per-row evaluation alone would make a misconfigured table fail
5336
+ * only once it holds rows. Mirrors the `_loadRelations` depth budget so the
5337
+ * plan walked here matches the plan that would be executed.
5338
+ */
5339
+ _assertRlsSelectPlan(withConfig, tableConfig, edges, depth, maxDepth) {
5340
+ assertRlsRolesResolvable({
5341
+ table: tableConfig.table,
5342
+ operation: "select",
5343
+ rls: this.rls
5344
+ });
5345
+ if (!withConfig || depth >= maxDepth) return;
5346
+ for (const [relationName, relationConfig] of Object.entries(withConfig)) {
5347
+ if (relationName === "_count") {
5348
+ this._assertRelationCountRlsPlan(relationConfig, edges);
5349
+ continue;
5350
+ }
5351
+ const edge = edges.find((entry) => entry.edgeName === relationName);
5352
+ if (!edge) continue;
5353
+ if (edge.through) {
5354
+ const throughTableConfig = this._getTableConfigByDbName(edge.through.table);
5355
+ if (throughTableConfig) assertRlsRolesResolvable({
5356
+ table: throughTableConfig.table,
5357
+ operation: "select",
5358
+ rls: this.rls
5359
+ });
5360
+ }
5361
+ const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
5362
+ if (!targetTableConfig) continue;
5363
+ let nestedWith;
5364
+ if (relationConfig && typeof relationConfig === "object") {
5365
+ const configuredWith = relationConfig.with;
5366
+ if (this._isRecord(configuredWith)) nestedWith = { ...configuredWith };
5367
+ const relationWhere = relationConfig.where;
5368
+ if (relationWhere && typeof relationWhere !== "function") {
5369
+ const filterWith = this._buildFilterWithConfig(relationWhere, targetTableConfig);
5370
+ if (Object.keys(filterWith).length > 0) if (nestedWith) this._mergeWithConfig(nestedWith, filterWith);
5371
+ else nestedWith = filterWith;
5372
+ }
5373
+ }
5374
+ this._assertRlsSelectPlan(nestedWith, targetTableConfig, this._getTargetTableEdges(edge.targetTable), depth + 1, maxDepth);
5375
+ }
5376
+ }
5377
+ _assertRelationCountRlsPlan(relationCountConfig, edges) {
5378
+ if (!this._isRecord(relationCountConfig)) return;
5379
+ for (const [relationName, relationSelection] of Object.entries(relationCountConfig)) {
5380
+ if (relationSelection === void 0 || relationSelection === false) continue;
5381
+ const edge = edges.find((entry) => entry.edgeName === relationName);
5382
+ if (!edge) continue;
5383
+ const where = this._coerceRelationCountWhere(relationName, relationSelection);
5384
+ if (edge.through) {
5385
+ const throughTableConfig = this._getTableConfigByDbName(edge.through.table);
5386
+ if (throughTableConfig) ensureCountAllowedForRls(throughTableConfig, this.rls?.mode);
5387
+ if (this._isEmptyWhere(where) || where === void 0) continue;
5388
+ }
5389
+ const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
5390
+ if (targetTableConfig) ensureCountAllowedForRls(targetTableConfig, this.rls?.mode);
5391
+ }
5392
+ }
5218
5393
  async _finalizeRows(rows) {
5219
5394
  const polymorphicState = this._resolvePolymorphicFinalizeState();
5220
5395
  const requestedWith = this.config.with;
@@ -5223,6 +5398,7 @@ var GelRelationalQuery = class extends QueryPromise {
5223
5398
  const extrasConfig = this.config.extras;
5224
5399
  const resolvedExtras = typeof extrasConfig === "function" ? extrasConfig(tableColumns) : extrasConfig;
5225
5400
  if (polymorphicState) this._assertPolymorphicAliasCollisions(polymorphicState.configs, requestedWith, resolvedExtras);
5401
+ this._assertRlsSelectPlan(effectiveWith, this.tableConfig, this.edgeMetadata, 0, 3);
5226
5402
  let rowsWithRelations = rows;
5227
5403
  if (effectiveWith) rowsWithRelations = await this._loadRelations(rowsWithRelations, effectiveWith, 0, 3, this.edgeMetadata, this.tableConfig);
5228
5404
  if (polymorphicState) this._synthesizePolymorphicRows(rowsWithRelations, polymorphicState.configs);
@@ -5317,6 +5493,33 @@ var GelRelationalQuery = class extends QueryPromise {
5317
5493
  }
5318
5494
  throw new Error(`${context} where callback must return a filter expression or predicate(...).`);
5319
5495
  }
5496
+ /**
5497
+ * Read an id-only `where` through the primary key instead of scanning for it.
5498
+ *
5499
+ * The where-clause compiler is built from declared indexes only, and `_id` is
5500
+ * never one of them, so an `id` filter can never be index-selected: it lands
5501
+ * in the post-filters and the stream walks the creation-time index until it
5502
+ * happens on the row. `db.get()` reads exactly the rows asked for, so the ids
5503
+ * are fetched directly and replayed as a creation-time-ordered stream — the
5504
+ * order the scan would have produced, so stage order and cursors are the same.
5505
+ *
5506
+ * Returns null when something else already owns the read: a pinned index, an
5507
+ * index the compiler did select, a `where(predicate)`, or an `orderBy` that
5508
+ * walks a different index.
5509
+ */
5510
+ async _buildIdLookupStream(params) {
5511
+ const { configuredIndex, idLookup, order, queryConfig, wherePredicate } = params;
5512
+ if (!idLookup || wherePredicate || configuredIndex?.name || queryConfig.index) return null;
5513
+ const primaryOrder = queryConfig.order?.[0];
5514
+ if (primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD) return null;
5515
+ const ids = idLookup.kind === "in" ? Array.from(new Map(idLookup.ids.map((id) => [String(id), id])).values()) : [idLookup.id];
5516
+ const rows = (await this._mapWithConcurrency(ids, async (id) => {
5517
+ return this._getById(this.tableConfig.name, id);
5518
+ })).filter((row) => !!row);
5519
+ rows.sort((a, b) => compareValues(a[INTERNAL_CREATION_TIME_FIELD], b[INTERNAL_CREATION_TIME_FIELD]) || compareValues(a[INTERNAL_ID_FIELD], b[INTERNAL_ID_FIELD]));
5520
+ if (order === "desc") rows.reverse();
5521
+ return new BufferedQueryStream(rows.map((row) => [row, [row[INTERNAL_CREATION_TIME_FIELD], row[INTERNAL_ID_FIELD]]]), order, [INTERNAL_CREATION_TIME_FIELD, INTERNAL_ID_FIELD], []);
5522
+ }
5320
5523
  _buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex) {
5321
5524
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
5322
5525
  let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
@@ -5340,6 +5543,38 @@ var GelRelationalQuery = class extends QueryPromise {
5340
5543
  });
5341
5544
  return streamQuery;
5342
5545
  }
5546
+ /**
5547
+ * Stream equivalent of the `db.query(...)` chain, used when a post-fetch
5548
+ * filter has to run in JavaScript before `limit` or a page boundary can be
5549
+ * applied. `filterWith` evaluates the predicate as rows are pulled, so
5550
+ * `take`/`paginate` size by matches instead of by scanned rows.
5551
+ *
5552
+ * It deliberately mirrors the index and order decisions the caller already
5553
+ * made for the plain query rather than re-deriving them, so switching to the
5554
+ * stream cannot change which index is scanned or in what direction.
5555
+ *
5556
+ * Returns null when the schema definition needed by `stream()` is missing;
5557
+ * the caller then falls back to its plain-query path.
5558
+ */
5559
+ _buildResidualFilterStream(params) {
5560
+ const schemaDefinition = this.schema[OrmSchemaDefinition];
5561
+ if (!schemaDefinition) return null;
5562
+ const { queryConfig, configuredIndex, membershipFilter, orderIndexName, primaryOrder, fallbackOrder } = params;
5563
+ let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
5564
+ if (queryConfig.index) streamQuery = streamQuery.withIndex(queryConfig.index.name, (q) => {
5565
+ let indexQuery = q;
5566
+ for (const filter of queryConfig.index.filters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
5567
+ return indexQuery;
5568
+ });
5569
+ else if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
5570
+ else if (orderIndexName) streamQuery = streamQuery.withIndex(orderIndexName, (q) => q);
5571
+ streamQuery = streamQuery.order(primaryOrder ? primaryOrder.direction : fallbackOrder);
5572
+ streamQuery = streamQuery.filterWith(async (row) => {
5573
+ for (const filter of queryConfig.postFilters) if (!this._evaluatePostFetchFilter(row, filter)) return false;
5574
+ return membershipFilter ? await membershipFilter(row) : true;
5575
+ });
5576
+ return streamQuery;
5577
+ }
5343
5578
  _buildUnionSourceStream(source, fallbackOrder) {
5344
5579
  const configuredIndex = this.configuredIndex;
5345
5580
  this._assertWhereIndexRequirement({
@@ -5365,11 +5600,18 @@ var GelRelationalQuery = class extends QueryPromise {
5365
5600
  const targetFields = edge.cardinality === "one" ? edge.targetFields.length > 0 ? edge.targetFields : ["_id"] : edge.targetFields.length > 0 ? edge.targetFields : [edge.fieldName];
5366
5601
  const targetTableConfig = this._getTableConfigByDbName(edge.targetTable);
5367
5602
  if (!targetTableConfig) throw new Error(`Pipeline flatMap target table '${edge.targetTable}' not found.`);
5603
+ assertRlsRolesResolvable({
5604
+ table: targetTableConfig.table,
5605
+ operation: "select",
5606
+ rls: this.rls
5607
+ });
5368
5608
  const strict = this.tableConfig.strict !== false;
5369
5609
  const indexName = targetFields.length === 1 && targetFields[0] === "_id" ? "by_id" : findRelationIndex(targetTableConfig.table, targetFields, `${this.tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
5370
5610
  const outerOrder = sourceStream.getOrder();
5371
5611
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
5372
5612
  const innerIndexFields = getIndexFields(edge.targetTable, indexName ?? "by_creation_time" ?? "by_creation_time", schemaDefinition);
5613
+ if (stage.limit !== void 0 && (!Number.isInteger(stage.limit) || stage.limit < 1)) throw new Error("pipeline.flatMap.limit must be a positive integer");
5614
+ const mappedIndexFields = stage.limit === void 0 ? innerIndexFields : [...innerIndexFields, PIPELINE_LIMIT_ORDINAL_FIELD];
5373
5615
  const stageWherePredicate = this._buildTableFilterPredicate(stage.where, targetTableConfig);
5374
5616
  this._assertWhereIndexRequirement({
5375
5617
  where: stage.where,
@@ -5379,21 +5621,21 @@ var GelRelationalQuery = class extends QueryPromise {
5379
5621
  });
5380
5622
  return sourceStream.flatMap(async (parent) => {
5381
5623
  const values = sourceFields.map((field) => parent[field]);
5382
- if (values.some((value) => value === null || value === void 0)) return new EmptyStream(outerOrder, innerIndexFields);
5624
+ if (values.some((value) => value === null || value === void 0)) return new EmptyStream(outerOrder, mappedIndexFields);
5383
5625
  let inner = stream(this.db, schemaDefinition).query(edge.targetTable);
5384
5626
  if (indexName) inner = inner.withIndex(indexName, (q) => this._applyEqBounds(q, targetFields, values));
5385
5627
  inner = inner.order(outerOrder);
5386
5628
  if (stageWherePredicate) inner = inner.filterWith(stageWherePredicate);
5387
- if (stage.limit !== void 0) {
5388
- if (!Number.isInteger(stage.limit) || stage.limit < 1) throw new Error("pipeline.flatMap.limit must be a positive integer");
5389
- inner = new LimitedQueryStream(inner, stage.limit);
5390
- }
5629
+ if (isRlsEnabled(targetTableConfig.table)) inner = inner.filterWith(async (row) => {
5630
+ return (await this._applyRlsSelectFilter([row], targetTableConfig)).length === 1;
5631
+ });
5632
+ if (stage.limit !== void 0) inner = new LimitedMatchesQueryStream(inner, stage.limit);
5391
5633
  if (stage.includeParent ?? true) inner = inner.map(async (child) => ({
5392
5634
  parent,
5393
5635
  child
5394
5636
  }));
5395
5637
  return inner;
5396
- }, innerIndexFields);
5638
+ }, mappedIndexFields);
5397
5639
  }
5398
5640
  async _applyPipelineStages(baseStream, pipeline) {
5399
5641
  let streamQuery = baseStream;
@@ -6379,8 +6621,11 @@ var GelRelationalQuery = class extends QueryPromise {
6379
6621
  if (vectorSearchConfig) throw new Error("pageByKey cannot be combined with vectorSearch.");
6380
6622
  if (pipeline) throw new Error("pageByKey cannot be combined with pipeline.");
6381
6623
  }
6624
+ const preflightWith = this._resolveWithVariantsState(config.with, this._resolvePolymorphicFinalizeState()).effectiveWith;
6625
+ this._assertRlsSelectPlan(preflightWith, this.tableConfig, this.edgeMetadata, 0, 3);
6626
+ if (whereFilter) this._assertRlsSelectPlan(this._buildFilterWithConfig(whereFilter, this.tableConfig), this.tableConfig, this.edgeMetadata, 0, 3);
6382
6627
  const idLookup = this._extractIdOnlyWhere(whereFilter);
6383
- if (idLookup && !vectorSearchConfig && !searchConfig && !wherePredicate && !isCursorPaginated && configuredIndex === void 0) {
6628
+ if (idLookup && !vectorSearchConfig && !searchConfig && !wherePredicate && !isCursorPaginated && !pipeline && !pageByKey && endCursor === void 0 && configuredIndex === void 0) {
6384
6629
  const orderSpecs = this._orderBySpecs(config.orderBy);
6385
6630
  const offset = config.offset ?? 0;
6386
6631
  if (offset !== void 0 && typeof offset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
@@ -6395,7 +6640,7 @@ var GelRelationalQuery = class extends QueryPromise {
6395
6640
  const selectedRows = await this._finalizeRows(rows);
6396
6641
  return this._returnSelectedRows(selectedRows);
6397
6642
  }
6398
- const queryConfig = this._toConvexQuery(whereExpressionFromCallback);
6643
+ const queryConfig = this._toConvexQuery(whereExpressionFromCallback, configuredIndex);
6399
6644
  if (!searchConfig && !vectorSearchConfig && wherePredicate !== void 0 && !configuredIndex?.name) throw new Error("This where() with predicate(...) requires .withIndex(name, range?). Add .withIndex(...) before findMany/findFirst.");
6400
6645
  if (pageByKey) {
6401
6646
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
@@ -6433,7 +6678,23 @@ var GelRelationalQuery = class extends QueryPromise {
6433
6678
  if (!pipeline?.interleaveBy || pipeline.interleaveBy.length === 0) throw new Error("pipeline.interleaveBy is required when pipeline.union has multiple sources.");
6434
6679
  streamQuery = mergedStream(streams, pipeline.interleaveBy.map((field) => this._normalizePublicFieldName(field)));
6435
6680
  }
6436
- } else streamQuery = this._buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex);
6681
+ } else {
6682
+ if (isCursorPaginated && maxScan !== void 0 && idLookup?.kind === "in") throw new Error("An id IN pipeline cannot use maxScan because creation-order replay requires reading the complete ID list.");
6683
+ streamQuery = await this._buildIdLookupStream({
6684
+ configuredIndex,
6685
+ idLookup,
6686
+ order: fallbackOrder,
6687
+ queryConfig,
6688
+ wherePredicate
6689
+ }) ?? this._buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex);
6690
+ }
6691
+ const rootRlsEnabled = isRlsEnabled(this.tableConfig.table);
6692
+ if (rootRlsEnabled) {
6693
+ await this._applyRlsSelectFilter([], this.tableConfig);
6694
+ streamQuery = streamQuery.filterWith(async (row) => {
6695
+ return (await this._applyRlsSelectFilter([row], this.tableConfig)).length === 1;
6696
+ });
6697
+ }
6437
6698
  if (pipeline) streamQuery = await this._applyPipelineStages(streamQuery, pipeline);
6438
6699
  if (isCursorPaginated) {
6439
6700
  const paginationResult = await streamQuery.paginate({
@@ -6443,7 +6704,7 @@ var GelRelationalQuery = class extends QueryPromise {
6443
6704
  maxScan
6444
6705
  });
6445
6706
  return {
6446
- page: await this._finalizeRows(await this._applyRlsSelectFilter(paginationResult.page, this.tableConfig)),
6707
+ page: await this._finalizeRows(rootRlsEnabled ? paginationResult.page : await this._applyRlsSelectFilter(paginationResult.page, this.tableConfig)),
6447
6708
  continueCursor: paginationResult.continueCursor,
6448
6709
  isDone: paginationResult.isDone,
6449
6710
  pageStatus: paginationResult.pageStatus,
@@ -6455,7 +6716,7 @@ var GelRelationalQuery = class extends QueryPromise {
6455
6716
  const limit = this._resolveNonPaginatedLimit(config);
6456
6717
  let rows = limit === void 0 ? await streamQuery.collect() : await streamQuery.take(offset > 0 ? offset + limit : limit);
6457
6718
  if (offset > 0) rows = rows.slice(offset);
6458
- rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
6719
+ if (!rootRlsEnabled) rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
6459
6720
  const selectedRows = await this._finalizeRows(rows);
6460
6721
  return this._returnSelectedRows(selectedRows);
6461
6722
  }
@@ -6561,7 +6822,7 @@ var GelRelationalQuery = class extends QueryPromise {
6561
6822
  const orderField = primaryOrder.field;
6562
6823
  if (orderField === "_creationTime") query = query.order(primaryOrder.direction);
6563
6824
  else {
6564
- const orderIndex = getIndexes(this.tableConfig.table).find((idx) => idx.fields.includes(orderField)) ?? this.edgeMetadata.find((idx) => idx.indexFields.includes(orderField));
6825
+ const orderIndex = getIndexes(this.tableConfig.table).find((idx) => idx.fields[0] === orderField) ?? this.edgeMetadata.find((idx) => idx.indexFields[0] === orderField);
6565
6826
  if (orderIndex) {
6566
6827
  orderIndexName = "indexName" in orderIndex ? orderIndex.indexName : orderIndex.name;
6567
6828
  query = query.withIndex(orderIndexName, (q) => q);
@@ -6643,7 +6904,7 @@ var GelRelationalQuery = class extends QueryPromise {
6643
6904
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
6644
6905
  rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
6645
6906
  if (whereFilter) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, 3, this.config.with);
6646
- if (usePostFetchSort && postFetchOrders.length > 0) rows = rows.sort((a, b) => this._compareByOrderSpecs(a, b, postFetchOrders));
6907
+ if (postFetchOrders.length > 0) rows = rows.sort((a, b) => this._compareByOrderSpecs(a, b, postFetchOrders));
6647
6908
  const offset = config.offset ?? 0;
6648
6909
  if (typeof offset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
6649
6910
  const limit = this._resolveNonPaginatedLimit(config);
@@ -6652,6 +6913,12 @@ var GelRelationalQuery = class extends QueryPromise {
6652
6913
  const selectedRows = await this._finalizeRows(rows);
6653
6914
  return this._returnSelectedRows(selectedRows);
6654
6915
  }
6916
+ const matchesPostFetchMembership = async (row) => {
6917
+ const visibleRows = await this._applyRlsSelectFilter([row], this.tableConfig);
6918
+ if (visibleRows.length === 0) return false;
6919
+ if (!whereFilter) return true;
6920
+ return (await this._applyRelationsFilterToRows(visibleRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, 3, this.config.with)).length > 0;
6921
+ };
6655
6922
  if (isCursorPaginated) {
6656
6923
  if (queryConfig.strategy === "multiProbe") if (maxScan === void 0) {
6657
6924
  if (strict) throw new Error("Pagination with multi-probe index-union filters requires maxScan when strict=true. Add maxScan or make the query indexable.");
@@ -6691,6 +6958,7 @@ var GelRelationalQuery = class extends QueryPromise {
6691
6958
  const schemaDefinition = this.schema[OrmSchemaDefinition];
6692
6959
  if (!schemaDefinition) throw new Error("Pagination with maxScan requires defineSchema(). Ensure defineSchema(tables) was used with the same tables object passed to defineRelations.");
6693
6960
  let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
6961
+ if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
6694
6962
  if (queryConfig.order && primaryOrder) {
6695
6963
  if (needsPostFetchSortForPrimary) {
6696
6964
  if (strict) throw new Error(`Pagination: Field '${primaryOrder.field}' has no index. Add an index or disable strict.`);
@@ -6716,9 +6984,40 @@ var GelRelationalQuery = class extends QueryPromise {
6716
6984
  splitCursor: paginationResult.splitCursor
6717
6985
  };
6718
6986
  }
6719
- if (queryConfig.postFilters.length > 0) query = query.filter((q) => {
6987
+ const convexPostFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
6988
+ const hasResidualPostFilter = convexPostFilters.length !== queryConfig.postFilters.length;
6989
+ const residualPageStream = hasResidualPostFilter ? this._buildResidualFilterStream({
6990
+ queryConfig,
6991
+ configuredIndex,
6992
+ membershipFilter: matchesPostFetchMembership,
6993
+ orderIndexName,
6994
+ primaryOrder,
6995
+ fallbackOrder: "desc"
6996
+ }) : null;
6997
+ if (hasResidualPostFilter && !residualPageStream) this._getSchemaDefinitionOrThrow();
6998
+ if (residualPageStream) {
6999
+ if (queryConfig.order && primaryOrder) {
7000
+ if (needsPostFetchSortForPrimary && strict) throw new Error(`Pagination: Field '${primaryOrder.field}' has no index. Add an index or disable strict.`);
7001
+ if (needsPostFetchSortForPrimary) console.warn(`Pagination: Field '${primaryOrder.field}' has no index. Falling back to _creationTime ordering.`);
7002
+ 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.");
7003
+ }
7004
+ const paginationResult = await residualPageStream.paginate({
7005
+ cursor: cursor ?? null,
7006
+ endCursor: endCursor ?? void 0,
7007
+ limit: config.limit,
7008
+ maxScan
7009
+ });
7010
+ return {
7011
+ page: await this._finalizeRows(paginationResult.page),
7012
+ continueCursor: paginationResult.continueCursor,
7013
+ isDone: paginationResult.isDone,
7014
+ pageStatus: paginationResult.pageStatus,
7015
+ splitCursor: paginationResult.splitCursor
7016
+ };
7017
+ }
7018
+ if (convexPostFilters.length > 0) query = query.filter((q) => {
6720
7019
  let result = null;
6721
- for (const filter of queryConfig.postFilters) {
7020
+ for (const filter of convexPostFilters) {
6722
7021
  const expr = this._toConvexExpression(filter)(q);
6723
7022
  result = result ? q.and(result, expr) : expr;
6724
7023
  }
@@ -6745,9 +7044,11 @@ var GelRelationalQuery = class extends QueryPromise {
6745
7044
  isDone: paginationResult.isDone
6746
7045
  };
6747
7046
  }
6748
- if (queryConfig.postFilters.length > 0) query = query.filter((q) => {
7047
+ const convexPostFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
7048
+ const hasResidualPostFilter = convexPostFilters.length !== queryConfig.postFilters.length;
7049
+ if (convexPostFilters.length > 0) query = query.filter((q) => {
6749
7050
  let result = null;
6750
- for (const filter of queryConfig.postFilters) {
7051
+ for (const filter of convexPostFilters) {
6751
7052
  const expr = this._toConvexExpression(filter)(q);
6752
7053
  result = result ? q.and(result, expr) : expr;
6753
7054
  }
@@ -6757,11 +7058,29 @@ var GelRelationalQuery = class extends QueryPromise {
6757
7058
  if (typeof offset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
6758
7059
  const limit = this._resolveNonPaginatedLimit(config);
6759
7060
  const paginateAfterPostFetchSort = usePostFetchSort && postFetchOrders.length > 0;
6760
- let rows = limit === void 0 || paginateAfterPostFetchSort ? await query.collect() : await query.take(offset > 0 ? offset + limit : limit);
6761
- if (!paginateAfterPostFetchSort && offset > 0) rows = rows.slice(offset);
7061
+ const sizeAfterPostFilter = hasResidualPostFilter && !paginateAfterPostFetchSort;
7062
+ const residualLimitStream = sizeAfterPostFilter && limit !== void 0 ? this._buildResidualFilterStream({
7063
+ queryConfig,
7064
+ configuredIndex,
7065
+ membershipFilter: matchesPostFetchMembership,
7066
+ orderIndexName,
7067
+ primaryOrder,
7068
+ fallbackOrder: "asc"
7069
+ }) : null;
7070
+ let rows;
7071
+ if (residualLimitStream) rows = await residualLimitStream.take(offset > 0 ? offset + limit : limit);
7072
+ else if (limit === void 0 || paginateAfterPostFetchSort || hasResidualPostFilter) rows = await query.collect();
7073
+ else rows = await query.take(offset > 0 ? offset + limit : limit);
7074
+ if (!(paginateAfterPostFetchSort || sizeAfterPostFilter) && offset > 0) rows = rows.slice(offset);
6762
7075
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
6763
- rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
6764
- if (whereFilter) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, 3, this.config.with);
7076
+ if (!residualLimitStream) {
7077
+ rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
7078
+ if (whereFilter) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, 3, this.config.with);
7079
+ }
7080
+ if (sizeAfterPostFilter) {
7081
+ if (offset > 0) rows = rows.slice(offset);
7082
+ if (limit !== void 0) rows = rows.slice(0, limit);
7083
+ }
6765
7084
  if (usePostFetchSort && postFetchOrders.length > 0) rows = rows.sort((a, b) => this._compareByOrderSpecs(a, b, postFetchOrders));
6766
7085
  if (paginateAfterPostFetchSort) {
6767
7086
  if (offset > 0) rows = rows.slice(offset);
@@ -6774,7 +7093,7 @@ var GelRelationalQuery = class extends QueryPromise {
6774
7093
  * Convert query config to Convex query parameters
6775
7094
  * Phase 4 implementation with WhereClauseCompiler
6776
7095
  */
6777
- _toConvexQuery(whereExpressionOverride) {
7096
+ _toConvexQuery(whereExpressionOverride, configuredIndex) {
6778
7097
  const config = this.config;
6779
7098
  const tableIndexes = getIndexes(this.tableConfig.table).map((index) => ({
6780
7099
  indexName: index.name,
@@ -6783,12 +7102,20 @@ var GelRelationalQuery = class extends QueryPromise {
6783
7102
  const compiler = new WhereClauseCompiler(this.tableConfig.table.tableName, tableIndexes);
6784
7103
  let whereExpression = whereExpressionOverride;
6785
7104
  if (!whereExpression && config.where && typeof config.where !== "function") whereExpression = this._buildFilterExpression(config.where, this.tableConfig);
6786
- const compiled = compiler.compile(whereExpression);
7105
+ const planned = compiler.compile(whereExpression);
7106
+ const plannedUsesIndex = !!planned.selectedIndex && (planned.indexFilters.length > 0 || planned.probeFilters.length > 0);
7107
+ const compiled = !configuredIndex?.name || !plannedUsesIndex || planned.selectedIndex?.indexName === configuredIndex.name && !configuredIndex.range ? planned : {
7108
+ strategy: "none",
7109
+ selectedIndex: null,
7110
+ indexFilters: [],
7111
+ probeFilters: [],
7112
+ postFilters: whereExpression ? [whereExpression] : []
7113
+ };
6787
7114
  const result = {
6788
7115
  table: this.tableConfig.table.tableName,
6789
7116
  strategy: compiled.strategy,
6790
- probeFilters: compiled.probeFilters,
6791
- postFilters: compiled.postFilters
7117
+ probeFilters: [...compiled.probeFilters],
7118
+ postFilters: [...compiled.postFilters]
6792
7119
  };
6793
7120
  if (compiled.selectedIndex && (compiled.indexFilters.length > 0 || compiled.probeFilters.length > 0)) result.index = {
6794
7121
  name: compiled.selectedIndex.indexName,
@@ -7274,6 +7601,7 @@ var GelRelationalQuery = class extends QueryPromise {
7274
7601
  if (!targetTableConfig) throw new Error(`Relation '${relationName}' target table '${edge.targetTable}' not found.`);
7275
7602
  const relationDefinition = tableConfig.relations[relationName];
7276
7603
  const strict = tableConfig.strict !== false;
7604
+ const hasPostFetchTargetFilter = this.rls?.mode !== "skip" && isRlsEnabled(targetTableConfig.table) || Boolean(relationDefinition?.where) || Boolean(relationConfig && typeof relationConfig === "object" && "where" in relationConfig && relationConfig.where);
7277
7605
  let orderSpecs = [];
7278
7606
  if (relationConfig && typeof relationConfig === "object" && "orderBy" in relationConfig) {
7279
7607
  let orderByValue = relationConfig.orderBy;
@@ -7294,17 +7622,34 @@ var GelRelationalQuery = class extends QueryPromise {
7294
7622
  if (effectivePerParentLimit !== void 0) result = result.slice(0, effectivePerParentLimit);
7295
7623
  return result;
7296
7624
  };
7625
+ const applyPostFetchTargetFilters = async (candidateTargets) => {
7626
+ let filteredTargets = await this._applyRlsSelectFilter(candidateTargets, targetTableConfig);
7627
+ if (relationDefinition?.where) filteredTargets = filteredTargets.filter((target) => this._evaluateTableFilter(target, targetTableConfig, relationDefinition.where));
7628
+ if (relationConfig && typeof relationConfig === "object" && "where" in relationConfig) {
7629
+ const whereFilter = relationConfig.where;
7630
+ if (typeof whereFilter === "function") {
7631
+ const whereExpression = this._resolveWhereCallbackExpression(whereFilter, targetTableConfig, { context: "relation" });
7632
+ if (whereExpression && !this._isPredicateWhereClause(whereExpression)) filteredTargets = filteredTargets.filter((target) => this._evaluatePostFetchFilter(target, whereExpression));
7633
+ } else if (whereFilter) {
7634
+ const targetEdges = this._getTargetTableEdges(edge.targetTable);
7635
+ filteredTargets = await this._applyRelationsFilterToRows(filteredTargets, targetTableConfig, whereFilter, targetEdges, depth + 1, maxDepth, relationConfig.with);
7636
+ }
7637
+ }
7638
+ return filteredTargets;
7639
+ };
7297
7640
  let targets = [];
7298
7641
  let throughBySourceKey;
7642
+ let targetFiltersApplied = false;
7299
7643
  if (edge.through) {
7300
7644
  const throughTableConfig = this._getTableConfigByDbName(edge.through.table);
7301
7645
  if (!throughTableConfig) throw new Error(`Relation '${relationName}' through table '${edge.through.table}' not found.`);
7302
7646
  const throughIndexName = findRelationIndex(throughTableConfig.table, edge.through.sourceFields, `${tableConfig.name}.${relationName}`, edge.through.table, strict, this.allowFullScan);
7303
7647
  const entries = Array.from(sourceKeyMap.entries());
7304
7648
  const throughRowsPerSource = await this._mapWithConcurrency(entries, async ([key, values]) => {
7649
+ const query = this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName);
7305
7650
  return {
7306
7651
  key,
7307
- rows: await this._queryByFields(this.db.query(edge.through.table), edge.through.sourceFields, values, throughIndexName).collect()
7652
+ rows: await this._applyRlsSelectFilter(await query.collect(), throughTableConfig)
7308
7653
  };
7309
7654
  });
7310
7655
  throughBySourceKey = /* @__PURE__ */ new Map();
@@ -7341,27 +7686,30 @@ var GelRelationalQuery = class extends QueryPromise {
7341
7686
  } else {
7342
7687
  const indexName = findRelationIndex(targetTableConfig.table, targetFields, `${tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
7343
7688
  const entries = Array.from(sourceKeyMap.entries());
7689
+ const streamPostFetchTargetFilters = orderSpecs.length === 0 && hasPostFetchTargetFilter && effectivePerParentLimit !== void 0;
7690
+ targetFiltersApplied = streamPostFetchTargetFilters;
7344
7691
  targets = (await this._mapWithConcurrency(entries, async ([, values]) => {
7345
7692
  const query = this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName);
7346
- if (orderSpecs.length === 0 && effectivePerParentLimit !== void 0) {
7693
+ if (orderSpecs.length === 0 && !hasPostFetchTargetFilter && effectivePerParentLimit !== void 0) {
7347
7694
  const fetchLimit = (perParentOffset ?? 0) + (effectivePerParentLimit ?? 0);
7348
7695
  return await query.take(fetchLimit);
7349
7696
  }
7697
+ if (streamPostFetchTargetFilters) {
7698
+ const visibleTargets = [];
7699
+ const fetchLimit = Math.max(perParentOffset ?? 0, 0) + (effectivePerParentLimit ?? 0);
7700
+ for await (const target of query) {
7701
+ const filtered = await applyPostFetchTargetFilters([target]);
7702
+ if (filtered.length > 0) {
7703
+ visibleTargets.push(filtered[0]);
7704
+ if (visibleTargets.length >= fetchLimit) break;
7705
+ }
7706
+ }
7707
+ return visibleTargets;
7708
+ }
7350
7709
  return await query.collect();
7351
7710
  })).flat();
7352
7711
  }
7353
- targets = await this._applyRlsSelectFilter(targets, targetTableConfig);
7354
- if (relationDefinition?.where) targets = targets.filter((target) => this._evaluateTableFilter(target, targetTableConfig, relationDefinition.where));
7355
- if (relationConfig && typeof relationConfig === "object" && "where" in relationConfig) {
7356
- const whereFilter = relationConfig.where;
7357
- if (typeof whereFilter === "function") {
7358
- const whereExpression = this._resolveWhereCallbackExpression(whereFilter, targetTableConfig, { context: "relation" });
7359
- if (whereExpression && !this._isPredicateWhereClause(whereExpression)) targets = targets.filter((target) => this._evaluatePostFetchFilter(target, whereExpression));
7360
- } else if (whereFilter) {
7361
- const targetEdges = this._getTargetTableEdges(edge.targetTable);
7362
- targets = await this._applyRelationsFilterToRows(targets, targetTableConfig, whereFilter, targetEdges, depth + 1, maxDepth, relationConfig.with);
7363
- }
7364
- }
7712
+ if (!targetFiltersApplied) targets = await applyPostFetchTargetFilters(targets);
7365
7713
  if (orderSpecs.length > 0) targets.sort((a, b) => this._compareByOrderSpecs(a, b, orderSpecs));
7366
7714
  if (relationConfig && typeof relationConfig === "object" && "with" in relationConfig) {
7367
7715
  const targetTableEdges = this._getTargetTableEdges(edge.targetTable);
@@ -7613,6 +7961,12 @@ var ConvexDeleteBuilder = class extends QueryPromise {
7613
7961
  if (!allowFullScan) throw new Error("update/delete without where() requires allowFullScan: true.");
7614
7962
  if (strict) console.warn("update/delete without where() is running with allowFullScan: true.");
7615
7963
  }
7964
+ const rls = ormContext?.rls;
7965
+ assertRlsRolesResolvable({
7966
+ table: this.table,
7967
+ operation: "delete",
7968
+ rls
7969
+ });
7616
7970
  let rows;
7617
7971
  let continueCursor = null;
7618
7972
  let isDone = true;
@@ -7714,7 +8068,6 @@ var ConvexDeleteBuilder = class extends QueryPromise {
7714
8068
  if (this.whereExpression) rows = rows.filter((row) => evaluateFilter(row, this.whereExpression));
7715
8069
  const results = [];
7716
8070
  let numAffected = 0;
7717
- const rls = ormContext?.rls;
7718
8071
  const foreignKeyGraph = ormContext?.foreignKeyGraph;
7719
8072
  if (!foreignKeyGraph) throw new Error("Foreign key actions require orm.db(ctx) configured from createOrm({ schema, ... }).");
7720
8073
  const cascadeMode = this.cascadeMode ?? (deleteMode === "soft" || deleteMode === "scheduled" ? "soft" : "hard");
@@ -8744,6 +9097,12 @@ var ConvexUpdateBuilder = class extends QueryPromise {
8744
9097
  if (!allowFullScan) throw new Error("update/delete without where() requires allowFullScan: true.");
8745
9098
  if (strict) console.warn("update/delete without where() is running with allowFullScan: true.");
8746
9099
  }
9100
+ const rls = ormContext?.rls;
9101
+ assertRlsRolesResolvable({
9102
+ table: this.table,
9103
+ operation: "update",
9104
+ rls
9105
+ });
8747
9106
  const onUpdateSet = {};
8748
9107
  for (const [columnName, builder] of Object.entries(getTableColumns$2(this.table))) {
8749
9108
  if (columnName in normalizedSetValues) continue;
@@ -8855,7 +9214,6 @@ var ConvexUpdateBuilder = class extends QueryPromise {
8855
9214
  maxRows
8856
9215
  });
8857
9216
  if (this.whereExpression) rows = rows.filter((row) => evaluateFilter(row, this.whereExpression));
8858
- const rls = ormContext?.rls;
8859
9217
  const foreignKeyGraph = ormContext?.foreignKeyGraph;
8860
9218
  if (!foreignKeyGraph) throw new Error("Foreign key actions require orm.db(ctx) configured from createOrm({ schema, ... }).");
8861
9219
  const updates = await Promise.all(rows.map(async (row) => {
@@ -10122,6 +10480,8 @@ function scheduledDeleteFactory(schema, edgeMetadata, scheduledMutationBatch) {
10122
10480
 
10123
10481
  //#endregion
10124
10482
  //#region src/orm/scheduled-mutation-batch.ts
10483
+ /** Column stamped by `softDeleteRow`; see mutation-utils.ts. */
10484
+ const DELETION_TIME_FIELD = "deletionTime";
10125
10485
  const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
10126
10486
  function scheduledMutationBatchFactory(schema, edgeMetadata, scheduledMutationBatch) {
10127
10487
  const tableByName = /* @__PURE__ */ new Map();
@@ -10185,12 +10545,17 @@ function scheduledMutationBatchFactory(schema, edgeMetadata, scheduledMutationBa
10185
10545
  const targetValues = decodeUndefinedDeep(args.targetValues);
10186
10546
  if (!targetValues || !Array.isArray(targetValues)) throw new Error("scheduledMutationBatch: targetValues are required for cascade work.");
10187
10547
  if (!args.foreignIndexName) throw new Error("scheduledMutationBatch: foreignIndexName is required for cascade work.");
10188
- const queryWithIndex = () => ctx.db.query(args.table).withIndex(args.foreignIndexName, (q) => {
10189
- let builder = q.eq(sourceColumns[0], targetValues[0]);
10190
- for (let i = 1; i < sourceColumns.length; i += 1) builder = builder.eq(sourceColumns[i], targetValues[i]);
10191
- return builder;
10192
- });
10193
10548
  const action = args.foreignAction ?? "no action";
10549
+ const skipSoftDeletedRows = workType === "cascade-delete" && action === "cascade" && (args.cascadeMode ?? "hard") === "soft";
10550
+ const queryWithIndex = () => {
10551
+ const indexed = ctx.db.query(args.table).withIndex(args.foreignIndexName, (q) => {
10552
+ let builder = q.eq(sourceColumns[0], targetValues[0]);
10553
+ for (let i = 1; i < sourceColumns.length; i += 1) builder = builder.eq(sourceColumns[i], targetValues[i]);
10554
+ return builder;
10555
+ });
10556
+ if (!skipSoftDeletedRows) return indexed;
10557
+ return indexed.filter((q) => q.or(q.eq(q.field(DELETION_TIME_FIELD), void 0), q.eq(q.field(DELETION_TIME_FIELD), null)));
10558
+ };
10194
10559
  const paged = await queryWithIndex().paginate({
10195
10560
  cursor: null,
10196
10561
  numItems: args.batchSize