kitcn 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/orm/index.js CHANGED
@@ -4,8 +4,8 @@ import { a as pretendRequired, i as pretend, n as deprecated } from "../validato
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
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";
7
+ import { a as getIndexFields, c as stream, d as isUnsetToken, f as unsetToken, i as concatStreams, l as streamCanOrderBy, n as EmptyStream, o as indexKeyWithinBounds, r as QueryStream, s as mergedStream, t as getByIdWithOrmQueryFallback, u as streamIndexRange } from "../query-context-DQLv58LH.js";
8
+ import { $ as getOrmContext, A as canUsePrimaryIdLookupCursor, B as enforceUniqueIndexes, Ct as isConvexEnforceableFilter, D as applyIncomingForeignKeyActionsOnDelete, E as applyDefaults, F as deserializeFilterExpression, G as evaluateFilter, H as ensureNonNullValues, I as encodeUndefinedDeep, J as getColumnName$1, K as extractPrimaryIdLookup, L as enforceCheckConstraints, M as collectPrimaryIdLookupRows, N as createForeignKeyProbeMemo, O as applyIncomingForeignKeyActionsOnUpdate, P as decodeUndefinedDeep, Q as getMutationExecutionMode, R as enforceForeignKeys, S as createCountError, St as convexAnd, T as ensureCountAllowedForRls, U as ensureNullableColumns, V as ensureDefaultColumns, W as evaluateCheckConstraintTriState, X as getMutationAsyncDelayMs, Y as getForeignKeys, Z as getMutationCollectionLimits, _t as toConvexFilter, at as hydrateDateFieldsForRead, b as COUNT_ERROR, bt as markLifecycleHookedTables, c as getAggregateIndexDefinitions, ct as patchReferencingRows, dt as selectReturningRowWithHydration, et as getTableColumns$2, ft as serializeFilterExpression, g as runInOrmWriteScope, gt as takeRowsWithinByteBudget, ht as stripUnsetFields, it as hardDeleteRow, j as collectMutationRowsBounded, k as buildForeignKeyGraph, l as getRankIndexDefinitions, lt as resolveOrmRuntimeDefaults, m as runInOrmWriteBatch, mt as splitReturningSelection, nt as getTableName, o as aggregateExtension, ot as normalizeDateFieldsForWrite, p as runInOrmUserCallback, pt as softDeleteRow, q as getChecks, rt as getUniqueIndexes, st as normalizeTemporalComparableValue, tt as getTableDeleteConfig, ut as returningSelectionReadsCreationTime, v as markOrmTransactionAnchor, vt as unsetFieldsOf, w as ensureAggregateAllowedForRls, wt as mapWithConcurrency, x as createAggregateError, xt as compileConvexFilter, y as AGGREGATE_ERROR, yt as hasLifecycleHooks, z as enforcePolymorphicWrite } from "../schema-BF4P0ZjS.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";
@@ -560,7 +560,7 @@ function policyExpressionPasses(row, expression) {
560
560
  async function resolveExpression(policy, checkType, ctx, table) {
561
561
  const candidate = checkType === "withCheck" ? policy.withCheck ?? policy.using : policy.using;
562
562
  if (!candidate) return;
563
- if (typeof candidate === "function") return await candidate(ctx, table);
563
+ if (typeof candidate === "function") return await runInOrmUserCallback(() => candidate(ctx, table));
564
564
  return candidate;
565
565
  }
566
566
  /**
@@ -703,13 +703,14 @@ async function filterSelectRows(options) {
703
703
  */
704
704
  const INDEX_ORDER_BONUS = 30;
705
705
  /**
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.
706
+ * Widest index union worth reading as one merged stream. A merge holds every
707
+ * probe's index range open at once, and past this width that fan-out costs more
708
+ * than reading the same ranges one after another.
709
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.
710
+ * Only the executor's business: it picks between a merged and a concatenated
711
+ * union. Width is never a reason to compile a scan instead of a union — the
712
+ * ranges an `in` opens are what bounds the read, and a table scan does not stop
713
+ * being a table scan because the list got longer.
713
714
  */
714
715
  const MAX_INDEX_UNION_PROBES = 64;
715
716
  var WhereClauseCompiler = class {
@@ -789,7 +790,7 @@ var WhereClauseCompiler = class {
789
790
  for (const term of terms) {
790
791
  if (term.type !== "binary" || term.operator !== "inArray") continue;
791
792
  const probePlan = this.tryCompileInArray(term);
792
- if (!probePlan || probePlan.probeFilters.length > MAX_INDEX_UNION_PROBES) continue;
793
+ if (!probePlan) continue;
793
794
  return {
794
795
  ...probePlan,
795
796
  postFilters: [expression]
@@ -1633,6 +1634,20 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
1633
1634
  * intervening write would make it stale.
1634
1635
  */
1635
1636
  _documentByNormalizedId = /* @__PURE__ */ new Map();
1637
+ /**
1638
+ * Single target documents resolved by an eq-pinned key during one execution,
1639
+ * keyed on the read itself: table, index, join columns and their values.
1640
+ *
1641
+ * `_documentByNormalizedId` only covers a join on the primary id. A relation
1642
+ * joined on any other column resolves its target with `.first()` instead, and
1643
+ * the same one-row-at-a-time membership predicate re-issues that read once per
1644
+ * drain. The index is fixed for the whole relation and the key is pinned by
1645
+ * `eq`, so the first row is a pure function of this key within an execution.
1646
+ *
1647
+ * Same scope and staleness argument as `_documentByNormalizedId`: not handed
1648
+ * to the next run by `_forExecution`, so an intervening write is still seen.
1649
+ */
1650
+ _firstDocumentByFieldKey = /* @__PURE__ */ new Map();
1636
1651
  constructor(schema, tableConfig, edgeMetadata, db, config, mode, _allEdges, rls, relationLoading, vectorSearchProvider, configuredIndex, countIndexReadiness) {
1637
1652
  super();
1638
1653
  this.schema = schema;
@@ -2699,34 +2714,70 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2699
2714
  for (let i = 0; i < fields.length; i += 1) builder = builder.eq(fields[i], values[i]);
2700
2715
  return builder;
2701
2716
  }
2702
- _buildTableFilterPredicate(where, tableConfig) {
2703
- if (!where) return null;
2704
- if (typeof where === "function") {
2705
- const whereResult = this._resolveWhereCallbackExpression(where, tableConfig, { context: "pipeline" });
2706
- if (!whereResult) return null;
2707
- if (this._isPredicateWhereClause(whereResult)) return async (row) => await whereResult.predicate(row);
2708
- return async (row) => this._evaluatePostFetchFilter(row, whereResult);
2709
- }
2710
- return async (row) => {
2711
- const expression = this._buildFilterExpression(where, tableConfig);
2712
- if (!expression) return true;
2713
- return this._evaluatePostFetchFilter(row, expression);
2714
- };
2717
+ /**
2718
+ * The row filter for an already-resolved pipeline stage `where`.
2719
+ *
2720
+ * Takes the resolved shape rather than the clause so the rows that are read
2721
+ * and the rows that are kept come from the same resolution. Resolving twice
2722
+ * would let a `where` callback that closes over changing state — a clock, a
2723
+ * counter bound the read with one expression and filter it with another,
2724
+ * dropping rows that the second expression matched but the first never read.
2725
+ */
2726
+ _buildResolvedWherePredicate(where) {
2727
+ if (where.kind === "none") return null;
2728
+ if (where.kind === "predicate") {
2729
+ const { clause } = where;
2730
+ return async (row) => await clause.predicate(row);
2731
+ }
2732
+ const { expression } = where;
2733
+ return async (row) => this._evaluatePostFetchFilter(row, expression);
2715
2734
  }
2716
- _assertWhereIndexRequirement(options) {
2717
- const { where, tableConfig, hasConfiguredIndex, context } = options;
2718
- if (!where) return;
2719
- let whereExpression;
2735
+ /**
2736
+ * What a pipeline stage `where` is, resolved once.
2737
+ *
2738
+ * Object clauses and callbacks that return an expression can be compiled and
2739
+ * therefore index-lowered. `predicate(...)` is opaque JavaScript and never
2740
+ * can be. Both the anchoring check and the index lowering need to tell those
2741
+ * apart, and a callback `where` should be run once per read rather than once
2742
+ * per caller that asks.
2743
+ */
2744
+ _resolvePipelineWhere(where, tableConfig) {
2745
+ if (!where) return { kind: "none" };
2720
2746
  if (typeof where === "function") {
2721
2747
  const result = this._resolveWhereCallbackExpression(where, tableConfig, { context: "pipeline" });
2722
- if (!result) return;
2723
- if (this._isPredicateWhereClause(result)) {
2724
- if (!hasConfiguredIndex) throw new Error(`${context} where uses predicate(...) and requires .withIndex(...).`);
2725
- return;
2726
- }
2727
- whereExpression = result;
2728
- } else whereExpression = this._buildFilterExpression(where, tableConfig);
2729
- if (!whereExpression) return;
2748
+ if (!result) return { kind: "none" };
2749
+ return this._isPredicateWhereClause(result) ? {
2750
+ kind: "predicate",
2751
+ clause: result
2752
+ } : {
2753
+ kind: "expression",
2754
+ expression: result
2755
+ };
2756
+ }
2757
+ const expression = this._buildFilterExpression(where, tableConfig);
2758
+ return expression ? {
2759
+ kind: "expression",
2760
+ expression
2761
+ } : { kind: "none" };
2762
+ }
2763
+ /**
2764
+ * Reject a `predicate(...)` stage `where` on a read nothing bounds.
2765
+ *
2766
+ * A predicate can only run after the row is read, so it costs one read per
2767
+ * candidate row. That is fine over an index range and unbounded over a table
2768
+ * scan, which is the same line `findMany` draws for a chain-level
2769
+ * `predicate(...)`. An object `where` is not held to it: the compiler
2770
+ * lowers what it can and post-filters the rest, exactly as the chain-level
2771
+ * read does.
2772
+ *
2773
+ * `remedy` is per call site because the anchor a caller can add differs: a
2774
+ * union source takes an index, a `flatMap` stage has no index option at all
2775
+ * and is anchored by the relation's own declared index.
2776
+ */
2777
+ _assertPipelineWhereIsAnchored(options) {
2778
+ const { where, isAnchored, context, remedy } = options;
2779
+ if (isAnchored || where.kind !== "predicate") return;
2780
+ throw new Error(`${context} where uses predicate(...), which is evaluated after each row is read, so it needs an index-bounded read. ${remedy}`);
2730
2781
  }
2731
2782
  _isFilterExpressionNode(value) {
2732
2783
  return typeof value === "object" && value !== null && "accept" in value && typeof value.accept === "function";
@@ -2823,16 +2874,23 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2823
2874
  /**
2824
2875
  * The compiled index union, as one ordered stream.
2825
2876
  *
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
2877
+ * Each probe is its own index range, so the union is only usable where its
2878
+ * order is the order the read has to produce. `mergedStream` orders by a
2879
+ * suffix of the index key, and a suffix may only drop key components the
2829
2880
  * probes all pin to a single value — so the requested field has to sit inside
2830
2881
  * the pinned run or immediately after it. Returns null when it does not, and
2831
2882
  * the caller falls back to the plan's plain index range or a bounded scan.
2883
+ *
2884
+ * Two executors, because a union that is too wide to merge is not too wide to
2885
+ * read. A merge registers every probe up front and holds them open for the
2886
+ * life of the read, which is what `MAX_INDEX_UNION_PROBES` refuses; past that
2887
+ * width the probes are concatenated instead, one open query at a time. What
2888
+ * the cap picks is therefore fan-out versus sequential — never index versus
2889
+ * table scan, which is a trade no read wins.
2832
2890
  */
2833
2891
  _buildProbeUnionStream(params) {
2834
2892
  const { probeFilters } = params;
2835
- if (probeFilters.length === 0 || probeFilters.length > MAX_INDEX_UNION_PROBES) return null;
2893
+ if (probeFilters.length === 0) return null;
2836
2894
  const streamIndexFields = getIndexFields(this.tableConfig.name, params.indexName, params.schemaDefinition);
2837
2895
  const pinned = this._indexEqPrefixCount({ probeFilters });
2838
2896
  let mergeOffset = -1;
@@ -2841,11 +2899,102 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2841
2899
  break;
2842
2900
  }
2843
2901
  if (mergeOffset === -1) return null;
2844
- return mergedStream(probeFilters.map((filters) => stream(this.db, params.schemaDefinition).query(this.tableConfig.name).withIndex(params.indexName, (q) => {
2902
+ const probeStream = (filters) => stream(this.db, params.schemaDefinition).query(this.tableConfig.name).withIndex(params.indexName, (q) => {
2845
2903
  let indexQuery = q;
2846
2904
  for (const filter of filters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
2847
2905
  return indexQuery;
2848
- }).order(params.order)), streamIndexFields.slice(mergeOffset));
2906
+ }).order(params.order);
2907
+ if (probeFilters.length > MAX_INDEX_UNION_PROBES) {
2908
+ if (mergeOffset !== 0) return null;
2909
+ const orderedProbes = this._orderDisjointProbes({
2910
+ probeFilters,
2911
+ indexField: streamIndexFields[0],
2912
+ order: params.order
2913
+ });
2914
+ if (!orderedProbes) return null;
2915
+ return concatStreams(orderedProbes.map(probeStream));
2916
+ }
2917
+ return mergedStream(probeFilters.map(probeStream), streamIndexFields.slice(mergeOffset));
2918
+ }
2919
+ /**
2920
+ * `probeFilters` in the order a scan of `indexField` reaches them, or null
2921
+ * when they cannot be proven to be pairwise-disjoint ranges on that field.
2922
+ *
2923
+ * Concatenation stands in for a merge only under those two properties, and
2924
+ * both have to come from the filters themselves rather than from trust in the
2925
+ * compiler that emitted them: overlapping probes would emit a row twice, and
2926
+ * probes handed over out of order would emit index keys backwards.
2927
+ */
2928
+ _orderDisjointProbes(params) {
2929
+ const ranges = [];
2930
+ for (const filters of params.probeFilters) {
2931
+ let lower = null;
2932
+ let upper = null;
2933
+ let usable = filters.length > 0;
2934
+ for (const filter of filters) {
2935
+ if (filter.type !== "binary") {
2936
+ usable = false;
2937
+ break;
2938
+ }
2939
+ const [field, rawValue] = filter.operands;
2940
+ if (!isFieldReference(field) || field.fieldName !== params.indexField || rawValue === void 0 || isFieldReference(rawValue)) {
2941
+ usable = false;
2942
+ break;
2943
+ }
2944
+ const value = this._normalizeComparableValue(field.fieldName, rawValue);
2945
+ switch (filter.operator) {
2946
+ case "eq":
2947
+ usable = lower === null && upper === null;
2948
+ lower = {
2949
+ inclusive: true,
2950
+ value
2951
+ };
2952
+ upper = {
2953
+ inclusive: true,
2954
+ value
2955
+ };
2956
+ break;
2957
+ case "gt":
2958
+ case "gte":
2959
+ usable = lower === null;
2960
+ lower = {
2961
+ inclusive: filter.operator === "gte",
2962
+ value
2963
+ };
2964
+ break;
2965
+ case "lt":
2966
+ case "lte":
2967
+ usable = upper === null;
2968
+ upper = {
2969
+ inclusive: filter.operator === "lte",
2970
+ value
2971
+ };
2972
+ break;
2973
+ default: usable = false;
2974
+ }
2975
+ if (!usable) break;
2976
+ }
2977
+ if (!usable) return null;
2978
+ ranges.push({
2979
+ filters,
2980
+ lower,
2981
+ upper
2982
+ });
2983
+ }
2984
+ ranges.sort((a, b) => {
2985
+ if (!(a.lower && b.lower)) return a.lower ? 1 : b.lower ? -1 : 0;
2986
+ return compareValues(a.lower.value, b.lower.value) || Number(!a.lower.inclusive) - Number(!b.lower.inclusive);
2987
+ });
2988
+ for (let i = 1; i < ranges.length; i += 1) {
2989
+ const previous = ranges[i - 1];
2990
+ const current = ranges[i];
2991
+ if (!(previous.upper && current.lower)) return null;
2992
+ const gap = compareValues(previous.upper.value, current.lower.value);
2993
+ if (gap > 0) return null;
2994
+ if (gap === 0 && previous.upper.inclusive && current.lower.inclusive) return null;
2995
+ }
2996
+ const ordered = ranges.map((range) => range.filters);
2997
+ return params.order === "asc" ? ordered : ordered.reverse();
2849
2998
  }
2850
2999
  /**
2851
3000
  * The read the compiled plan describes, as a stream, with nothing filtered
@@ -2872,7 +3021,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2872
3021
  indexName: probeIndex.name,
2873
3022
  probeFilters: queryConfig.probeFilters,
2874
3023
  order,
2875
- orderField: primaryOrder?.field ?? probeIndex.fields[0]
3024
+ orderField: params.probeOrderField ?? primaryOrder?.field ?? probeIndex.fields[0]
2876
3025
  });
2877
3026
  if (probeUnion) return {
2878
3027
  stream: probeUnion,
@@ -2945,22 +3094,82 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2945
3094
  });
2946
3095
  return streamQuery;
2947
3096
  }
2948
- _buildUnionSourceStream(source, fallbackOrder) {
3097
+ /**
3098
+ * One `pipeline.union` source, as a stream.
3099
+ *
3100
+ * The source's own `where` is compiled into an index plan first, so an
3101
+ * object `where` bounds the read instead of being discarded row by row after
3102
+ * it. Two rules keep that from changing what the union produces:
3103
+ *
3104
+ * - `_compileQueryPlan` discards a plan that would displace an index the
3105
+ * caller pinned, so a source's own range (a tenant scope, say) still wins.
3106
+ * - A merged union is ordered by `interleaveBy`, which only some index
3107
+ * shapes can supply. The lowered read is checked against that before it is
3108
+ * committed to, and the unlowered scan is used when it cannot serve the
3109
+ * merge — an index that saves reads is not worth an ordering that throws.
3110
+ *
3111
+ * The `where` stays applied in JS either way. The index range is a bound on
3112
+ * what is read, not a replacement for the predicate.
3113
+ */
3114
+ _buildUnionSourceStream(source, fallbackOrder, interleaveFields) {
2949
3115
  const sourceIndex = source.index ?? this.configuredIndex;
2950
- this._assertWhereIndexRequirement({
2951
- where: source.where,
2952
- tableConfig: this.tableConfig,
2953
- hasConfiguredIndex: Boolean(sourceIndex?.name),
2954
- context: "pipeline.union source"
3116
+ const sourceWhere = this._resolvePipelineWhere(source.where, this.tableConfig);
3117
+ this._assertPipelineWhereIsAnchored({
3118
+ where: sourceWhere,
3119
+ isAnchored: Boolean(sourceIndex?.name),
3120
+ context: "pipeline.union source",
3121
+ remedy: "Give the source an index: { name, range }, or pin .withIndex(name, range?) on the chain."
2955
3122
  });
2956
3123
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
2957
- let sourceStream = stream(this.db, schemaDefinition).query(this.tableConfig.name);
2958
- if (sourceIndex?.name) sourceStream = sourceStream.withIndex(sourceIndex.name, sourceIndex.range ? sourceIndex.range : (q) => q);
2959
- sourceStream = sourceStream.order(fallbackOrder);
2960
- const sourcePredicate = this._buildTableFilterPredicate(source.where, this.tableConfig);
3124
+ const buildRead = (whereExpression) => this._buildPlanStream({
3125
+ queryConfig: this._compileQueryPlan({
3126
+ whereExpression,
3127
+ configuredIndex: sourceIndex,
3128
+ orderSpecs: []
3129
+ }),
3130
+ configuredIndex: sourceIndex,
3131
+ order: fallbackOrder,
3132
+ orderIndexName: null,
3133
+ probeOrderField: interleaveFields?.[0],
3134
+ schemaDefinition
3135
+ }).stream;
3136
+ const planned = sourceWhere.kind === "expression" ? buildRead(sourceWhere.expression) : null;
3137
+ let sourceStream = planned !== null && (!interleaveFields || streamCanOrderBy(planned, interleaveFields)) ? planned : buildRead();
3138
+ const sourcePredicate = this._buildResolvedWherePredicate(sourceWhere);
2961
3139
  if (sourcePredicate) sourceStream = sourceStream.filterWith(sourcePredicate);
2962
3140
  return sourceStream;
2963
3141
  }
3142
+ /**
3143
+ * The index a `flatMap` stage `where` can ride on top of the join keys.
3144
+ *
3145
+ * The join is only correct while `targetFields` stay pinned by equality, and
3146
+ * a Convex range builder pins fields in index-key order with no gaps, so the
3147
+ * candidates are exactly the declared indexes that lead with `targetFields`
3148
+ * and carry at least one more field. Those trailing fields are what the
3149
+ * stage `where` is compiled against — the compiler never sees the join keys,
3150
+ * because their values are per parent and the index choice has to be made
3151
+ * once for all of them.
3152
+ *
3153
+ * Returns null when nothing is gained, and the caller keeps the relation's
3154
+ * own index plus the post-filter. A multi-probe plan is refused: each probe
3155
+ * is its own range, and a union of ranges per parent is a different stream
3156
+ * shape than the one `flatMap` was handed as `mappedIndexFields`.
3157
+ */
3158
+ _resolveFlatMapStageIndex(params) {
3159
+ const { where, targetTableConfig, targetFields } = params;
3160
+ if (where.kind !== "expression") return null;
3161
+ const candidates = getIndexes(targetTableConfig.table).filter((index) => index.fields.length > targetFields.length && targetFields.every((field, i) => index.fields[i] === field)).map((index) => ({
3162
+ indexName: index.name,
3163
+ indexFields: index.fields.slice(targetFields.length)
3164
+ }));
3165
+ if (candidates.length === 0) return null;
3166
+ const compiled = new WhereClauseCompiler(targetTableConfig.table.tableName, candidates).compile(where.expression);
3167
+ if (!compiled.selectedIndex || compiled.probeFilters.length > 0 || compiled.indexFilters.length === 0) return null;
3168
+ return {
3169
+ name: compiled.selectedIndex.indexName,
3170
+ filters: compiled.indexFilters
3171
+ };
3172
+ }
2964
3173
  async _applyFlatMapStage(sourceStream, stage) {
2965
3174
  const relationName = stage.relation;
2966
3175
  const edge = this.edgeMetadata.find((e) => e.edgeName === relationName);
@@ -2976,24 +3185,36 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
2976
3185
  rls: this.rls
2977
3186
  });
2978
3187
  const strict = this.tableConfig.strict !== false;
2979
- const indexName = targetFields.length === 1 && targetFields[0] === "_id" ? "by_id" : findRelationIndex(targetTableConfig.table, targetFields, `${this.tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
3188
+ const useGetById = targetFields.length === 1 && targetFields[0] === "_id";
3189
+ const relationIndexName = useGetById ? "by_id" : findRelationIndex(targetTableConfig.table, targetFields, `${this.tableConfig.name}.${relationName}`, edge.targetTable, strict, this.allowFullScan);
3190
+ const stageWhere = this._resolvePipelineWhere(stage.where, targetTableConfig);
3191
+ const stageIndex = useGetById ? null : this._resolveFlatMapStageIndex({
3192
+ where: stageWhere,
3193
+ targetTableConfig,
3194
+ targetFields
3195
+ });
3196
+ const indexName = stageIndex?.name ?? relationIndexName;
2980
3197
  const outerOrder = sourceStream.getOrder();
2981
3198
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
2982
3199
  const innerIndexFields = getIndexFields(edge.targetTable, indexName ?? "by_creation_time" ?? "by_creation_time", schemaDefinition);
2983
3200
  if (stage.limit !== void 0 && (!Number.isInteger(stage.limit) || stage.limit < 1)) throw new Error("pipeline.flatMap.limit must be a positive integer");
2984
3201
  const mappedIndexFields = stage.limit === void 0 ? innerIndexFields : [...innerIndexFields, PIPELINE_LIMIT_ORDINAL_FIELD];
2985
- const stageWherePredicate = this._buildTableFilterPredicate(stage.where, targetTableConfig);
2986
- this._assertWhereIndexRequirement({
2987
- where: stage.where,
2988
- tableConfig: targetTableConfig,
2989
- hasConfiguredIndex: Boolean(indexName),
2990
- context: `pipeline.flatMap(${relationName})`
3202
+ const stageWherePredicate = this._buildResolvedWherePredicate(stageWhere);
3203
+ this._assertPipelineWhereIsAnchored({
3204
+ where: stageWhere,
3205
+ isAnchored: Boolean(indexName),
3206
+ context: `pipeline.flatMap(${relationName})`,
3207
+ remedy: `flatMap takes no index of its own — add an index on ${edge.targetTable}(${targetFields.join(", ")}) so the join is anchored, or use an object where().`
2991
3208
  });
2992
3209
  return sourceStream.flatMap(async (parent) => {
2993
3210
  const values = sourceFields.map((field) => parent[field]);
2994
3211
  if (values.some((value) => value === null || value === void 0)) return new EmptyStream(outerOrder, mappedIndexFields);
2995
3212
  let inner = stream(this.db, schemaDefinition).query(edge.targetTable);
2996
- if (indexName) inner = inner.withIndex(indexName, (q) => this._applyEqBounds(q, targetFields, values));
3213
+ if (indexName) inner = inner.withIndex(indexName, (q) => {
3214
+ let range = this._applyEqBounds(q, targetFields, values);
3215
+ for (const filter of stageIndex?.filters ?? []) range = this._applyFilterToQuery(range, filter, targetTableConfig);
3216
+ return range;
3217
+ });
2997
3218
  inner = inner.order(outerOrder);
2998
3219
  if (stageWherePredicate) inner = inner.filterWith(stageWherePredicate);
2999
3220
  if (isRlsEnabled(targetTableConfig.table)) inner = inner.filterWith(async (row) => {
@@ -4095,12 +4316,14 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4095
4316
  let streamQuery;
4096
4317
  const unionSources = pipeline?.union ?? [];
4097
4318
  if (unionSources.length > 0) {
4098
- const streams = unionSources.map((source) => this._buildUnionSourceStream(source, fallbackOrder));
4099
- if (streams.length === 1) streamQuery = streams[0];
4100
- else {
4101
- if (!pipeline?.interleaveBy || pipeline.interleaveBy.length === 0) throw new Error("pipeline.interleaveBy is required when pipeline.union has multiple sources.");
4102
- streamQuery = mergedStream(streams, pipeline.interleaveBy.map((field) => this._normalizePublicFieldName(field)));
4319
+ let interleaveFields;
4320
+ if (unionSources.length > 1) {
4321
+ const declared = pipeline?.interleaveBy;
4322
+ if (!declared || declared.length === 0) throw new Error("pipeline.interleaveBy is required when pipeline.union has multiple sources.");
4323
+ interleaveFields = declared.map((field) => this._normalizePublicFieldName(field));
4103
4324
  }
4325
+ const streams = unionSources.map((source) => this._buildUnionSourceStream(source, fallbackOrder, interleaveFields));
4326
+ streamQuery = interleaveFields ? mergedStream(streams, interleaveFields) : streams[0];
4104
4327
  } else {
4105
4328
  const orderedIdList = isCursorPaginated && maxScan !== void 0 && idLookup?.kind === "in" ? queryConfig.order?.[0] : void 0;
4106
4329
  if (orderedIdList) {
@@ -4385,41 +4608,53 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4385
4608
  const selectedRows = await this._finalizeRows(rows);
4386
4609
  return this._returnSelectedRows(selectedRows);
4387
4610
  }
4611
+ const matchesPostFetchMembership = async (row) => {
4612
+ const visibleRows = await this._applyRlsSelectFilter([row], this.tableConfig);
4613
+ if (visibleRows.length === 0) return false;
4614
+ if (!whereFilter) return true;
4615
+ return (await this._applyRelationsFilterToRows(visibleRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with)).length > 0;
4616
+ };
4388
4617
  if (queryConfig.strategy === "multiProbe" && queryConfig.index && !isCursorPaginated) {
4389
4618
  const probeOffset = config.offset ?? 0;
4390
4619
  if (typeof probeOffset !== "number") throw new Error("Only numeric offset is supported in kitcn ORM.");
4391
4620
  const probeLimit = this._resolveNonPaginatedLimit(config);
4392
4621
  const convexProbeFilters = queryConfig.postFilters.filter((filter) => this._isConvexEnforceableFilter(filter));
4393
4622
  const probeHasResidualFilter = convexProbeFilters.length !== queryConfig.postFilters.length;
4394
- const probeHasPostFetchMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig) || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
4623
+ const probeHasRelationMembership = this._hasSearchDisallowedRelationFilter(whereFilter, this.tableConfig);
4624
+ const probeHasPostFetchMembership = probeHasRelationMembership || this.rls?.mode !== "skip" && isRlsEnabled(this.tableConfig.table);
4395
4625
  const probeOrderDirection = orderPushdownDirection;
4396
- const probeBound = probeLimit !== void 0 && !probeHasResidualFilter && !probeHasPostFetchMembership && (postFetchOrders.length === 0 || probeOrderDirection !== null) ? probeOffset + probeLimit : void 0;
4626
+ const probeBound = probeLimit !== void 0 && (postFetchOrders.length === 0 || probeOrderDirection !== null) ? probeOffset + probeLimit : void 0;
4627
+ const probeBoundedTake = probeBound !== void 0 && !probeHasResidualFilter && !probeHasPostFetchMembership;
4628
+ const probeSchemaDefinition = this.schema[OrmSchemaDefinition];
4629
+ const probeStreamed = probeBound !== void 0 && !probeBoundedTake && !probeHasRelationMembership && !!probeSchemaDefinition;
4397
4630
  const probeRows = await Promise.all(queryConfig.probeFilters.map(async (probeFilters) => {
4631
+ if (probeStreamed) return await stream(this.db, probeSchemaDefinition).query(this.tableConfig.name).withIndex(queryConfig.index.name, (q) => {
4632
+ let indexQuery = q;
4633
+ for (const filter of probeFilters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
4634
+ return indexQuery;
4635
+ }).order(probeOrderDirection ?? "asc").filterWith(async (row) => {
4636
+ for (const filter of queryConfig.postFilters) if (!this._evaluatePostFetchFilter(row, filter)) return false;
4637
+ return await matchesPostFetchMembership(row);
4638
+ }).take(probeBound);
4398
4639
  let probeQuery = this.db.query(queryConfig.table).withIndex(queryConfig.index.name, (q) => {
4399
4640
  let indexQuery = q;
4400
4641
  for (const filter of probeFilters) indexQuery = this._applyFilterToQuery(indexQuery, filter);
4401
4642
  return indexQuery;
4402
4643
  });
4403
- if (probeBound !== void 0 && probeOrderDirection) probeQuery = probeQuery.order(probeOrderDirection);
4644
+ if (probeBoundedTake && probeOrderDirection) probeQuery = probeQuery.order(probeOrderDirection);
4404
4645
  if (convexProbeFilters.length > 0) probeQuery = probeQuery.filter((q) => convexAnd(q, convexProbeFilters.map((filter) => this._toConvexExpression(filter)(q))));
4405
- return probeBound === void 0 ? await probeQuery.collect() : await probeQuery.take(probeBound);
4646
+ return probeBoundedTake ? await probeQuery.take(probeBound) : await probeQuery.collect();
4406
4647
  }));
4407
4648
  let rows = Array.from(new Map(probeRows.flat().map((row) => [String(row._id), row])).values());
4408
4649
  if (queryConfig.postFilters.length > 0) rows = rows.filter((row) => queryConfig.postFilters.every((filter) => this._evaluatePostFetchFilter(row, filter)));
4409
4650
  rows = await this._applyRlsSelectFilter(rows, this.tableConfig);
4410
- if (whereFilter) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with);
4651
+ if (whereFilter && !probeStreamed) rows = await this._applyRelationsFilterToRows(rows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with);
4411
4652
  if (postFetchOrders.length > 0) rows = rows.sort((a, b) => this._compareByOrderSpecs(a, b, postFetchOrders));
4412
4653
  if (probeOffset > 0) rows = rows.slice(probeOffset);
4413
4654
  if (probeLimit !== void 0) rows = rows.slice(0, probeLimit);
4414
4655
  const selectedRows = await this._finalizeRows(rows);
4415
4656
  return this._returnSelectedRows(selectedRows);
4416
4657
  }
4417
- const matchesPostFetchMembership = async (row) => {
4418
- const visibleRows = await this._applyRlsSelectFilter([row], this.tableConfig);
4419
- if (visibleRows.length === 0) return false;
4420
- if (!whereFilter) return true;
4421
- return (await this._applyRelationsFilterToRows(visibleRows, this.tableConfig, whereFilter, this.edgeMetadata, 0, MAX_RELATION_DEPTH, this.config.with)).length > 0;
4422
- };
4423
4658
  if (isCursorPaginated) {
4424
4659
  const isMultiProbePlan = queryConfig.strategy === "multiProbe" && !!queryConfig.index;
4425
4660
  const isScanFallbackPlan = !queryConfig.index && queryConfig.postFilters.length > 0;
@@ -4569,11 +4804,6 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4569
4804
  */
4570
4805
  _toConvexQuery(whereExpressionOverride, configuredIndex) {
4571
4806
  const config = this.config;
4572
- const tableIndexes = getIndexes(this.tableConfig.table).map((index) => ({
4573
- indexName: index.name,
4574
- indexFields: index.fields
4575
- }));
4576
- const compiler = new WhereClauseCompiler(this.tableConfig.table.tableName, tableIndexes);
4577
4807
  let orderSpecs = [];
4578
4808
  if (config.orderBy) {
4579
4809
  const orderByValue = typeof config.orderBy === "function" ? config.orderBy(this.tableConfig.table, {
@@ -4584,7 +4814,29 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4584
4814
  }
4585
4815
  let whereExpression = whereExpressionOverride;
4586
4816
  if (!whereExpression && config.where && typeof config.where !== "function") whereExpression = this._buildFilterExpression(config.where, this.tableConfig);
4587
- const planned = compiler.compile(whereExpression, { orderFields: orderSpecs.map((spec) => spec.field) });
4817
+ return this._compileQueryPlan({
4818
+ whereExpression,
4819
+ configuredIndex,
4820
+ orderSpecs
4821
+ });
4822
+ }
4823
+ /**
4824
+ * The read plan for one `where` on this table.
4825
+ *
4826
+ * Split out of `_toConvexQuery` because the chain-level `where` is not the
4827
+ * only one that has to be index-lowered: a `pipeline.union` source carries
4828
+ * its own `where` and its own pinned index, and compiles against the same
4829
+ * table with a different ordering contract (`interleaveBy`, not `orderBy`).
4830
+ * Both go through here so a plain-object `where` is planned the same way
4831
+ * wherever it is written.
4832
+ */
4833
+ _compileQueryPlan(params) {
4834
+ const { whereExpression, configuredIndex, orderSpecs } = params;
4835
+ const tableIndexes = getIndexes(this.tableConfig.table).map((index) => ({
4836
+ indexName: index.name,
4837
+ indexFields: index.fields
4838
+ }));
4839
+ const planned = new WhereClauseCompiler(this.tableConfig.table.tableName, tableIndexes).compile(whereExpression, { orderFields: orderSpecs.map((spec) => spec.field) });
4588
4840
  const plannedUsesIndex = !!planned.selectedIndex && (planned.indexFilters.length > 0 || planned.probeFilters.length > 0);
4589
4841
  const compiled = !configuredIndex?.name || !plannedUsesIndex || planned.selectedIndex?.indexName === configuredIndex.name && !configuredIndex.range ? planned : {
4590
4842
  strategy: "none",
@@ -4666,11 +4918,11 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4666
4918
  * Apply a single filter expression to a Convex query builder
4667
4919
  * Used for index filters (eq operations)
4668
4920
  */
4669
- _applyFilterToQuery(query, filter) {
4921
+ _applyFilterToQuery(query, filter, tableConfig = this.tableConfig) {
4670
4922
  if (filter.type === "binary") {
4671
4923
  const [field, value] = filter.operands;
4672
4924
  if (!isFieldReference(field)) return query;
4673
- const normalizedValue = this._normalizeComparableValue(field.fieldName, value);
4925
+ const normalizedValue = this._normalizeComparableValue(field.fieldName, value, tableConfig);
4674
4926
  switch (filter.operator) {
4675
4927
  case "eq": return query.eq(field.fieldName, normalizedValue);
4676
4928
  case "gt": return query.gt(field.fieldName, normalizedValue);
@@ -4700,18 +4952,75 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4700
4952
  if (!this._allEdges) return [];
4701
4953
  return this._allEdges.filter((edge) => edge.sourceTable === tableName);
4702
4954
  }
4955
+ /**
4956
+ * A memo entry is a snapshot, so every caller has to get its own document.
4957
+ *
4958
+ * Relation loaders write nested `with` results and `extras` straight onto the
4959
+ * target they were handed, and `hydrateDateFieldsForRead` copies every own key
4960
+ * it finds. Two loads that share one entry would therefore publish each
4961
+ * other's fields — a relation asked for as `true` coming back carrying a
4962
+ * nested relation only the `where` requested. Those writes are all top-level,
4963
+ * so a shallow copy is exactly as much isolation as they need.
4964
+ */
4965
+ _ownedCopy(doc) {
4966
+ return doc === null || doc === void 0 ? null : { ...doc };
4967
+ }
4703
4968
  async _getById(tableName, id) {
4704
4969
  if (id === null || id === void 0) return null;
4705
4970
  const normalizedId = this.db.normalizeId(tableName, id);
4706
4971
  if (normalizedId === null) return null;
4707
4972
  const existing = this._documentByNormalizedId.get(normalizedId);
4708
- if (existing) return await existing;
4973
+ if (existing) return this._ownedCopy(await existing);
4709
4974
  const pending = Promise.resolve(this.db.get(normalizedId)).catch((error) => {
4710
4975
  this._documentByNormalizedId.delete(normalizedId);
4711
4976
  throw error;
4712
4977
  });
4713
4978
  this._documentByNormalizedId.set(normalizedId, pending);
4714
- return await pending;
4979
+ return this._ownedCopy(await pending);
4980
+ }
4981
+ /**
4982
+ * Identity of one `_firstByFields` read, or null when the join values cannot
4983
+ * be encoded losslessly.
4984
+ *
4985
+ * `JSON.stringify` alone is not safe here. It renders every `ArrayBuffer` as
4986
+ * `{}` and `NaN`/`Infinity`/`-Infinity` as `null`, so two distinct join values
4987
+ * would share one entry, and it throws outright on `int64`. The relation
4988
+ * loader's own per-batch key map cannot catch that, because the residual
4989
+ * relation `where` hands it one row at a time — a map of one never compares
4990
+ * two values. This key is the only thing that tells them apart.
4991
+ *
4992
+ * `convexToJson` is the same wire encoding the client uses for query keys, so
4993
+ * every Convex value round-trips distinctly. Anything it rejects is not a
4994
+ * Convex value and simply goes unmemoized.
4995
+ */
4996
+ _firstByFieldsMemoKey(tableName, fields, values, indexName) {
4997
+ try {
4998
+ return JSON.stringify([
4999
+ tableName,
5000
+ indexName,
5001
+ fields,
5002
+ values.map((value) => convexToJson(value))
5003
+ ]);
5004
+ } catch {
5005
+ return null;
5006
+ }
5007
+ }
5008
+ /**
5009
+ * `_getById` for a relation joined on something other than the primary id:
5010
+ * resolve one target document by an eq-pinned key, memoized per execution.
5011
+ */
5012
+ async _firstByFields(tableName, fields, values, indexName) {
5013
+ const read = () => this._queryByFields(this.db.query(tableName), fields, values, indexName).first();
5014
+ const key = this._firstByFieldsMemoKey(tableName, fields, values, indexName);
5015
+ if (key === null) return await read();
5016
+ const existing = this._firstDocumentByFieldKey.get(key);
5017
+ if (existing) return this._ownedCopy(await existing);
5018
+ const pending = Promise.resolve(read()).catch((error) => {
5019
+ this._firstDocumentByFieldKey.delete(key);
5020
+ throw error;
5021
+ });
5022
+ this._firstDocumentByFieldKey.set(key, pending);
5023
+ return this._ownedCopy(await pending);
4715
5024
  }
4716
5025
  _getRelationConcurrency() {
4717
5026
  const value = this.relationLoading?.concurrency;
@@ -4863,7 +5172,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4863
5172
  const resolveTargetMatch = async (values) => {
4864
5173
  let target = null;
4865
5174
  if (useGetById) target = await this._getById(edge.targetTable, values[0]);
4866
- else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, targetIndexName).first();
5175
+ else target = await this._firstByFields(edge.targetTable, targetFields, values, targetIndexName);
4867
5176
  if (!target) return false;
4868
5177
  return this._evaluateTableFilter(target, targetTableConfig, whereRecord);
4869
5178
  };
@@ -4965,7 +5274,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4965
5274
  const fetched = await this._mapWithConcurrency(entries, async ([key, values]) => {
4966
5275
  let target = null;
4967
5276
  if (useGetById) target = await this._getById(edge.targetTable, values[0]);
4968
- else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName).first();
5277
+ else target = await this._firstByFields(edge.targetTable, targetFields, values, indexName);
4969
5278
  return {
4970
5279
  key,
4971
5280
  target
@@ -5217,7 +5526,7 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5217
5526
  return await this._mapWithConcurrency(keyEntries, async ([key, values]) => {
5218
5527
  let target = null;
5219
5528
  if (useGetById) target = await this._getById(edge.targetTable, values[0]);
5220
- else target = await this._queryByFields(this.db.query(edge.targetTable), targetFields, values, indexName).first();
5529
+ else target = await this._firstByFields(edge.targetTable, targetFields, values, indexName);
5221
5530
  return {
5222
5531
  key,
5223
5532
  target
@@ -5566,6 +5875,9 @@ var ConvexDeleteBuilder = class extends QueryPromise {
5566
5875
  });
5567
5876
  }
5568
5877
  async execute(...args) {
5878
+ return await runInOrmWriteScope(this.db, () => runInOrmWriteBatch(this.db, () => this._runStatement(...args)));
5879
+ }
5880
+ async _runStatement(...args) {
5569
5881
  const config = args[0];
5570
5882
  const tableName = getTableName(this.table);
5571
5883
  const ormContext = getOrmContext(this.db);
@@ -5857,6 +6169,9 @@ var ConvexInsertBuilder = class extends QueryPromise {
5857
6169
  return this;
5858
6170
  }
5859
6171
  async execute() {
6172
+ return await runInOrmWriteScope(this.db, () => runInOrmWriteBatch(this.db, () => this._runStatement()));
6173
+ }
6174
+ async _runStatement() {
5860
6175
  if (this.valuesList.length === 0) throw new Error("values() must be called before execute()");
5861
6176
  const ormContext = getOrmContext(this.db);
5862
6177
  const tableName = getTableName(this.table);
@@ -6109,6 +6424,7 @@ function normalizeOrmTriggers(triggers) {
6109
6424
  //#region src/orm/lifecycle.ts
6110
6425
  const ORMLIFECYCLE_WRAPPED_DB = Symbol.for("kitcn:OrmLifecycleWrappedDB");
6111
6426
  const ORMLIFECYCLE_INNER_DB = Symbol.for("kitcn:OrmLifecycleInnerDB");
6427
+ const wrapUserHook = (hook) => hook ? (...args) => runInOrmUserCallback(() => hook(...args)) : void 0;
6112
6428
  const isWriterDb = (db) => typeof db.insert === "function" && typeof db.patch === "function" && typeof db.delete === "function";
6113
6429
  const isLifecycleWrappedDb = (db) => db[ORMLIFECYCLE_WRAPPED_DB] === true;
6114
6430
  const markLifecycleWrappedDb = (db) => {
@@ -6292,6 +6608,7 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
6292
6608
  const tableHooks = hooksByTable.get(tableName);
6293
6609
  if (!tableHooks) return innerDb.patch(tableName, id, value);
6294
6610
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
6611
+ await tableHooks.writeBarrier?.(innerDb);
6295
6612
  const needsDocuments = Boolean(tableHooks.update?.after || tableHooks.change);
6296
6613
  const oldDoc = needsDocuments ? await innerDb.get(tableName, id) : null;
6297
6614
  const updatePayload = await mergeBeforeData(tableName, "update", tableHooks.update?.before, value, hookCtx);
@@ -6337,6 +6654,7 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
6337
6654
  const tableHooks = hooksByTable.get(tableName);
6338
6655
  if (!tableHooks) return innerDb.replace(tableName, id, value);
6339
6656
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
6657
+ await tableHooks.writeBarrier?.(innerDb);
6340
6658
  const oldDoc = Boolean(tableHooks.update?.after || tableHooks.change) ? await innerDb.get(tableName, id) : null;
6341
6659
  const updatePayload = await mergeBeforeData(tableName, "update", tableHooks.update?.before, value, hookCtx);
6342
6660
  await innerDb.replace(tableName, id, updatePayload);
@@ -6372,6 +6690,7 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
6372
6690
  const tableHooks = hooksByTable.get(tableName);
6373
6691
  if (!tableHooks) return innerDb.delete(tableName, id);
6374
6692
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
6693
+ await tableHooks.writeBarrier?.(innerDb);
6375
6694
  const oldDoc = Boolean(tableHooks.delete || tableHooks.change) ? await innerDb.get(tableName, id) : null;
6376
6695
  if (!oldDoc) {
6377
6696
  await innerDb.delete(tableName, id);
@@ -6407,6 +6726,7 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
6407
6726
  const tableHooks = hooksByTable.get(table);
6408
6727
  if (!tableHooks) return innerDb.insert(table, value);
6409
6728
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
6729
+ await tableHooks.writeBarrier?.(innerDb);
6410
6730
  const insertPayload = await mergeBeforeData(table, "create", tableHooks.create?.before, value, hookCtx);
6411
6731
  const id = await innerDb.insert(table, insertPayload);
6412
6732
  const newDoc = Boolean(tableHooks.create?.after || tableHooks.change) ? await innerDb.get(table, id) : null;
@@ -6469,53 +6789,57 @@ function createOrmDbLifecycle(schema, triggerDefinitions, capabilities) {
6469
6789
  for (const [triggerKey, hooks] of normalizedTriggers.entries()) {
6470
6790
  const tableName = tableNameBySchemaKey.get(triggerKey) ?? (tableNames.has(triggerKey) ? triggerKey : void 0);
6471
6791
  if (!tableName) throw new Error(`Unknown trigger table '${triggerKey}'. Export triggers with defineTriggers(relations, { ... }) using keys from your relations export.`);
6472
- tableHooks.set(tableName, hooks);
6792
+ tableHooks.set(tableName, {
6793
+ create: hooks.create && {
6794
+ before: wrapUserHook(hooks.create.before),
6795
+ after: wrapUserHook(hooks.create.after)
6796
+ },
6797
+ update: hooks.update && {
6798
+ before: wrapUserHook(hooks.update.before),
6799
+ after: wrapUserHook(hooks.update.after)
6800
+ },
6801
+ delete: hooks.delete && {
6802
+ before: wrapUserHook(hooks.delete.before),
6803
+ after: wrapUserHook(hooks.delete.after)
6804
+ },
6805
+ change: wrapUserHook(hooks.change)
6806
+ });
6473
6807
  }
6474
6808
  for (const tableConfig of Object.values(schema)) {
6475
6809
  if (!tableConfig?.table || !tableConfig?.name) continue;
6476
6810
  const aggregateIndexes = getAggregateIndexDefinitions(tableConfig);
6477
6811
  const rankIndexes = getRankIndexDefinitions(tableConfig);
6478
6812
  if (aggregateIndexes.length === 0 && rankIndexes.length === 0) continue;
6479
- const aggregate = requireAggregateCapability(capabilities, `Table '${tableConfig.name}' declares an aggregateIndex/rankIndex, which`);
6480
- const existing = tableHooks.get(tableConfig.name) ?? {};
6813
+ const tableName = tableConfig.name;
6814
+ const aggregate = requireAggregateCapability(capabilities, `Table '${tableName}' declares an aggregateIndex/rankIndex, which`);
6815
+ const existing = tableHooks.get(tableName) ?? {};
6481
6816
  const existingChange = existing.change;
6817
+ const existingBarrier = existing.writeBarrier;
6482
6818
  const metricIndexNames = aggregateIndexes.map((entry) => entry.name);
6483
6819
  const rankIndexNames = rankIndexes.map((entry) => entry.name);
6484
- const prependWriteBarrier = (before) => async (data, ctx) => {
6485
- await aggregate.assertAggregateIndexesWritable(ctx.db, tableConfig.name, metricIndexNames, rankIndexNames);
6486
- return before?.(data, ctx);
6487
- };
6488
- tableHooks.set(tableConfig.name, {
6820
+ tableHooks.set(tableName, {
6489
6821
  ...existing,
6490
- create: {
6491
- ...existing.create,
6492
- before: prependWriteBarrier(existing.create?.before)
6493
- },
6494
- update: {
6495
- ...existing.update,
6496
- before: prependWriteBarrier(existing.update?.before)
6497
- },
6498
- delete: {
6499
- ...existing.delete,
6500
- before: prependWriteBarrier(existing.delete?.before)
6822
+ writeBarrier: async (db) => {
6823
+ await existingBarrier?.(db);
6824
+ await aggregate.assertAggregateIndexesWritable(db, tableName, metricIndexNames, rankIndexNames);
6501
6825
  },
6502
6826
  change: async (change, ctx) => {
6503
6827
  if (change.operation === "delete") {
6504
- if (aggregateIndexes.length > 0) await aggregate.applyAggregateIndexesForChange(ctx.db, tableConfig.name, aggregateIndexes, {
6828
+ if (aggregateIndexes.length > 0) await aggregate.applyAggregateIndexesForChange(ctx.db, tableName, aggregateIndexes, {
6505
6829
  operation: "delete",
6506
6830
  id: change.id
6507
6831
  });
6508
- if (rankIndexes.length > 0) await aggregate.applyRankIndexesForChange(ctx.db, tableConfig.name, rankIndexes, {
6832
+ if (rankIndexes.length > 0) await aggregate.applyRankIndexesForChange(ctx.db, tableName, rankIndexes, {
6509
6833
  operation: "delete",
6510
6834
  id: change.id
6511
6835
  });
6512
6836
  } else {
6513
- if (aggregateIndexes.length > 0) await aggregate.applyAggregateIndexesForChange(ctx.db, tableConfig.name, aggregateIndexes, {
6837
+ if (aggregateIndexes.length > 0) await aggregate.applyAggregateIndexesForChange(ctx.db, tableName, aggregateIndexes, {
6514
6838
  operation: change.operation,
6515
6839
  id: change.id,
6516
6840
  newDoc: change.newDoc
6517
6841
  });
6518
- if (rankIndexes.length > 0) await aggregate.applyRankIndexesForChange(ctx.db, tableConfig.name, rankIndexes, {
6842
+ if (rankIndexes.length > 0) await aggregate.applyRankIndexesForChange(ctx.db, tableName, rankIndexes, {
6519
6843
  operation: change.operation,
6520
6844
  id: change.id,
6521
6845
  newDoc: change.newDoc
@@ -6786,6 +7110,9 @@ var ConvexUpdateBuilder = class extends QueryPromise {
6786
7110
  });
6787
7111
  }
6788
7112
  async execute(...args) {
7113
+ return await runInOrmWriteScope(this.db, () => runInOrmWriteBatch(this.db, () => this._runStatement(...args)));
7114
+ }
7115
+ async _runStatement(...args) {
6789
7116
  if (!this.setValues) throw new Error("set() must be called before execute()");
6790
7117
  if (Object.keys(this.setValues).length === 0) {
6791
7118
  if (this.paginateConfig !== void 0) {
@@ -7133,7 +7460,7 @@ function createDatabase(db, schema, edgeMetadata, options) {
7133
7460
  defaults,
7134
7461
  resolvedDefaults
7135
7462
  };
7136
- const baseDb = Object.assign(Object.create(db), { [OrmContext]: ormContext });
7463
+ const baseDb = markOrmTransactionAnchor(Object.assign(Object.create(db), { [OrmContext]: ormContext }), db);
7137
7464
  const query = {};
7138
7465
  const edgesBySourceTable = getEdgesBySourceTable(edgeMetadata);
7139
7466
  for (const [tableName, tableConfig] of Object.entries(schema)) query[tableName] = new RelationalQueryBuilder(schema, tableConfig, edgesBySourceTable.get(tableConfig.name) ?? NO_EDGES, baseDb, edgeMetadata, rls, options?.relationLoading, options?.vectorSearch);