kitcn 0.17.2 → 0.17.4

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
@@ -350,6 +350,63 @@ const hasUserCreatedAtColumn = (table) => {
350
350
  };
351
351
  const usesSystemCreatedAtAlias = (_table) => true;
352
352
 
353
+ //#endregion
354
+ //#region src/orm/write-fanout.ts
355
+ /**
356
+ * Shared primitives for fanning out independent writes.
357
+ *
358
+ * Deliberately dependency-free: `mutation-utils`, `lifecycle` and `query` all
359
+ * import it, and Convex bundles every static import of a function entry.
360
+ */
361
+ const ORMLIFECYCLE_HOOKED_TABLES = Symbol.for("kitcn:OrmLifecycleHookedTables");
362
+ /** Matches the ORM's relation-loading default. */
363
+ const DEFAULT_WRITE_FANOUT_CONCURRENCY = 25;
364
+ /**
365
+ * Runs `worker` over `items` with at most `limit` in flight. Results keep input
366
+ * order. Unbounded `Promise.all` is not an option here: a fan-out is bounded
367
+ * only by `mutationMaxRows` (10,000), and that many simultaneous in-flight
368
+ * syscalls is its own failure mode.
369
+ */
370
+ async function mapWithConcurrency(items, limit, worker) {
371
+ if (items.length === 0) return [];
372
+ const width = Math.max(1, Math.min(limit, items.length));
373
+ const results = new Array(items.length);
374
+ let nextIndex = 0;
375
+ const runWorker = async () => {
376
+ while (true) {
377
+ const index = nextIndex;
378
+ nextIndex += 1;
379
+ if (index >= items.length) return;
380
+ results[index] = await worker(items[index], index);
381
+ }
382
+ };
383
+ await Promise.all(Array.from({ length: width }, () => runWorker()));
384
+ return results;
385
+ }
386
+ /**
387
+ * Records which tables the lifecycle writer intercepts, so write fan-out can
388
+ * see it without importing `lifecycle` (which would close an import cycle
389
+ * through `aggregate-index/runtime`).
390
+ */
391
+ const markLifecycleHookedTables = (db, tableNames) => {
392
+ Object.defineProperty(db, ORMLIFECYCLE_HOOKED_TABLES, {
393
+ configurable: false,
394
+ enumerable: false,
395
+ value: tableNames,
396
+ writable: false
397
+ });
398
+ return db;
399
+ };
400
+ /**
401
+ * True when writes to `tableName` run through trigger / aggregate-index hooks.
402
+ * Those writes are serialized by the lifecycle write lock and their hooks fire
403
+ * in write order, so they must keep their sequential loop; everything else is
404
+ * free to fan out.
405
+ */
406
+ const hasLifecycleHooks = (db, tableName) => {
407
+ return (db?.[ORMLIFECYCLE_HOOKED_TABLES])?.has(tableName) ?? false;
408
+ };
409
+
353
410
  //#endregion
354
411
  //#region src/orm/mutation-utils.ts
355
412
  const UNDEFINED_SENTINEL_KEY = "__kitcnUndefined";
@@ -430,23 +487,6 @@ const normalizeTemporalComparableValue = (table, fieldName, value) => {
430
487
  if (Array.isArray(value)) return value.map((entry) => normalizeTemporalWriteValue(descriptor, entry));
431
488
  return normalizeTemporalWriteValue(descriptor, value);
432
489
  };
433
- const normalizePublicSystemFields = (value, options) => {
434
- if (!isPlainObject$1(value)) return value;
435
- const hasId = Object.hasOwn(value, INTERNAL_ID_FIELD$2);
436
- const hasCreationTime = Object.hasOwn(value, INTERNAL_CREATION_TIME_FIELD);
437
- if (!hasId && !hasCreationTime) return value;
438
- const obj = value;
439
- const { [INTERNAL_ID_FIELD$2]: internalId, ...rest } = obj;
440
- const publicRow = { ...rest };
441
- if (hasId) publicRow[PUBLIC_ID_FIELD$2] = internalId;
442
- if (hasCreationTime) {
443
- const raw = obj[INTERNAL_CREATION_TIME_FIELD];
444
- if (options?.useSystemCreatedAtAlias && raw !== void 0) publicRow[PUBLIC_CREATED_AT_FIELD] = raw;
445
- delete publicRow[INTERNAL_CREATION_TIME_FIELD];
446
- }
447
- delete publicRow[INTERNAL_ID_FIELD$2];
448
- return publicRow;
449
- };
450
490
  const normalizeDateFieldsForWrite = (table, value) => {
451
491
  const useSystemCreatedAt = usesSystemCreatedAtAlias(table);
452
492
  const hasUserCreatedAt = hasUserCreatedAtColumn(table);
@@ -460,19 +500,34 @@ const normalizeDateFieldsForWrite = (table, value) => {
460
500
  }
461
501
  return result;
462
502
  };
503
+ /**
504
+ * Reshapes a stored document into its public form in a single pass: renames
505
+ * `_id` to `id`, aliases `_creationTime` to `createdAt`, and hydrates temporal
506
+ * columns. Deliberately allocates exactly one object and never calls `delete`
507
+ * on it — deleting an existing own property drops the object into V8
508
+ * dictionary mode, which then poisons every downstream spread.
509
+ */
463
510
  const hydrateDateFieldsForRead = (table, value) => {
464
- const rawCreationTime = isPlainObject$1(value) && typeof value[INTERNAL_CREATION_TIME_FIELD] === "number" ? value[INTERNAL_CREATION_TIME_FIELD] : void 0;
465
- const base = normalizePublicSystemFields(value, { useSystemCreatedAtAlias: usesSystemCreatedAtAlias(table) });
466
- if (!isPlainObject$1(base)) return base;
467
- const result = { ...base };
511
+ if (!isPlainObject$1(value)) return value;
512
+ const obj = value;
513
+ const hasId = Object.hasOwn(obj, INTERNAL_ID_FIELD$2);
514
+ const rawCreationTime = obj[INTERNAL_CREATION_TIME_FIELD];
515
+ const aliasCreatedAt = usesSystemCreatedAtAlias(table) && Object.hasOwn(obj, INTERNAL_CREATION_TIME_FIELD) && rawCreationTime !== void 0;
468
516
  const temporalColumns = getTemporalColumnDescriptors(table);
469
- for (const [name, descriptor] of temporalColumns.entries()) {
470
- if (name === PUBLIC_CREATED_AT_FIELD && result[name] === void 0 && rawCreationTime !== void 0) {
471
- result[name] = hydrateTemporalReadValue(descriptor, rawCreationTime);
517
+ const result = {};
518
+ for (const key of Object.keys(obj)) {
519
+ if (key === INTERNAL_ID_FIELD$2 || key === INTERNAL_CREATION_TIME_FIELD) continue;
520
+ if (key === PUBLIC_CREATED_AT_FIELD && aliasCreatedAt) {
521
+ result[key] = void 0;
472
522
  continue;
473
523
  }
474
- if (!Object.hasOwn(result, name)) continue;
475
- result[name] = hydrateTemporalReadValue(descriptor, result[name]);
524
+ const descriptor = temporalColumns.get(key);
525
+ result[key] = descriptor ? hydrateTemporalReadValue(descriptor, obj[key]) : obj[key];
526
+ }
527
+ if (hasId) result[PUBLIC_ID_FIELD$2] = obj[INTERNAL_ID_FIELD$2];
528
+ if (aliasCreatedAt) {
529
+ const descriptor = temporalColumns.get(PUBLIC_CREATED_AT_FIELD);
530
+ result[PUBLIC_CREATED_AT_FIELD] = descriptor ? hydrateTemporalReadValue(descriptor, rawCreationTime) : rawCreationTime;
476
531
  }
477
532
  return result;
478
533
  };
@@ -996,8 +1051,24 @@ async function softDeleteRow(db, table, row) {
996
1051
  await db.patch(tableName, row._id, { deletionTime });
997
1052
  return deletionTime;
998
1053
  }
999
- async function hardDeleteRow(db, _tableName, row) {
1000
- await db.delete(row._id);
1054
+ /**
1055
+ * Applies one loop-invariant payload to every referencing row. The ids come
1056
+ * from a single index scan so they are distinct, and the write set is
1057
+ * order-independent — but a table whose writes run through lifecycle hooks
1058
+ * keeps the sequential loop: those writes are serialized by the lifecycle write
1059
+ * lock anyway, and their hooks would otherwise fire out of write order.
1060
+ */
1061
+ async function patchReferencingRows(db, tableName, rows, patch) {
1062
+ if (hasLifecycleHooks(db, tableName)) {
1063
+ for (const row of rows) await db.patch(tableName, row._id, patch);
1064
+ return;
1065
+ }
1066
+ await mapWithConcurrency(rows, DEFAULT_WRITE_FANOUT_CONCURRENCY, async (row) => {
1067
+ await db.patch(tableName, row._id, patch);
1068
+ });
1069
+ }
1070
+ async function hardDeleteRow(db, tableName, row) {
1071
+ await db.delete(tableName, row._id);
1001
1072
  }
1002
1073
  async function applyIncomingForeignKeyActionsOnDelete(db, table, row, options) {
1003
1074
  const tableName = getTableName(table);
@@ -1053,16 +1124,14 @@ async function applyIncomingForeignKeyActionsOnDelete(db, table, row, options) {
1053
1124
  if (referencingRows.length === 0) continue;
1054
1125
  if (action === "set null") {
1055
1126
  ensureNullableColumns(foreignKey.sourceTable, foreignKey.sourceColumns, `Foreign key set null on '${foreignKey.sourceTableName}'`);
1056
- for (const referencingRow of referencingRows) {
1057
- const patch = {};
1058
- for (const columnName of foreignKey.sourceColumns) patch[columnName] = null;
1059
- await db.patch(foreignKey.sourceTableName, referencingRow._id, patch);
1060
- }
1127
+ const nullPatch = {};
1128
+ for (const columnName of foreignKey.sourceColumns) nullPatch[columnName] = null;
1129
+ await patchReferencingRows(db, foreignKey.sourceTableName, referencingRows, nullPatch);
1061
1130
  continue;
1062
1131
  }
1063
1132
  if (action === "set default") {
1064
1133
  const defaults = ensureDefaultColumns(foreignKey.sourceTable, foreignKey.sourceColumns, `Foreign key set default on '${foreignKey.sourceTableName}'`);
1065
- for (const referencingRow of referencingRows) await db.patch(foreignKey.sourceTableName, referencingRow._id, defaults);
1134
+ await patchReferencingRows(db, foreignKey.sourceTableName, referencingRows, defaults);
1066
1135
  continue;
1067
1136
  }
1068
1137
  if (action === "cascade") for (const referencingRow of referencingRows) {
@@ -1130,23 +1199,21 @@ async function applyIncomingForeignKeyActionsOnUpdate(db, table, oldRow, newRow,
1130
1199
  if (referencingRows.length === 0) continue;
1131
1200
  if (action === "set null") {
1132
1201
  ensureNullableColumns(foreignKey.sourceTable, foreignKey.sourceColumns, `Foreign key set null on '${foreignKey.sourceTableName}'`);
1133
- for (const referencingRow of referencingRows) {
1134
- const patch = {};
1135
- for (const columnName of foreignKey.sourceColumns) patch[columnName] = null;
1136
- await db.patch(foreignKey.sourceTableName, referencingRow._id, patch);
1137
- }
1202
+ const nullPatch = {};
1203
+ for (const columnName of foreignKey.sourceColumns) nullPatch[columnName] = null;
1204
+ await patchReferencingRows(db, foreignKey.sourceTableName, referencingRows, nullPatch);
1138
1205
  continue;
1139
1206
  }
1140
1207
  if (action === "set default") {
1141
1208
  const defaults = ensureDefaultColumns(foreignKey.sourceTable, foreignKey.sourceColumns, `Foreign key set default on '${foreignKey.sourceTableName}'`);
1142
- for (const referencingRow of referencingRows) await db.patch(foreignKey.sourceTableName, referencingRow._id, defaults);
1209
+ await patchReferencingRows(db, foreignKey.sourceTableName, referencingRows, defaults);
1143
1210
  continue;
1144
1211
  }
1145
1212
  if (action === "cascade") {
1146
1213
  const patchValues = {};
1147
1214
  for (let i = 0; i < foreignKey.sourceColumns.length; i++) patchValues[foreignKey.sourceColumns[i]] = newValues[i];
1148
1215
  ensureNonNullValues(foreignKey.sourceTable, patchValues, `Foreign key cascade update on '${foreignKey.sourceTableName}'`);
1149
- for (const referencingRow of referencingRows) await db.patch(foreignKey.sourceTableName, referencingRow._id, patchValues);
1216
+ await patchReferencingRows(db, foreignKey.sourceTableName, referencingRows, patchValues);
1150
1217
  }
1151
1218
  }
1152
1219
  }
@@ -3424,6 +3491,54 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3424
3491
  };
3425
3492
  }
3426
3493
 
3494
+ //#endregion
3495
+ //#region src/orm/query-promise.ts
3496
+ /**
3497
+ * Query Promise - Lazy query execution via Promise interface
3498
+ *
3499
+ * Implements Drizzle's QueryPromise pattern:
3500
+ * - Queries don't execute until awaited or .then() is called
3501
+ * - Implements full Promise interface (then/catch/finally)
3502
+ * - Subclasses provide execute() implementation
3503
+ *
3504
+ * @example
3505
+ * const query = ctx.db.query.users.findMany();
3506
+ * // Query not executed yet
3507
+ * const users = await query; // Now executed
3508
+ */
3509
+ /**
3510
+ * Abstract base class for promise-based query execution
3511
+ *
3512
+ * @template T - The result type returned by the query
3513
+ *
3514
+ * Pattern from Drizzle ORM: query-promise.ts:27-31
3515
+ */
3516
+ var QueryPromise = class {
3517
+ /**
3518
+ * Promise tag for debugging and type identification
3519
+ */
3520
+ [Symbol.toStringTag] = "QueryPromise";
3521
+ /**
3522
+ * Promise.then() implementation - delegates to execute()
3523
+ * This enables lazy evaluation: queries only run when awaited
3524
+ */
3525
+ then(onFulfilled, onRejected) {
3526
+ return this.execute().then(onFulfilled, onRejected);
3527
+ }
3528
+ /**
3529
+ * Promise.catch() implementation - delegates to execute()
3530
+ */
3531
+ catch(onRejected) {
3532
+ return this.execute().catch(onRejected);
3533
+ }
3534
+ /**
3535
+ * Promise.finally() implementation - delegates to execute()
3536
+ */
3537
+ finally(onFinally) {
3538
+ return this.execute().finally(onFinally);
3539
+ }
3540
+ };
3541
+
3427
3542
  //#endregion
3428
3543
  //#region src/orm/errors.ts
3429
3544
  var OrmNotFoundError = class extends Error {
@@ -3522,54 +3637,6 @@ async function* streamQuery(ctx, request) {
3522
3637
  }
3523
3638
  const DEFAULT_TARGET_MAX_ROWS = 100;
3524
3639
 
3525
- //#endregion
3526
- //#region src/orm/query-promise.ts
3527
- /**
3528
- * Query Promise - Lazy query execution via Promise interface
3529
- *
3530
- * Implements Drizzle's QueryPromise pattern:
3531
- * - Queries don't execute until awaited or .then() is called
3532
- * - Implements full Promise interface (then/catch/finally)
3533
- * - Subclasses provide execute() implementation
3534
- *
3535
- * @example
3536
- * const query = ctx.db.query.users.findMany();
3537
- * // Query not executed yet
3538
- * const users = await query; // Now executed
3539
- */
3540
- /**
3541
- * Abstract base class for promise-based query execution
3542
- *
3543
- * @template T - The result type returned by the query
3544
- *
3545
- * Pattern from Drizzle ORM: query-promise.ts:27-31
3546
- */
3547
- var QueryPromise = class {
3548
- /**
3549
- * Promise tag for debugging and type identification
3550
- */
3551
- [Symbol.toStringTag] = "QueryPromise";
3552
- /**
3553
- * Promise.then() implementation - delegates to execute()
3554
- * This enables lazy evaluation: queries only run when awaited
3555
- */
3556
- then(onFulfilled, onRejected) {
3557
- return this.execute().then(onFulfilled, onRejected);
3558
- }
3559
- /**
3560
- * Promise.catch() implementation - delegates to execute()
3561
- */
3562
- catch(onRejected) {
3563
- return this.execute().catch(onRejected);
3564
- }
3565
- /**
3566
- * Promise.finally() implementation - delegates to execute()
3567
- */
3568
- finally(onFinally) {
3569
- return this.execute().finally(onFinally);
3570
- }
3571
- };
3572
-
3573
3640
  //#endregion
3574
3641
  //#region src/orm/rls/roles.ts
3575
3642
  var RlsRole = class {
@@ -3605,6 +3672,12 @@ function isRlsRole(value) {
3605
3672
 
3606
3673
  //#endregion
3607
3674
  //#region src/orm/rls/evaluator.ts
3675
+ function createRlsPolicyResolutionCache() {
3676
+ return {
3677
+ applicable: /* @__PURE__ */ new WeakMap(),
3678
+ expressions: /* @__PURE__ */ new WeakMap()
3679
+ };
3680
+ }
3608
3681
  function isRlsEnabled(table) {
3609
3682
  return Boolean(table[EnableRLS] || getRlsPolicies(table).length > 0);
3610
3683
  }
@@ -3690,22 +3763,52 @@ async function resolveExpression(policy, checkType, ctx, table) {
3690
3763
  if (typeof candidate === "function") return await candidate(ctx, table);
3691
3764
  return candidate;
3692
3765
  }
3693
- async function evaluatePolicySet({ table, operation, checkType, row, rls }) {
3694
- if (!isRlsEnabled(table)) return true;
3695
- if (rls?.mode === "skip") return true;
3766
+ /**
3767
+ * The applicable-policy set depends only on `(table, operation, rls)`, never on
3768
+ * a row, so it is computed once per cache. `assertRlsRolesResolvable` is folded
3769
+ * in to keep the fail-closed ordering: configuration errors still throw before
3770
+ * any policy expression is resolved.
3771
+ */
3772
+ function getApplicablePolicies(table, operation, rls, cache) {
3773
+ const byOperation = cache.applicable.get(table);
3774
+ const cached = byOperation?.get(operation);
3775
+ if (cached) return cached;
3696
3776
  assertRlsRolesResolvable({
3697
3777
  table,
3698
3778
  operation,
3699
3779
  rls
3700
3780
  });
3701
- const ctx = rls?.ctx ?? {};
3702
3781
  const policies = getRlsPolicies(table).filter((policy) => policyApplies(policy, operation) && roleMatches(policy, rls));
3782
+ if (byOperation) byOperation.set(operation, policies);
3783
+ else cache.applicable.set(table, new Map([[operation, policies]]));
3784
+ return policies;
3785
+ }
3786
+ /**
3787
+ * Resolved lazily and per policy, so the permissive short-circuit still means a
3788
+ * later policy's callback is never invoked. The promise (not the value) is
3789
+ * memoized so concurrent rows cannot double-invoke a user callback.
3790
+ */
3791
+ function resolveExpressionCached(policy, checkType, ctx, table, cache) {
3792
+ const byCheckType = cache.expressions.get(policy);
3793
+ const cached = byCheckType?.get(checkType);
3794
+ if (cached) return cached;
3795
+ const pending = resolveExpression(policy, checkType, ctx, table);
3796
+ if (byCheckType) byCheckType.set(checkType, pending);
3797
+ else cache.expressions.set(policy, new Map([[checkType, pending]]));
3798
+ return pending;
3799
+ }
3800
+ async function evaluatePolicySet({ table, operation, checkType, row, rls, cache }) {
3801
+ if (!isRlsEnabled(table)) return true;
3802
+ if (rls?.mode === "skip") return true;
3803
+ const resolution = cache ?? createRlsPolicyResolutionCache();
3804
+ const ctx = rls?.ctx ?? {};
3805
+ const policies = getApplicablePolicies(table, operation, rls, resolution);
3703
3806
  if (policies.length === 0) return false;
3704
3807
  const permissive = policies.filter((policy) => (policy.as ?? "permissive") !== "restrictive");
3705
3808
  if (permissive.length === 0) return false;
3706
3809
  let permissivePasses = false;
3707
3810
  for (const policy of permissive) {
3708
- const expression = await resolveExpression(policy, checkType, ctx, table);
3811
+ const expression = await resolveExpressionCached(policy, checkType, ctx, table, resolution);
3709
3812
  if (!expression || policyExpressionPasses(row, expression)) {
3710
3813
  permissivePasses = true;
3711
3814
  break;
@@ -3714,7 +3817,7 @@ async function evaluatePolicySet({ table, operation, checkType, row, rls }) {
3714
3817
  if (!permissivePasses) return false;
3715
3818
  const restrictive = policies.filter((policy) => (policy.as ?? "permissive") === "restrictive");
3716
3819
  for (const policy of restrictive) {
3717
- const expression = await resolveExpression(policy, checkType, ctx, table);
3820
+ const expression = await resolveExpressionCached(policy, checkType, ctx, table, resolution);
3718
3821
  if (!expression) continue;
3719
3822
  if (!policyExpressionPasses(row, expression)) return false;
3720
3823
  }
@@ -3726,7 +3829,8 @@ async function canSelectRow(options) {
3726
3829
  operation: "select",
3727
3830
  checkType: "using",
3728
3831
  row: options.row,
3729
- rls: options.rls
3832
+ rls: options.rls,
3833
+ cache: options.cache
3730
3834
  });
3731
3835
  }
3732
3836
  async function canInsertRow(options) {
@@ -3735,7 +3839,8 @@ async function canInsertRow(options) {
3735
3839
  operation: "insert",
3736
3840
  checkType: "withCheck",
3737
3841
  row: options.row,
3738
- rls: options.rls
3842
+ rls: options.rls,
3843
+ cache: options.cache
3739
3844
  });
3740
3845
  }
3741
3846
  async function canDeleteRow(options) {
@@ -3744,7 +3849,8 @@ async function canDeleteRow(options) {
3744
3849
  operation: "delete",
3745
3850
  checkType: "using",
3746
3851
  row: options.row,
3747
- rls: options.rls
3852
+ rls: options.rls,
3853
+ cache: options.cache
3748
3854
  });
3749
3855
  }
3750
3856
  async function evaluateUpdateDecision(options) {
@@ -3753,14 +3859,16 @@ async function evaluateUpdateDecision(options) {
3753
3859
  operation: "update",
3754
3860
  checkType: "using",
3755
3861
  row: options.existingRow,
3756
- rls: options.rls
3862
+ rls: options.rls,
3863
+ cache: options.cache
3757
3864
  });
3758
3865
  const withCheckAllowed = await evaluatePolicySet({
3759
3866
  table: options.table,
3760
3867
  operation: "update",
3761
3868
  checkType: "withCheck",
3762
3869
  row: options.updatedRow,
3763
- rls: options.rls
3870
+ rls: options.rls,
3871
+ cache: options.cache
3764
3872
  });
3765
3873
  return {
3766
3874
  allowed: usingAllowed && withCheckAllowed,
@@ -3776,8 +3884,10 @@ async function filterSelectRows(options) {
3776
3884
  operation: "select",
3777
3885
  rls: options.rls
3778
3886
  });
3887
+ const cache = options.cache ?? createRlsPolicyResolutionCache();
3779
3888
  const rows = [];
3780
3889
  for (const row of options.rows) if (await canSelectRow({
3890
+ cache,
3781
3891
  table: options.table,
3782
3892
  row,
3783
3893
  rls: options.rls
@@ -4462,11 +4572,37 @@ var GelRankQuery = class {
4462
4572
  *
4463
4573
  * Pattern from Drizzle: gel-core/query-builders/query.ts:32-62
4464
4574
  */
4465
- var GelRelationalQuery = class extends QueryPromise {
4575
+ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
4466
4576
  allowFullScan;
4467
- _countIndexReadinessByKey = /* @__PURE__ */ new Map();
4577
+ /**
4578
+ * Set synchronously by the first `execute()`. Later executions run on a fresh
4579
+ * instance instead, which is what keeps execution-scoped state from leaking
4580
+ * between two awaits of the same query object.
4581
+ */
4582
+ _executionClaimed = false;
4583
+ /**
4584
+ * Scoped to one execution, because `_executionClaimed` diverts every later
4585
+ * execution to its own instance. Within a run, every `_applyRlsSelectFilter`
4586
+ * call shares it, including the streaming sites that pass a single row at a
4587
+ * time — those would otherwise re-resolve the whole policy set per row.
4588
+ *
4589
+ * SECURITY: a resolved policy expression can embed database state that a
4590
+ * later write invalidates, so it must never outlive the execution that
4591
+ * resolved it.
4592
+ */
4593
+ _rlsPolicyResolution = createRlsPolicyResolutionCache();
4594
+ /**
4595
+ * Assigned in the constructor so callers that run many short-lived query
4596
+ * instances against the same tables (mutation `returning({ _count })`) can
4597
+ * share one readiness memo instead of re-probing per instance.
4598
+ */
4599
+ _countIndexReadinessByKey;
4600
+ /**
4601
+ * Index readiness is a probe memo, not execution state, so `_forExecution`
4602
+ * hands it to the next run rather than re-probing.
4603
+ */
4468
4604
  _aggregateIndexReadinessByKey = /* @__PURE__ */ new Map();
4469
- constructor(schema, tableConfig, edgeMetadata, db, config, mode, _allEdges, rls, relationLoading, vectorSearchProvider, configuredIndex) {
4605
+ constructor(schema, tableConfig, edgeMetadata, db, config, mode, _allEdges, rls, relationLoading, vectorSearchProvider, configuredIndex, countIndexReadiness) {
4470
4606
  super();
4471
4607
  this.schema = schema;
4472
4608
  this.tableConfig = tableConfig;
@@ -4480,6 +4616,7 @@ var GelRelationalQuery = class extends QueryPromise {
4480
4616
  this.vectorSearchProvider = vectorSearchProvider;
4481
4617
  this.configuredIndex = configuredIndex;
4482
4618
  this.allowFullScan = config.allowFullScan === true;
4619
+ this._countIndexReadinessByKey = countIndexReadiness ?? /* @__PURE__ */ new Map();
4483
4620
  }
4484
4621
  _usesSystemCreatedAtAlias(tableConfig = this.tableConfig) {
4485
4622
  return usesSystemCreatedAtAlias(tableConfig.table);
@@ -4558,6 +4695,7 @@ var GelRelationalQuery = class extends QueryPromise {
4558
4695
  async _applyRlsSelectFilter(rows, tableConfig) {
4559
4696
  if (!tableConfig) return rows;
4560
4697
  return await filterSelectRows({
4698
+ cache: this._rlsPolicyResolution,
4561
4699
  table: tableConfig.table,
4562
4700
  rows,
4563
4701
  rls: this.rls
@@ -6567,10 +6705,21 @@ var GelRelationalQuery = class extends QueryPromise {
6567
6705
  return rows;
6568
6706
  }
6569
6707
  /**
6708
+ * A second instance of the same query: identical configuration, its own
6709
+ * execution-scoped state, the same index-readiness memos.
6710
+ */
6711
+ _forExecution() {
6712
+ const next = new GelRelationalQuery(this.schema, this.tableConfig, this.edgeMetadata, this.db, this.config, this.mode, this._allEdges, this.rls, this.relationLoading, this.vectorSearchProvider, this.configuredIndex, this._countIndexReadinessByKey);
6713
+ next._aggregateIndexReadinessByKey = this._aggregateIndexReadinessByKey;
6714
+ return next;
6715
+ }
6716
+ /**
6570
6717
  * Execute the query and return results
6571
6718
  * Phase 4 implementation with WhereClauseCompiler integration
6572
6719
  */
6573
6720
  async execute() {
6721
+ if (this._executionClaimed) return this._forExecution().execute();
6722
+ this._executionClaimed = true;
6574
6723
  const config = this.config;
6575
6724
  if (this.mode === "count") return await this._executeCount(config);
6576
6725
  if (this.mode === "aggregate") return await this._executeAggregate(config);
@@ -7295,21 +7444,8 @@ var GelRelationalQuery = class extends QueryPromise {
7295
7444
  if (!this.allowFullScan) throw new Error(`${baseMessage} Set allowFullScan: true, reduce fan-out, or increase defineSchema(..., { defaults: { relationFanOutMaxKeys } }).`);
7296
7445
  if (options.tableConfig.strict !== false) console.warn(`${baseMessage} Continuing because allowFullScan: true.`);
7297
7446
  }
7298
- async _mapWithConcurrency(items, worker) {
7299
- if (items.length === 0) return [];
7300
- const limit = Math.min(this._getRelationConcurrency(), items.length);
7301
- const results = new Array(items.length);
7302
- let nextIndex = 0;
7303
- const runWorker = async () => {
7304
- while (true) {
7305
- const index = nextIndex;
7306
- nextIndex += 1;
7307
- if (index >= items.length) return;
7308
- results[index] = await worker(items[index], index);
7309
- }
7310
- };
7311
- await Promise.all(Array.from({ length: limit }, () => runWorker()));
7312
- return results;
7447
+ _mapWithConcurrency(items, worker) {
7448
+ return mapWithConcurrency(items, this._getRelationConcurrency(), worker);
7313
7449
  }
7314
7450
  /**
7315
7451
  * Load relations for query results
@@ -7772,7 +7908,16 @@ var GelRelationalQuery = class extends QueryPromise {
7772
7908
  if (key in tableColumns) throw new Error(`extras.${key} conflicts with a column on table '${tableName}'.`);
7773
7909
  if (withConfig && key in withConfig) throw new Error(`extras.${key} conflicts with a relation on table '${tableName}'.`);
7774
7910
  }
7775
- for (const row of rows) for (const [key, definition] of entries) row[key] = typeof definition === "function" ? definition(this._toPublicRow(row, tableConfig)) : definition;
7911
+ for (const row of rows) {
7912
+ let publicRow;
7913
+ for (const [key, definition] of entries) {
7914
+ if (typeof definition === "function") {
7915
+ publicRow ??= this._toPublicRow(row, tableConfig);
7916
+ row[key] = definition(publicRow);
7917
+ } else row[key] = definition;
7918
+ if (publicRow) publicRow[key] = row[key];
7919
+ }
7920
+ }
7776
7921
  return rows;
7777
7922
  }
7778
7923
  /**
@@ -7810,6 +7955,35 @@ var GelRelationalQuery = class extends QueryPromise {
7810
7955
  }
7811
7956
  };
7812
7957
 
7958
+ //#endregion
7959
+ //#region src/orm/returning-count.ts
7960
+ /**
7961
+ * Builds the `returning({ _count })` reader once per mutation statement.
7962
+ *
7963
+ * The table config lookup and edge filter are invariant across rows, and — more
7964
+ * importantly — the aggregate-index readiness memo lives on the query instance.
7965
+ * Constructing a fresh `GelRelationalQuery` per row discarded it, so the
7966
+ * readiness probe ran a real indexed collect for every row x counted relation
7967
+ * instead of once per (table, index).
7968
+ */
7969
+ function createReturningCountLoader(db, table, ormContext) {
7970
+ const schema = ormContext?.schema;
7971
+ const edgeMetadata = ormContext?.edgeMetadata;
7972
+ if (!schema || !edgeMetadata) throw new Error("returning({ _count }) requires orm.db(ctx) configured from createOrm({ schema, ... }).");
7973
+ const tableName = getTableName(table);
7974
+ const tableConfig = Object.values(schema).find((config) => config.name === tableName);
7975
+ if (!tableConfig) throw new Error(`Table config for '${tableName}' is not registered.`);
7976
+ const tableEdges = edgeMetadata.filter((edge) => edge.sourceTable === tableName);
7977
+ const countIndexReadiness = /* @__PURE__ */ new Map();
7978
+ return { async load(row, countSelection) {
7979
+ return (await new GelRelationalQuery(schema, tableConfig, tableEdges, db, {
7980
+ where: { id: row._id },
7981
+ columns: {},
7982
+ with: { _count: countSelection }
7983
+ }, "first", edgeMetadata, ormContext?.rls, ormContext?.relationLoading, void 0, void 0, countIndexReadiness).execute())?._count ?? {};
7984
+ } };
7985
+ }
7986
+
7813
7987
  //#endregion
7814
7988
  //#region src/orm/delete.ts
7815
7989
  const applyIndexFilter$1 = (query, filter) => {
@@ -7834,18 +8008,14 @@ var ConvexDeleteBuilder = class extends QueryPromise {
7834
8008
  scheduledDelayMs;
7835
8009
  executionModeOverride;
7836
8010
  paginateConfig;
8011
+ _returningCountLoader;
8012
+ /**
8013
+ * One loader per statement: the table config, edge filter and aggregate-index
8014
+ * readiness memo are invariant across the affected rows.
8015
+ */
7837
8016
  async _loadReturningCount(row, countSelection, ormContext) {
7838
- const schema = ormContext?.schema;
7839
- const edgeMetadata = ormContext?.edgeMetadata;
7840
- if (!schema || !edgeMetadata) throw new Error("returning({ _count }) requires orm.db(ctx) configured from createOrm({ schema, ... }).");
7841
- const tableName = getTableName(this.table);
7842
- const tableConfig = Object.values(schema).find((config) => config.name === tableName);
7843
- if (!tableConfig) throw new Error(`Table config for '${tableName}' is not registered.`);
7844
- return (await new GelRelationalQuery(schema, tableConfig, edgeMetadata.filter((edge) => edge.sourceTable === tableName), this.db, {
7845
- where: { id: row._id },
7846
- columns: {},
7847
- with: { _count: countSelection }
7848
- }, "first", edgeMetadata, ormContext?.rls, ormContext?.relationLoading).execute())?._count ?? {};
8017
+ this._returningCountLoader ??= createReturningCountLoader(this.db, this.table, ormContext);
8018
+ return await this._returningCountLoader.load(row, countSelection);
7849
8019
  }
7850
8020
  constructor(db, table) {
7851
8021
  super();
@@ -8079,6 +8249,7 @@ var ConvexDeleteBuilder = class extends QueryPromise {
8079
8249
  const fkBatchSize = isPaginated ? pagination.limit : batchSize;
8080
8250
  for (const row of rows) {
8081
8251
  if (!await canDeleteRow({
8252
+ cache: createRlsPolicyResolutionCache(),
8082
8253
  table: this.table,
8083
8254
  row,
8084
8255
  rls
@@ -8151,18 +8322,14 @@ var ConvexInsertBuilder = class extends QueryPromise {
8151
8322
  returningFields;
8152
8323
  conflictConfig;
8153
8324
  allowFullScanFlag = false;
8325
+ _returningCountLoader;
8326
+ /**
8327
+ * One loader per statement: the table config, edge filter and aggregate-index
8328
+ * readiness memo are invariant across the affected rows.
8329
+ */
8154
8330
  async _loadReturningCount(row, countSelection, ormContext) {
8155
- const schema = ormContext?.schema;
8156
- const edgeMetadata = ormContext?.edgeMetadata;
8157
- if (!schema || !edgeMetadata) throw new Error("returning({ _count }) requires orm.db(ctx) configured from createOrm({ schema, ... }).");
8158
- const tableName = getTableName(this.table);
8159
- const tableConfig = Object.values(schema).find((config) => config.name === tableName);
8160
- if (!tableConfig) throw new Error(`Table config for '${tableName}' is not registered.`);
8161
- return (await new GelRelationalQuery(schema, tableConfig, edgeMetadata.filter((edge) => edge.sourceTable === tableName), this.db, {
8162
- where: { id: row._id },
8163
- columns: {},
8164
- with: { _count: countSelection }
8165
- }, "first", edgeMetadata, ormContext?.rls, ormContext?.relationLoading).execute())?._count ?? {};
8331
+ this._returningCountLoader ??= createReturningCountLoader(this.db, this.table, ormContext);
8332
+ return await this._returningCountLoader.load(row, countSelection);
8166
8333
  }
8167
8334
  constructor(db, table) {
8168
8335
  super();
@@ -8207,12 +8374,14 @@ var ConvexInsertBuilder = class extends QueryPromise {
8207
8374
  enforcePolymorphicWrite(this.table, preparedValue);
8208
8375
  const rls = ormContext?.rls;
8209
8376
  const tableName = getTableName(this.table);
8377
+ const rlsResolution = createRlsPolicyResolutionCache();
8210
8378
  if (!await canInsertRow({
8379
+ cache: rlsResolution,
8211
8380
  table: this.table,
8212
8381
  row: preparedValue,
8213
8382
  rls
8214
8383
  })) throw new Error(`RLS policy violation for insert on table "${tableName}"`);
8215
- const conflictResult = await this.handleConflict(preparedValue);
8384
+ const conflictResult = await this.handleConflict(preparedValue, rlsResolution);
8216
8385
  if (conflictResult?.status === "skip") continue;
8217
8386
  if (conflictResult?.status === "updated") {
8218
8387
  if (conflictResult.row && this.returningFields) results.push(await this.resolveReturningRow(conflictResult.row, returningSelection, ormContext));
@@ -8235,7 +8404,7 @@ var ConvexInsertBuilder = class extends QueryPromise {
8235
8404
  if (returningSelection?.countSelection) selected._count = await this._loadReturningCount(row, returningSelection.countSelection, ormContext);
8236
8405
  return selected;
8237
8406
  }
8238
- async handleConflict(value) {
8407
+ async handleConflict(value, rlsResolution) {
8239
8408
  if (!this.conflictConfig) return;
8240
8409
  const { action, config } = this.conflictConfig;
8241
8410
  const targetColumns = Array.isArray(config.target) ? config.target : config.target ? [config.target] : [];
@@ -8280,6 +8449,7 @@ var ConvexInsertBuilder = class extends QueryPromise {
8280
8449
  };
8281
8450
  const writeSet = normalizeDateFieldsForWrite(this.table, effectiveSet);
8282
8451
  const updateDecision = await evaluateUpdateDecision({
8452
+ cache: rlsResolution,
8283
8453
  table: this.table,
8284
8454
  existingRow: existing,
8285
8455
  updatedRow: {
@@ -8455,9 +8625,14 @@ const getOrmLifecycleInnerDb = (db) => {
8455
8625
  return inner;
8456
8626
  };
8457
8627
  const isBeforeDataResult = (value) => typeof value === "object" && value !== null && "data" in value;
8628
+ /**
8629
+ * FIFO mutex. A re-checking spin over one shared promise wakes every waiter on
8630
+ * each release, which costs n(n-1)/2 resumptions for n contenders; handing the
8631
+ * lock to exactly one queued waiter costs n-1.
8632
+ */
8458
8633
  var Lock = class {
8459
- promise = null;
8460
- resolve = null;
8634
+ locked = false;
8635
+ waiters = [];
8461
8636
  async withLock(fn) {
8462
8637
  const unlock = await this.acquire();
8463
8638
  try {
@@ -8467,15 +8642,20 @@ var Lock = class {
8467
8642
  }
8468
8643
  }
8469
8644
  async acquire() {
8470
- while (this.promise !== null) await this.promise;
8471
- let resolve;
8472
- this.promise = new Promise((res) => {
8473
- resolve = res;
8645
+ if (this.locked) await new Promise((resolve) => {
8646
+ this.waiters.push(resolve);
8474
8647
  });
8475
- this.resolve = resolve;
8648
+ this.locked = true;
8649
+ let released = false;
8476
8650
  return () => {
8477
- this.promise = null;
8478
- this.resolve?.();
8651
+ if (released) return;
8652
+ released = true;
8653
+ const next = this.waiters.shift();
8654
+ if (next) {
8655
+ next();
8656
+ return;
8657
+ }
8658
+ this.locked = false;
8479
8659
  };
8480
8660
  }
8481
8661
  };
@@ -8502,6 +8682,69 @@ const withPublicIdAlias = (doc, id) => doc.id !== void 0 ? doc : {
8502
8682
  ...doc,
8503
8683
  id
8504
8684
  };
8685
+ const isPlainRecord = (value) => {
8686
+ if (typeof value !== "object" || value === null) return false;
8687
+ const prototype = Object.getPrototypeOf(value);
8688
+ return prototype === Object.prototype || prototype === null;
8689
+ };
8690
+ /**
8691
+ * Convex value serialization drops `undefined` at any depth below the top
8692
+ * level, so a document read back from storage never carries a nested
8693
+ * `undefined` key. Mirror that when deriving a document locally, otherwise
8694
+ * `Object.keys(newDoc)` diverges from what a hook would observe.
8695
+ */
8696
+ const stripUndefinedDeep = (value) => {
8697
+ if (Array.isArray(value)) {
8698
+ let changed = false;
8699
+ const next = value.map((entry) => {
8700
+ const stripped = stripUndefinedDeep(entry);
8701
+ if (stripped !== entry) changed = true;
8702
+ return stripped;
8703
+ });
8704
+ return changed ? next : value;
8705
+ }
8706
+ if (!isPlainRecord(value)) return value;
8707
+ let changed = false;
8708
+ const next = {};
8709
+ for (const key of Object.keys(value)) {
8710
+ const nested = value[key];
8711
+ if (nested === void 0) {
8712
+ changed = true;
8713
+ continue;
8714
+ }
8715
+ const stripped = stripUndefinedDeep(nested);
8716
+ if (stripped !== nested) changed = true;
8717
+ next[key] = stripped;
8718
+ }
8719
+ return changed ? next : value;
8720
+ };
8721
+ /**
8722
+ * Reproduce Convex `1.0/shallowMerge`: a top-level shallow merge where a
8723
+ * top-level `undefined` removes the key. The stored document is fully
8724
+ * determined by the document we already read plus the payload we are about to
8725
+ * write, so re-reading it after the patch buys nothing.
8726
+ */
8727
+ const applyPatchLocally = (oldDoc, payload) => {
8728
+ const payloadKeys = Object.keys(payload);
8729
+ const removed = /* @__PURE__ */ new Set();
8730
+ for (const key of payloadKeys) if (payload[key] === void 0) removed.add(key);
8731
+ const newDoc = {};
8732
+ for (const key of Object.keys(oldDoc)) {
8733
+ if (removed.has(key)) continue;
8734
+ newDoc[key] = oldDoc[key];
8735
+ }
8736
+ for (const key of payloadKeys) {
8737
+ if (removed.has(key)) continue;
8738
+ newDoc[key] = stripUndefinedDeep(payload[key]);
8739
+ }
8740
+ return newDoc;
8741
+ };
8742
+ /** Convex owns these; a replace payload can never carry them forward. */
8743
+ const applyReplaceLocally = (oldDoc, payload) => ({
8744
+ ...stripUndefinedDeep(payload),
8745
+ _id: oldDoc._id,
8746
+ _creationTime: oldDoc._creationTime
8747
+ });
8505
8748
  const tableNameFromId = (db, hooksByTable, id) => {
8506
8749
  for (const tableName of hooksByTable.keys()) if (db.normalizeId(tableName, id)) return tableName;
8507
8750
  return null;
@@ -8548,18 +8791,16 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
8548
8791
  const tableHooks = hooksByTable.get(tableName);
8549
8792
  if (!tableHooks) return innerDb.patch(tableName, id, value);
8550
8793
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
8551
- const oldDoc = await innerDb.get(tableName, id);
8794
+ const needsDocuments = Boolean(tableHooks.update?.after || tableHooks.change);
8795
+ const oldDoc = needsDocuments ? await innerDb.get(tableName, id) : null;
8552
8796
  const updatePayload = await mergeBeforeData(tableName, "update", tableHooks.update?.before, value, hookCtx);
8797
+ const currentDoc = needsDocuments && tableHooks.update?.before ? await innerDb.get(tableName, id) : oldDoc;
8553
8798
  await innerDb.patch(tableName, id, updatePayload);
8554
8799
  if (!oldDoc) return {
8555
8800
  result: void 0,
8556
8801
  queuedHooks: []
8557
8802
  };
8558
- const newDoc = await innerDb.get(tableName, id);
8559
- if (!newDoc) return {
8560
- result: void 0,
8561
- queuedHooks: []
8562
- };
8803
+ const newDoc = applyPatchLocally(currentDoc ?? oldDoc, updatePayload);
8563
8804
  const oldDocWithId = withPublicIdAlias(oldDoc, id);
8564
8805
  const newDocWithId = withPublicIdAlias(newDoc, id);
8565
8806
  const change = {
@@ -8595,18 +8836,14 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
8595
8836
  const tableHooks = hooksByTable.get(tableName);
8596
8837
  if (!tableHooks) return innerDb.replace(tableName, id, value);
8597
8838
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
8598
- const oldDoc = await innerDb.get(tableName, id);
8839
+ const oldDoc = Boolean(tableHooks.update?.after || tableHooks.change) ? await innerDb.get(tableName, id) : null;
8599
8840
  const updatePayload = await mergeBeforeData(tableName, "update", tableHooks.update?.before, value, hookCtx);
8600
8841
  await innerDb.replace(tableName, id, updatePayload);
8601
8842
  if (!oldDoc) return {
8602
8843
  result: void 0,
8603
8844
  queuedHooks: []
8604
8845
  };
8605
- const newDoc = await innerDb.get(tableName, id);
8606
- if (!newDoc) return {
8607
- result: void 0,
8608
- queuedHooks: []
8609
- };
8846
+ const newDoc = applyReplaceLocally(oldDoc, updatePayload);
8610
8847
  const oldDocWithId = withPublicIdAlias(oldDoc, id);
8611
8848
  const newDocWithId = withPublicIdAlias(newDoc, id);
8612
8849
  const change = {
@@ -8634,7 +8871,7 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
8634
8871
  const tableHooks = hooksByTable.get(tableName);
8635
8872
  if (!tableHooks) return innerDb.delete(tableName, id);
8636
8873
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
8637
- const oldDoc = await innerDb.get(tableName, id);
8874
+ const oldDoc = Boolean(tableHooks.delete || tableHooks.change) ? await innerDb.get(tableName, id) : null;
8638
8875
  if (!oldDoc) {
8639
8876
  await innerDb.delete(tableName, id);
8640
8877
  return {
@@ -8671,7 +8908,7 @@ function writerWithHooks(ctx, innerDb, hooksByTable, isWithinHook = false) {
8671
8908
  return executeThenDrainHooks(ctx, innerDb, hooksByTable, isWithinHook, async (hookCtx) => {
8672
8909
  const insertPayload = await mergeBeforeData(table, "create", tableHooks.create?.before, value, hookCtx);
8673
8910
  const id = await innerDb.insert(table, insertPayload);
8674
- const newDoc = await innerDb.get(table, id);
8911
+ const newDoc = Boolean(tableHooks.create?.after || tableHooks.change) ? await innerDb.get(table, id) : null;
8675
8912
  if (!newDoc) return {
8676
8913
  result: id,
8677
8914
  queuedHooks: []
@@ -8768,11 +9005,13 @@ function createOrmDbLifecycle(schema, triggerDefinitions) {
8768
9005
  });
8769
9006
  }
8770
9007
  if (tableHooks.size === 0) return createNoopLifecycle();
9008
+ const hookedTableNames = new Set(tableHooks.keys());
8771
9009
  return {
8772
9010
  enabled: true,
8773
9011
  wrapDB: (ctx) => {
8774
9012
  if (!isWriterDb(ctx.db) || isLifecycleWrappedDb(ctx.db)) return ctx;
8775
9013
  const wrappedDb = writerWithHooks(ctx, ctx.db, tableHooks, false);
9014
+ markLifecycleHookedTables(wrappedDb, hookedTableNames);
8776
9015
  return {
8777
9016
  ...ctx,
8778
9017
  db: markLifecycleWrappedDb(wrappedDb)
@@ -8977,18 +9216,14 @@ var ConvexUpdateBuilder = class extends QueryPromise {
8977
9216
  allowFullScanFlag = false;
8978
9217
  paginateConfig;
8979
9218
  executionModeOverride;
9219
+ _returningCountLoader;
9220
+ /**
9221
+ * One loader per statement: the table config, edge filter and aggregate-index
9222
+ * readiness memo are invariant across the affected rows.
9223
+ */
8980
9224
  async _loadReturningCount(row, countSelection, ormContext) {
8981
- const schema = ormContext?.schema;
8982
- const edgeMetadata = ormContext?.edgeMetadata;
8983
- if (!schema || !edgeMetadata) throw new Error("returning({ _count }) requires orm.db(ctx) configured from createOrm({ schema, ... }).");
8984
- const tableName = getTableName(this.table);
8985
- const tableConfig = Object.values(schema).find((config) => config.name === tableName);
8986
- if (!tableConfig) throw new Error(`Table config for '${tableName}' is not registered.`);
8987
- return (await new GelRelationalQuery(schema, tableConfig, edgeMetadata.filter((edge) => edge.sourceTable === tableName), this.db, {
8988
- where: { id: row._id },
8989
- columns: {},
8990
- with: { _count: countSelection }
8991
- }, "first", edgeMetadata, ormContext?.rls, ormContext?.relationLoading).execute())?._count ?? {};
9225
+ this._returningCountLoader ??= createReturningCountLoader(this.db, this.table, ormContext);
9226
+ return await this._returningCountLoader.load(row, countSelection);
8992
9227
  }
8993
9228
  constructor(db, table) {
8994
9229
  super();
@@ -9103,6 +9338,7 @@ var ConvexUpdateBuilder = class extends QueryPromise {
9103
9338
  operation: "update",
9104
9339
  rls
9105
9340
  });
9341
+ const rlsResolution = createRlsPolicyResolutionCache();
9106
9342
  const onUpdateSet = {};
9107
9343
  for (const [columnName, builder] of Object.entries(getTableColumns$2(this.table))) {
9108
9344
  if (columnName in normalizedSetValues) continue;
@@ -9225,6 +9461,7 @@ var ConvexUpdateBuilder = class extends QueryPromise {
9225
9461
  row,
9226
9462
  updatedRow,
9227
9463
  decision: await evaluateUpdateDecision({
9464
+ cache: rlsResolution,
9228
9465
  table: this.table,
9229
9466
  existingRow: row,
9230
9467
  updatedRow,
@@ -10571,14 +10808,12 @@ function scheduledMutationBatchFactory(schema, edgeMetadata, scheduledMutationBa
10571
10808
  if (workType === "cascade-delete") {
10572
10809
  if (action === "set null") {
10573
10810
  ensureNullableColumns(table, sourceColumns, `Foreign key set null on '${args.table}'`);
10574
- for (const row of rows) {
10575
- const patch = {};
10576
- for (const columnName of sourceColumns) patch[columnName] = null;
10577
- await ctx.db.patch(args.table, row._id, patch);
10578
- }
10811
+ const nullPatch = {};
10812
+ for (const columnName of sourceColumns) nullPatch[columnName] = null;
10813
+ await patchReferencingRows(ctx.db, args.table, rows, nullPatch);
10579
10814
  } else if (action === "set default") {
10580
10815
  const defaults = ensureDefaultColumns(table, sourceColumns, `Foreign key set default on '${args.table}'`);
10581
- for (const row of rows) await ctx.db.patch(args.table, row._id, defaults);
10816
+ await patchReferencingRows(ctx.db, args.table, rows, defaults);
10582
10817
  } else if (action === "cascade") {
10583
10818
  if (!foreignKeyGraph) throw new Error("scheduledMutationBatch: foreign key graph is missing from ORM context.");
10584
10819
  for (const row of rows) {
@@ -10607,21 +10842,19 @@ function scheduledMutationBatchFactory(schema, edgeMetadata, scheduledMutationBa
10607
10842
  } else if (workType === "cascade-update") {
10608
10843
  if (action === "set null") {
10609
10844
  ensureNullableColumns(table, sourceColumns, `Foreign key set null on '${args.table}'`);
10610
- for (const row of rows) {
10611
- const patch = {};
10612
- for (const columnName of sourceColumns) patch[columnName] = null;
10613
- await ctx.db.patch(args.table, row._id, patch);
10614
- }
10845
+ const nullPatch = {};
10846
+ for (const columnName of sourceColumns) nullPatch[columnName] = null;
10847
+ await patchReferencingRows(ctx.db, args.table, rows, nullPatch);
10615
10848
  } else if (action === "set default") {
10616
10849
  const defaults = ensureDefaultColumns(table, sourceColumns, `Foreign key set default on '${args.table}'`);
10617
- for (const row of rows) await ctx.db.patch(args.table, row._id, defaults);
10850
+ await patchReferencingRows(ctx.db, args.table, rows, defaults);
10618
10851
  } else if (action === "cascade") {
10619
10852
  const newValues = decodeUndefinedDeep(args.newValues);
10620
10853
  if (!newValues || !Array.isArray(newValues)) throw new Error("scheduledMutationBatch: newValues are required for cascade update.");
10621
10854
  const patchValues = {};
10622
10855
  for (let i = 0; i < sourceColumns.length; i += 1) patchValues[sourceColumns[i]] = newValues[i];
10623
10856
  ensureNonNullValues(table, patchValues, `Foreign key cascade update on '${args.table}'`);
10624
- for (const row of rows) await ctx.db.patch(args.table, row._id, patchValues);
10857
+ await patchReferencingRows(ctx.db, args.table, rows, patchValues);
10625
10858
  }
10626
10859
  }
10627
10860
  if (await queryWithIndex().first() !== null || hitByteLimit) await ctx.scheduler.runAfter(args.delayMs, scheduledMutationBatch, {