kitcn 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/orm/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { A as vectorIndex, B as ConvexColumnBuilder, C as RlsPolicy, D as rankIndex, E as index, F as arrayOf, I as custom, L as json, M as createSystemFields, N as integer, O as searchIndex, P as id, R as objectOf, S as TablePolymorphic, T as aggregateIndex, V as entityKind, _ as OrmSchemaRelations, a as deletion, b as TableDeleteConfig, c as Columns, d as OrmSchemaDefinition, f as OrmSchemaExtensionRelations, g as OrmSchemaOptions, h as OrmSchemaExtensions, i as convexTable, j as text, k as uniqueIndex, l as EnableRLS, m as OrmSchemaExtensionTriggers, o as discriminator, p as OrmSchemaExtensionTables, s as Brand, t as DirectAggregate, u as OrmContext, v as OrmSchemaTriggers, w as rlsPolicy, x as TableName, y as RlsPolicies, z as unionOf } from "../runtime-DmVSOe24.js";
1
+ import { A as vectorIndex, B as ConvexColumnBuilder, C as RlsPolicy, D as rankIndex, E as index, F as arrayOf, I as custom, L as json, M as createSystemFields, N as integer, O as searchIndex, P as id, R as objectOf, S as TablePolymorphic, T as aggregateIndex, V as entityKind, _ as OrmSchemaRelations, a as deletion, b as TableDeleteConfig, c as Columns, d as OrmSchemaDefinition, f as OrmSchemaExtensionRelations, g as OrmSchemaOptions, h as OrmSchemaExtensions, i as convexTable, j as text, k as uniqueIndex, l as EnableRLS, m as OrmSchemaExtensionTriggers, o as discriminator, p as OrmSchemaExtensionTables, s as Brand, t as DirectAggregate, u as OrmContext, v as OrmSchemaTriggers, w as rlsPolicy, x as TableName, y as RlsPolicies, z as unionOf } from "../runtime-6DJxpDfb.js";
2
2
  import { a as pretendRequired, i as pretend, n as deprecated } from "../validators-C7LelqTN.js";
3
- import { A as like, B as or, C as gt, D as isFieldReference, E as inArray, F as not, I as notBetween, L as notIlike, M as lte, N as matchLikePattern, O as isNotNull, P as ne, R as notInArray, S as filterValuesEqual, T as ilike, V as startsWith, _ as contains, a as indexKeyWithinBounds, b as fieldRef, c as streamIndexRange, d as and, f as arrayContained, g as column, h as between, i as getIndexFields, j as lt, k as isNull, l as isUnsetToken, m as arrayOverlaps, n as EmptyStream, o as mergedStream, p as arrayContains, r as QueryStream, s as stream, t as getByIdWithOrmQueryFallback, u as unsetToken, v as endsWith, w as gte, x as filterValueInList, y as eq, z as notLike } from "../query-context-CJE_hYA3.js";
3
+ import { A as like, B as or, C as gt, D as isFieldReference, E as inArray, F as not, I as notBetween, L as notIlike, M as lte, N as matchLikePattern, O as isNotNull, P as ne, R as notInArray, S as filterValuesEqual, T as ilike, V as startsWith, _ as contains, a as indexKeyWithinBounds, b as fieldRef, c as streamIndexRange, d as and, f as arrayContained, g as column, h as between, i as getIndexFields, j as lt, k as isNull, l as isUnsetToken, m as arrayOverlaps, n as EmptyStream, o as mergedStream, p as arrayContains, r as QueryStream, s as stream, t as getByIdWithOrmQueryFallback, u as unsetToken, v as endsWith, w as gte, x as filterValueInList, y as eq, z as notLike } from "../query-context-BzihIpnM.js";
4
4
  import { compareValues, v } from "convex/values";
5
5
  import { defineSchema as defineSchema$1, internalActionGeneric, internalMutationGeneric } from "convex/server";
6
6
 
@@ -1658,7 +1658,11 @@ const countStateTable = convexTable(AGGREGATE_STATE_TABLE, {
1658
1658
  updatedAt: integer().notNull(),
1659
1659
  completedAt: integer(),
1660
1660
  lastError: text()
1661
- }, (t) => [index("by_kind_table_index").on(t.kind, t.tableKey, t.indexName), index("by_kind_status").on(t.kind, t.status)]);
1661
+ }, (t) => [
1662
+ index("by_kind_table_index").on(t.kind, t.tableKey, t.indexName),
1663
+ index("by_kind_status").on(t.kind, t.status),
1664
+ index("by_table_status").on(t.tableKey, t.status)
1665
+ ]);
1662
1666
  const rankTreeTable = convexTable(AGGREGATE_RANK_TREE_TABLE, {
1663
1667
  aggregateName: text().notNull(),
1664
1668
  maxNodeSize: integer().notNull(),
@@ -1697,12 +1701,15 @@ const FLOAT64_MASK = (1n << 64n) - 1n;
1697
1701
  const DEFAULT_AGGREGATE_CARTESIAN_MAX_KEYS$1 = 4096;
1698
1702
  const DEFAULT_AGGREGATE_WORK_BUDGET$1 = 16384;
1699
1703
  const RANGE_PREFIX_WORK_UNIT_BASE = 2;
1704
+ const AGGREGATE_BUCKET_READ_CONCURRENCY = 25;
1700
1705
  const PUBLIC_ID_FIELD$1 = "id";
1701
1706
  const INTERNAL_ID_FIELD$1 = "_id";
1702
1707
  const AGGREGATE_STATE_KIND_METRIC = "metric";
1703
1708
  const AGGREGATE_STATE_KIND_RANK = "rank";
1704
1709
  const COUNT_STATUS_BUILDING = "BUILDING";
1705
1710
  const COUNT_STATUS_READY = "READY";
1711
+ /** Stored state is being drained before a rebuild, across several mutations. */
1712
+ const COUNT_STATUS_CLEARING = "CLEARING";
1706
1713
  const COUNT_ERROR = {
1707
1714
  FILTER_UNSUPPORTED: "COUNT_FILTER_UNSUPPORTED",
1708
1715
  NOT_INDEXED: "COUNT_NOT_INDEXED",
@@ -1716,6 +1723,7 @@ const AGGREGATE_ERROR = {
1716
1723
  INDEX_BUILDING: "AGGREGATE_INDEX_BUILDING",
1717
1724
  RLS_UNSUPPORTED: "AGGREGATE_RLS_UNSUPPORTED"
1718
1725
  };
1726
+ const RANGE_WORK_STATE = Symbol("aggregateRangeWorkState");
1719
1727
  const isPlainObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
1720
1728
  const normalizeUndefined = (value) => {
1721
1729
  if (Array.isArray(value)) return value.map((entry) => normalizeUndefined(entry));
@@ -2359,7 +2367,10 @@ const compileAggregatePlan = (tableConfig, where, metric, codes, methodName) =>
2359
2367
  rangeConstraint: {
2360
2368
  fieldName: rangeFieldName,
2361
2369
  comparisons: rangeComparisons,
2362
- prefixFields: rangeIndex.prefixFields
2370
+ prefixFields: rangeIndex.prefixFields,
2371
+ workBudget: getAggregateWorkBudget(tableConfig),
2372
+ filterErrorCode: codes.FILTER_UNSUPPORTED,
2373
+ methodName
2363
2374
  },
2364
2375
  postFieldValues,
2365
2376
  metric
@@ -2428,14 +2439,17 @@ const listBucketsByHash = async (db, tableName, indexName, keyHash) => await db.
2428
2439
  const getBucketByKey = async (db, tableName, indexName, keyParts) => {
2429
2440
  return (await listBucketsByHash(db, tableName, indexName, serializeCountKeyParts(keyParts))).find((bucket) => deepEquals(bucket.keyParts, keyParts)) ?? null;
2430
2441
  };
2431
- const listBucketsByHashPrefix = async (db, tableName, indexName, prefixStart, prefixEnd) => await db.query(AGGREGATE_BUCKET_TABLE).withIndex("by_table_index_hash", (q) => q.eq("tableKey", tableName).eq("indexName", indexName).gte("keyHash", prefixStart).lt("keyHash", prefixEnd)).collect();
2442
+ const listBucketsByHashPrefix = async (db, tableName, indexName, scan, limit) => await db.query(AGGREGATE_BUCKET_TABLE).withIndex("by_table_index_hash", (q) => {
2443
+ const scoped = q.eq("tableKey", tableName).eq("indexName", indexName);
2444
+ return (scan.after === void 0 ? scoped.gte("keyHash", scan.start) : scoped.gt("keyHash", scan.after)).lt("keyHash", scan.end);
2445
+ }).take(limit);
2432
2446
  const getExtremaByValue = async (db, tableName, indexName, keyHash, fieldName, value) => {
2433
2447
  const valueHash = serializeStable(value);
2434
2448
  return (await db.query(AGGREGATE_EXTREMA_TABLE).withIndex("by_table_index_hash_field_value", (q) => q.eq("tableKey", tableName).eq("indexName", indexName).eq("keyHash", keyHash).eq("fieldName", fieldName).eq("valueHash", valueHash)).collect()).find((row) => deepEquals(row.value, value)) ?? null;
2435
2449
  };
2436
- const listMembersForIndex = async (db, tableName, indexName) => await db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index", (q) => q.eq("kind", AGGREGATE_STATE_KIND_METRIC).eq("tableKey", tableName).eq("indexName", indexName)).collect();
2437
- const listBucketsForIndex = async (db, tableName, indexName) => await db.query(AGGREGATE_BUCKET_TABLE).withIndex("by_table_index", (q) => q.eq("tableKey", tableName).eq("indexName", indexName)).collect();
2438
- const listExtremaForIndex = async (db, tableName, indexName) => await db.query(AGGREGATE_EXTREMA_TABLE).withIndex("by_table_index", (q) => q.eq("tableKey", tableName).eq("indexName", indexName)).collect();
2450
+ const takeMembersForIndex = async (db, tableName, indexName, limit) => await db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index", (q) => q.eq("kind", AGGREGATE_STATE_KIND_METRIC).eq("tableKey", tableName).eq("indexName", indexName)).take(limit);
2451
+ const takeBucketsForIndex = async (db, tableName, indexName, limit) => await db.query(AGGREGATE_BUCKET_TABLE).withIndex("by_table_index", (q) => q.eq("tableKey", tableName).eq("indexName", indexName)).take(limit);
2452
+ const takeExtremaForIndex = async (db, tableName, indexName, limit) => await db.query(AGGREGATE_EXTREMA_TABLE).withIndex("by_table_index", (q) => q.eq("tableKey", tableName).eq("indexName", indexName)).take(limit);
2439
2453
  const applyBucketDelta = async (db, tableName, indexName, keyParts, deltaCount, deltaSums, deltaNonNullCounts) => {
2440
2454
  if (deltaCount === 0 && Object.keys(deltaSums).length === 0 && Object.keys(deltaNonNullCounts).length === 0) return;
2441
2455
  const existing = await getBucketByKey(db, tableName, indexName, keyParts);
@@ -2521,10 +2535,6 @@ const applyExtremaDelta = async (db, tableName, indexName, keyHash, fieldName, v
2521
2535
  updatedAt: now
2522
2536
  });
2523
2537
  };
2524
- const applyExtremaValuesDelta = async (db, tableName, indexName, keyHash, values, delta) => {
2525
- if (delta === 0) return;
2526
- for (const [fieldName, value] of Object.entries(values)) await applyExtremaDelta(db, tableName, indexName, keyHash, fieldName, value, delta);
2527
- };
2528
2538
  const buildKeyHashPrefixBounds = (prefixParts) => {
2529
2539
  if (prefixParts.length === 0) return {
2530
2540
  start: "[",
@@ -2537,47 +2547,73 @@ const buildKeyHashPrefixBounds = (prefixParts) => {
2537
2547
  };
2538
2548
  };
2539
2549
  const matchesKeyPrefix = (keyParts, prefixParts) => prefixParts.every((value, index) => deepEquals(keyParts[index], value));
2540
- const readPlanBuckets = async (db, plan) => {
2541
- if (!plan.rangeConstraint) {
2542
- const keyCandidates = plan.keyCandidates ?? buildCandidateKeys(plan.indexFields, plan.fieldValues);
2543
- const matched = [];
2544
- for (const keyParts of keyCandidates) {
2545
- const bucket = await getBucketByKey(db, plan.tableName, plan.indexName, keyParts);
2546
- if (!bucket) continue;
2547
- matched.push(bucket);
2548
- }
2549
- return matched;
2550
+ /**
2551
+ * A prefix scan only bounds the key tuple's leading equality fields, so every
2552
+ * bucket it returns still has to clear the range predicate and any equality
2553
+ * field that sits after the range field in the index.
2554
+ */
2555
+ const matchesRangePlanBucket = (options) => {
2556
+ const { bucket, indexFields } = options;
2557
+ if (!matchesKeyPrefix(bucket.keyParts, options.prefixParts)) return false;
2558
+ if (!matchesRangeComparisons(bucket.keyParts[options.rangeFieldIndex], options.comparisons)) return false;
2559
+ for (const [field, allowedValues] of Object.entries(options.postFieldSets)) {
2560
+ const fieldIndex = indexFields.indexOf(field);
2561
+ if (fieldIndex < 0) return false;
2562
+ if (!allowedValues.has(serializeStable(bucket.keyParts[fieldIndex]))) return false;
2550
2563
  }
2564
+ return true;
2565
+ };
2566
+ const readPlanBuckets = async (db, plan) => {
2567
+ if (!plan.rangeConstraint) return (await mapWithConcurrency(plan.keyCandidates ?? buildCandidateKeys(plan.indexFields, plan.fieldValues), AGGREGATE_BUCKET_READ_CONCURRENCY, (keyParts) => getBucketByKey(db, plan.tableName, plan.indexName, keyParts))).filter((bucket) => bucket !== null);
2551
2568
  const prefixCandidates = buildCandidateKeys(plan.rangeConstraint.prefixFields, plan.fieldValues);
2552
2569
  const rangeFieldIndex = plan.indexFields.indexOf(plan.rangeConstraint.fieldName);
2553
2570
  if (rangeFieldIndex < 0) return [];
2554
2571
  const postFieldSets = Object.fromEntries(Object.entries(plan.postFieldValues).map(([field, values]) => [field, toConstraintSet(values)]));
2572
+ const workBudget = plan.rangeConstraint.workBudget ?? DEFAULT_AGGREGATE_WORK_BUDGET$1;
2573
+ const scanLimit = workBudget + 1;
2574
+ let pending = prefixCandidates.map((prefixParts) => ({
2575
+ prefixParts,
2576
+ ...buildKeyHashPrefixBounds(prefixParts)
2577
+ }));
2555
2578
  const matchedById = /* @__PURE__ */ new Map();
2556
- for (const prefixParts of prefixCandidates) {
2557
- const { start, end } = buildKeyHashPrefixBounds(prefixParts);
2558
- const buckets = await listBucketsByHashPrefix(db, plan.tableName, plan.indexName, start, end);
2559
- for (const bucket of buckets) {
2560
- if (!matchesKeyPrefix(bucket.keyParts, prefixParts)) continue;
2561
- const rangeValue = bucket.keyParts[rangeFieldIndex];
2562
- if (!matchesRangeComparisons(rangeValue, plan.rangeConstraint.comparisons)) continue;
2563
- let matchesPostFields = true;
2564
- for (const [field, allowedValues] of Object.entries(postFieldSets)) {
2565
- const fieldIndex = plan.indexFields.indexOf(field);
2566
- if (fieldIndex < 0) {
2567
- matchesPostFields = false;
2568
- break;
2569
- }
2570
- const value = bucket.keyParts[fieldIndex];
2571
- if (!allowedValues.has(serializeStable(value))) {
2572
- matchesPostFields = false;
2573
- break;
2574
- }
2575
- }
2576
- if (!matchesPostFields) continue;
2577
- matchedById.set(String(bucket._id), bucket);
2579
+ let scanned = 0;
2580
+ while (pending.length > 0 && scanned < scanLimit) {
2581
+ const remaining = scanLimit - scanned;
2582
+ const batch = pending.slice(0, Math.min(AGGREGATE_BUCKET_READ_CONCURRENCY, remaining));
2583
+ const share = Math.floor(remaining / batch.length);
2584
+ const pages = await mapWithConcurrency(batch, AGGREGATE_BUCKET_READ_CONCURRENCY, (scan) => listBucketsByHashPrefix(db, plan.tableName, plan.indexName, scan, share));
2585
+ const next = pending.slice(batch.length);
2586
+ for (let index = 0; index < batch.length; index += 1) {
2587
+ const scan = batch[index];
2588
+ const page = pages[index];
2589
+ scanned += page.length;
2590
+ for (const bucket of page) if (matchesRangePlanBucket({
2591
+ bucket,
2592
+ prefixParts: scan.prefixParts,
2593
+ indexFields: plan.indexFields,
2594
+ rangeFieldIndex,
2595
+ comparisons: plan.rangeConstraint.comparisons,
2596
+ postFieldSets
2597
+ })) matchedById.set(String(bucket._id), bucket);
2598
+ const lastBucket = page.at(-1);
2599
+ if (page.length < share || !lastBucket) continue;
2600
+ next.push({
2601
+ ...scan,
2602
+ after: lastBucket.keyHash
2603
+ });
2578
2604
  }
2605
+ pending = next;
2606
+ }
2607
+ if (scanned > workBudget) {
2608
+ const fanOut = prefixCandidates.length > 1 ? ` across ${prefixCandidates.length} equality-prefix combinations` : "";
2609
+ throw createError(plan.rangeConstraint.filterErrorCode ?? AGGREGATE_ERROR.FILTER_UNSUPPORTED, `${plan.rangeConstraint.methodName ?? "aggregate()"} range filter on '${plan.rangeConstraint.fieldName}' scans more than ${workBudget} aggregate buckets${fanOut} on aggregateIndex '${plan.indexName}'. Narrow the equality prefix, shrink the IN lists it expands to, reduce the range field's cardinality, add a narrower aggregateIndex, or increase defineSchema(..., { defaults: { aggregateWorkBudget } }).`);
2579
2610
  }
2580
- return [...matchedById.values()];
2611
+ const buckets = [...matchedById.values()];
2612
+ buckets[RANGE_WORK_STATE] = {
2613
+ scannedBuckets: scanned,
2614
+ reservedExtremaReads: 0
2615
+ };
2616
+ return buckets;
2581
2617
  };
2582
2618
  const sortFieldValueRecord = (values) => Object.fromEntries(Object.keys(values).sort().map((field) => [field, values[field] ?? []]));
2583
2619
  const getPlanBucketCacheKey = (plan) => serializeStable({
@@ -2657,14 +2693,23 @@ const readExtremaFromBuckets = async (db, plan, bucketCache) => {
2657
2693
  if (plan.metric.kind !== "min" && plan.metric.kind !== "max") throw new Error("readExtremaFromBuckets() requires a min/max aggregate plan.");
2658
2694
  let selected = null;
2659
2695
  const buckets = await readPlanBucketsWithCache(db, plan, bucketCache);
2660
- for (const bucket of buckets) {
2661
- const value = await readKeyExtrema(db, {
2662
- tableName: plan.tableName,
2663
- indexName: plan.indexName,
2664
- keyHash: bucket.keyHash,
2665
- fieldName: plan.metric.field,
2666
- kind: plan.metric.kind
2667
- });
2696
+ const metric = plan.metric;
2697
+ const rangeWork = buckets[RANGE_WORK_STATE];
2698
+ if (rangeWork && plan.rangeConstraint) {
2699
+ const workBudget = plan.rangeConstraint.workBudget ?? DEFAULT_AGGREGATE_WORK_BUDGET$1;
2700
+ const requestedWork = buckets.length;
2701
+ const totalWork = rangeWork.scannedBuckets + rangeWork.reservedExtremaReads + requestedWork;
2702
+ if (totalWork > workBudget) throw createError(plan.rangeConstraint.filterErrorCode ?? AGGREGATE_ERROR.FILTER_UNSUPPORTED, `${plan.rangeConstraint.methodName ?? "aggregate()"} range filter on '${plan.rangeConstraint.fieldName}' needs ${totalWork} aggregate work units (${rangeWork.scannedBuckets} bucket reads and ${rangeWork.reservedExtremaReads + requestedWork} extrema reads), exceeding aggregateWorkBudget (${workBudget}) on aggregateIndex '${plan.indexName}'. Narrow the range, add a narrower aggregateIndex, or increase defineSchema(..., { defaults: { aggregateWorkBudget } }).`);
2703
+ rangeWork.reservedExtremaReads += requestedWork;
2704
+ }
2705
+ const values = await mapWithConcurrency(buckets, AGGREGATE_BUCKET_READ_CONCURRENCY, (bucket) => readKeyExtrema(db, {
2706
+ tableName: plan.tableName,
2707
+ indexName: plan.indexName,
2708
+ keyHash: bucket.keyHash,
2709
+ fieldName: metric.field,
2710
+ kind: metric.kind
2711
+ }));
2712
+ for (const value of values) {
2668
2713
  if (value === null || value === void 0) continue;
2669
2714
  if (selected === null) {
2670
2715
  selected = value;
@@ -2718,16 +2763,50 @@ const computeAggregateMetricValues = (doc, definition) => {
2718
2763
  nonNullCountValues
2719
2764
  };
2720
2765
  };
2721
- const reconcileAggregateMembership = async (db, params) => {
2766
+ const toExtremaDeltas = (keyHash, values, delta) => Object.entries(values).map(([fieldName, value]) => ({
2767
+ keyHash,
2768
+ fieldName,
2769
+ value,
2770
+ delta
2771
+ }));
2772
+ const accumulateNumberValues = (target, source) => {
2773
+ for (const [field, value] of Object.entries(source)) {
2774
+ const next = (target[field] ?? 0) + value;
2775
+ if (next === 0) {
2776
+ delete target[field];
2777
+ continue;
2778
+ }
2779
+ target[field] = next;
2780
+ }
2781
+ };
2782
+ /**
2783
+ * Pure half of aggregate reconciliation: works out what a single document's
2784
+ * membership change implies, without touching the database. Callers batch these
2785
+ * and flush once so a page of documents sharing a key tuple writes its bucket
2786
+ * one time instead of once per document.
2787
+ */
2788
+ const computeMembershipDelta = (existing, params) => {
2722
2789
  const { tableName, indexName, docId, keyParts, metricValues } = params;
2723
- const existing = await getMemberByDoc(db, tableName, indexName, docId);
2724
2790
  if (!keyParts || !metricValues) {
2725
- if (existing) {
2726
- await applyBucketDelta(db, tableName, indexName, existing.keyParts, -1, negateSumValues(normalizeSumValues(existing.sumValues)), negateCountValues(normalizeNonNullCountValues(existing.nonNullCountValues)));
2727
- await applyExtremaValuesDelta(db, tableName, indexName, existing.keyHash, normalizeExtremaValues(existing.extremaValues), -1);
2728
- await db.delete(AGGREGATE_MEMBER_TABLE, existing._id);
2729
- }
2730
- return;
2791
+ if (!existing) return {
2792
+ buckets: [],
2793
+ extrema: [],
2794
+ member: { kind: "none" }
2795
+ };
2796
+ return {
2797
+ buckets: [{
2798
+ keyHash: serializeCountKeyParts(existing.keyParts),
2799
+ keyParts: existing.keyParts,
2800
+ deltaCount: -1,
2801
+ deltaSums: negateSumValues(normalizeSumValues(existing.sumValues)),
2802
+ deltaNonNullCounts: negateCountValues(normalizeNonNullCountValues(existing.nonNullCountValues))
2803
+ }],
2804
+ extrema: toExtremaDeltas(existing.keyHash, normalizeExtremaValues(existing.extremaValues), -1),
2805
+ member: {
2806
+ kind: "delete",
2807
+ id: existing._id
2808
+ }
2809
+ };
2731
2810
  }
2732
2811
  const normalizedKeyParts = keyParts.map((part) => normalizeUndefined(part));
2733
2812
  const keyHash = serializeCountKeyParts(normalizedKeyParts);
@@ -2735,40 +2814,122 @@ const reconcileAggregateMembership = async (db, params) => {
2735
2814
  const normalizedNextSumValues = normalizeSumValues(metricValues.sumValues);
2736
2815
  const normalizedNextNonNullCountValues = normalizeNonNullCountValues(metricValues.nonNullCountValues);
2737
2816
  const normalizedNextExtremaValues = normalizeExtremaValues(metricValues.extremaValues);
2738
- if (existing && existing.keyHash === keyHash && deepEquals(existing.keyParts, normalizedKeyParts) && deepEquals(normalizeSumValues(existing.sumValues), normalizedNextSumValues) && deepEquals(normalizeNonNullCountValues(existing.nonNullCountValues), normalizedNextNonNullCountValues) && deepEquals(normalizeExtremaValues(existing.extremaValues), normalizedNextExtremaValues)) {
2739
- await db.patch(AGGREGATE_MEMBER_TABLE, existing._id, { updatedAt: now });
2740
- return;
2741
- }
2742
- if (existing) {
2743
- await applyBucketDelta(db, tableName, indexName, existing.keyParts, -1, negateSumValues(normalizeSumValues(existing.sumValues)), negateCountValues(normalizeNonNullCountValues(existing.nonNullCountValues)));
2744
- await applyExtremaValuesDelta(db, tableName, indexName, existing.keyHash, normalizeExtremaValues(existing.extremaValues), -1);
2745
- }
2746
- await applyBucketDelta(db, tableName, indexName, normalizedKeyParts, 1, normalizedNextSumValues, normalizedNextNonNullCountValues);
2747
- await applyExtremaValuesDelta(db, tableName, indexName, keyHash, normalizedNextExtremaValues, 1);
2817
+ if (existing && existing.keyHash === keyHash && deepEquals(existing.keyParts, normalizedKeyParts) && deepEquals(normalizeSumValues(existing.sumValues), normalizedNextSumValues) && deepEquals(normalizeNonNullCountValues(existing.nonNullCountValues), normalizedNextNonNullCountValues) && deepEquals(normalizeExtremaValues(existing.extremaValues), normalizedNextExtremaValues)) return {
2818
+ buckets: [],
2819
+ extrema: [],
2820
+ member: { kind: "none" }
2821
+ };
2822
+ const buckets = [];
2823
+ const extrema = [];
2748
2824
  if (existing) {
2749
- await db.patch(AGGREGATE_MEMBER_TABLE, existing._id, {
2750
- kind: AGGREGATE_STATE_KIND_METRIC,
2751
- keyHash,
2752
- keyParts: normalizedKeyParts,
2753
- sumValues: normalizedNextSumValues,
2754
- nonNullCountValues: normalizedNextNonNullCountValues,
2755
- extremaValues: normalizedNextExtremaValues,
2756
- updatedAt: now
2825
+ buckets.push({
2826
+ keyHash: serializeCountKeyParts(existing.keyParts),
2827
+ keyParts: existing.keyParts,
2828
+ deltaCount: -1,
2829
+ deltaSums: negateSumValues(normalizeSumValues(existing.sumValues)),
2830
+ deltaNonNullCounts: negateCountValues(normalizeNonNullCountValues(existing.nonNullCountValues))
2757
2831
  });
2758
- return;
2832
+ extrema.push(...toExtremaDeltas(existing.keyHash, normalizeExtremaValues(existing.extremaValues), -1));
2759
2833
  }
2760
- await db.insert(AGGREGATE_MEMBER_TABLE, {
2834
+ buckets.push({
2835
+ keyHash,
2836
+ keyParts: normalizedKeyParts,
2837
+ deltaCount: 1,
2838
+ deltaSums: normalizedNextSumValues,
2839
+ deltaNonNullCounts: normalizedNextNonNullCountValues
2840
+ });
2841
+ extrema.push(...toExtremaDeltas(keyHash, normalizedNextExtremaValues, 1));
2842
+ const memberFields = {
2761
2843
  kind: AGGREGATE_STATE_KIND_METRIC,
2762
- tableKey: tableName,
2763
- indexName,
2764
- docId,
2765
2844
  keyHash,
2766
2845
  keyParts: normalizedKeyParts,
2767
2846
  sumValues: normalizedNextSumValues,
2768
2847
  nonNullCountValues: normalizedNextNonNullCountValues,
2769
2848
  extremaValues: normalizedNextExtremaValues,
2770
2849
  updatedAt: now
2771
- });
2850
+ };
2851
+ return {
2852
+ buckets,
2853
+ extrema,
2854
+ member: existing ? {
2855
+ kind: "patch",
2856
+ id: existing._id,
2857
+ doc: memberFields
2858
+ } : {
2859
+ kind: "insert",
2860
+ doc: {
2861
+ ...memberFields,
2862
+ tableKey: tableName,
2863
+ indexName,
2864
+ docId
2865
+ }
2866
+ }
2867
+ };
2868
+ };
2869
+ const computeAggregateMembershipDelta = async (db, params) => {
2870
+ return computeMembershipDelta(await getMemberByDoc(db, params.tableName, params.indexName, params.docId), params);
2871
+ };
2872
+ /**
2873
+ * Write half of aggregate reconciliation. Folds bucket deltas by key tuple and
2874
+ * extrema deltas by (keyHash, field, value) so each storage document is read and
2875
+ * written once regardless of how many source documents contributed to it.
2876
+ */
2877
+ const flushAggregateMembershipDeltas = async (db, tableName, indexName, deltas) => {
2878
+ const bucketDeltas = /* @__PURE__ */ new Map();
2879
+ const extremaDeltas = /* @__PURE__ */ new Map();
2880
+ for (const delta of deltas) {
2881
+ for (const bucket of delta.buckets) {
2882
+ const current = bucketDeltas.get(bucket.keyHash);
2883
+ if (!current) {
2884
+ bucketDeltas.set(bucket.keyHash, {
2885
+ keyHash: bucket.keyHash,
2886
+ keyParts: bucket.keyParts,
2887
+ deltaCount: bucket.deltaCount,
2888
+ deltaSums: { ...bucket.deltaSums },
2889
+ deltaNonNullCounts: { ...bucket.deltaNonNullCounts }
2890
+ });
2891
+ continue;
2892
+ }
2893
+ current.deltaCount += bucket.deltaCount;
2894
+ accumulateNumberValues(current.deltaSums, bucket.deltaSums);
2895
+ accumulateNumberValues(current.deltaNonNullCounts, bucket.deltaNonNullCounts);
2896
+ }
2897
+ for (const entry of delta.extrema) {
2898
+ const cacheKey = serializeStable([
2899
+ entry.keyHash,
2900
+ entry.fieldName,
2901
+ serializeStable(entry.value)
2902
+ ]);
2903
+ const current = extremaDeltas.get(cacheKey);
2904
+ if (!current) {
2905
+ extremaDeltas.set(cacheKey, { ...entry });
2906
+ continue;
2907
+ }
2908
+ current.delta += entry.delta;
2909
+ }
2910
+ }
2911
+ for (const bucket of bucketDeltas.values()) await applyBucketDelta(db, tableName, indexName, bucket.keyParts, bucket.deltaCount, bucket.deltaSums, bucket.deltaNonNullCounts);
2912
+ for (const entry of extremaDeltas.values()) await applyExtremaDelta(db, tableName, indexName, entry.keyHash, entry.fieldName, entry.value, entry.delta);
2913
+ for (const delta of deltas) {
2914
+ const member = delta.member;
2915
+ if (member.kind === "delete") {
2916
+ await db.delete(AGGREGATE_MEMBER_TABLE, member.id);
2917
+ continue;
2918
+ }
2919
+ if (member.kind === "patch") {
2920
+ await db.patch(AGGREGATE_MEMBER_TABLE, member.id, member.doc);
2921
+ continue;
2922
+ }
2923
+ if (member.kind === "insert") await db.insert(AGGREGATE_MEMBER_TABLE, member.doc);
2924
+ }
2925
+ };
2926
+ /**
2927
+ * Single-document reconciliation. Flushes eagerly so user code reading an
2928
+ * aggregate later in the same mutation sees its own writes.
2929
+ */
2930
+ const reconcileAggregateMembership = async (db, params) => {
2931
+ const delta = await computeAggregateMembershipDelta(db, params);
2932
+ await flushAggregateMembershipDeltas(db, params.tableName, params.indexName, [delta]);
2772
2933
  };
2773
2934
  const computeCountKeyParts = (doc, fields) => fields.map((field) => normalizeUndefined(doc[field]));
2774
2935
  const applyAggregateIndexesForChange = async (db, tableName, aggregateIndexes, change) => {
@@ -2794,6 +2955,15 @@ const getCountState = async (db, tableName, indexName, kind = AGGREGATE_STATE_KI
2794
2955
  tableName: tableKey
2795
2956
  };
2796
2957
  };
2958
+ const assertAggregateIndexesWritable = async (db, tableName, metricIndexNames, rankIndexNames) => {
2959
+ const clearingStates = await db.query(AGGREGATE_STATE_TABLE).withIndex("by_table_status", (q) => q.eq("tableKey", tableName).eq("status", COUNT_STATUS_CLEARING)).collect();
2960
+ const metricNames = new Set(metricIndexNames);
2961
+ const rankNames = new Set(rankIndexNames);
2962
+ const blockingState = clearingStates.find((state) => state.kind === AGGREGATE_STATE_KIND_RANK ? rankNames.has(state.indexName) : metricNames.has(state.indexName));
2963
+ if (!blockingState) return;
2964
+ const indexType = blockingState.kind === AGGREGATE_STATE_KIND_RANK ? "rankIndex" : "aggregateIndex";
2965
+ throw createError(blockingState.kind === AGGREGATE_STATE_KIND_RANK ? "RANK_INDEX_BUILDING" : AGGREGATE_ERROR.INDEX_BUILDING, `${indexType} '${tableName}.${blockingState.indexName}' is CLEARING. Retry the write after aggregateBackfill reaches BUILDING or READY.`);
2966
+ };
2797
2967
  const setCountState = async (db, nextState, kind = AGGREGATE_STATE_KIND_METRIC) => {
2798
2968
  const existing = await getCountState(db, nextState.tableName, nextState.indexName, kind);
2799
2969
  const payload = {
@@ -2822,20 +2992,60 @@ const setCountStateError = async (db, tableName, indexName, error, kind = AGGREG
2822
2992
  const message = error instanceof Error ? error.message : String(error);
2823
2993
  if (!existing) throw new Error(`Missing count state for '${tableName}.${indexName}' while recording backfill error.`);
2824
2994
  await db.patch(AGGREGATE_STATE_TABLE, existing._id, {
2825
- status: COUNT_STATUS_BUILDING,
2995
+ status: existing.status === COUNT_STATUS_CLEARING ? COUNT_STATUS_CLEARING : COUNT_STATUS_BUILDING,
2826
2996
  updatedAt: now,
2827
2997
  keyDefinitionHash: existing.keyDefinitionHash,
2828
2998
  metricDefinitionHash: existing.metricDefinitionHash,
2829
2999
  lastError: message
2830
3000
  });
2831
3001
  };
2832
- const clearCountIndexData = async (db, tableName, indexName) => {
2833
- const members = await listMembersForIndex(db, tableName, indexName);
2834
- for (const member of members) await db.delete(AGGREGATE_MEMBER_TABLE, member._id);
2835
- const buckets = await listBucketsForIndex(db, tableName, indexName);
2836
- for (const bucket of buckets) await db.delete(AGGREGATE_BUCKET_TABLE, bucket._id);
2837
- const extrema = await listExtremaForIndex(db, tableName, indexName);
2838
- for (const entry of extrema) await db.delete(AGGREGATE_EXTREMA_TABLE, entry._id);
3002
+ /**
3003
+ * Removes at most `batchSize` documents of an aggregate index's stored state and
3004
+ * reports whether anything is left. Callers drive it to completion across
3005
+ * transactions, so clearing a large index never has to fit in one mutation.
3006
+ *
3007
+ * Members are removed through the normal delta machinery rather than raw
3008
+ * deletes, so buckets and extrema stay consistent with the members that remain
3009
+ * at every intermediate step. That keeps concurrent writers correct while the
3010
+ * clear drains. Residual bucket/extrema rows (drift with no member behind them)
3011
+ * are swept only once no members are left, and the loop re-checks members
3012
+ * afterwards.
3013
+ */
3014
+ const clearCountIndexChunk = async (db, tableName, indexName, batchSize) => {
3015
+ const members = await takeMembersForIndex(db, tableName, indexName, batchSize);
3016
+ if (members.length > 0) {
3017
+ await flushAggregateMembershipDeltas(db, tableName, indexName, members.map((member) => computeMembershipDelta(member, {
3018
+ tableName,
3019
+ indexName,
3020
+ docId: member.docId,
3021
+ keyParts: null,
3022
+ metricValues: null
3023
+ })));
3024
+ return {
3025
+ done: false,
3026
+ processed: members.length
3027
+ };
3028
+ }
3029
+ const buckets = await takeBucketsForIndex(db, tableName, indexName, batchSize);
3030
+ if (buckets.length > 0) {
3031
+ for (const bucket of buckets) await db.delete(AGGREGATE_BUCKET_TABLE, bucket._id);
3032
+ return {
3033
+ done: false,
3034
+ processed: buckets.length
3035
+ };
3036
+ }
3037
+ const extrema = await takeExtremaForIndex(db, tableName, indexName, batchSize);
3038
+ if (extrema.length > 0) {
3039
+ for (const entry of extrema) await db.delete(AGGREGATE_EXTREMA_TABLE, entry._id);
3040
+ return {
3041
+ done: false,
3042
+ processed: extrema.length
3043
+ };
3044
+ }
3045
+ return {
3046
+ done: true,
3047
+ processed: 0
3048
+ };
2839
3049
  };
2840
3050
  const createCountError = (code, message) => createError(code, message);
2841
3051
  const createAggregateError = (code, message) => createError(code, message);
@@ -2993,15 +3203,42 @@ const ensureRankIndexReady = async (db, tableName, indexName) => {
2993
3203
  const getRankMemberByDoc = async (db, tableName, indexName, docId) => {
2994
3204
  return (await db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index_doc", (q) => q.eq("kind", RANK_MEMBER_KIND).eq("tableKey", tableName).eq("indexName", indexName).eq("docId", docId)).collect())[0] ?? null;
2995
3205
  };
2996
- const listRankMembers = async (db, tableName, indexName) => await db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index", (q) => q.eq("kind", RANK_MEMBER_KIND).eq("tableKey", tableName).eq("indexName", indexName)).collect();
3206
+ const takeRankMembers = async (db, tableName, indexName, limit) => await db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index", (q) => q.eq("kind", RANK_MEMBER_KIND).eq("tableKey", tableName).eq("indexName", indexName)).take(limit);
2997
3207
  const rankCtx = (db) => ({
2998
3208
  db,
2999
3209
  orm: void 0
3000
3210
  });
3001
- const clearRankIndexData = async (db, tableName, indexName) => {
3002
- await rankAggregate(tableName, indexName).clearAll(rankCtx(db));
3003
- const members = await listRankMembers(db, tableName, indexName);
3004
- for (const member of members) await db.delete(AGGREGATE_MEMBER_TABLE, member._id);
3211
+ /** Trees dropped per invocation once every rank member has been removed. */
3212
+ const RANK_TREE_DROP_BATCH = 16;
3213
+ /**
3214
+ * Removes at most `batchSize` rank members and reports whether anything is
3215
+ * left. Each member is removed from the btree before its row is dropped, so the
3216
+ * tree stays consistent with the members that remain and a partially drained
3217
+ * clear can safely resume in a later mutation.
3218
+ */
3219
+ const clearRankIndexChunk = async (db, tableName, indexName, batchSize) => {
3220
+ const aggregate = rankAggregate(tableName, indexName);
3221
+ const ctx = rankCtx(db);
3222
+ const members = await takeRankMembers(db, tableName, indexName, batchSize);
3223
+ if (members.length > 0) {
3224
+ for (const member of members) {
3225
+ if (member.rankKey !== void 0) await aggregate.deleteIfExists(ctx, {
3226
+ id: member.docId,
3227
+ key: member.rankKey,
3228
+ namespace: member.rankNamespace
3229
+ });
3230
+ await db.delete(AGGREGATE_MEMBER_TABLE, member._id);
3231
+ }
3232
+ return {
3233
+ done: false,
3234
+ processed: members.length
3235
+ };
3236
+ }
3237
+ const done = await aggregate.deleteTrees(ctx, RANK_TREE_DROP_BATCH);
3238
+ return {
3239
+ done,
3240
+ processed: done ? 0 : RANK_TREE_DROP_BATCH
3241
+ };
3005
3242
  };
3006
3243
  const reconcileRankMembership = async (db, params) => {
3007
3244
  const { tableName, definition, docId, doc } = params;
@@ -3023,10 +3260,7 @@ const reconcileRankMembership = async (db, params) => {
3023
3260
  const namespace = buildNamespace(definition, new Map(definition.partitionFields.map((field) => [field, doc[field]])));
3024
3261
  const key = getRankSortKey(definition, doc, docId);
3025
3262
  const sumValue = getRankSumValue(definition, doc);
3026
- if (existing && stableEquals(existing.rankNamespace, namespace) && stableEquals(existing.rankKey, key) && (existing.rankSumValue ?? 0) === sumValue) {
3027
- await db.patch(AGGREGATE_MEMBER_TABLE, existing._id, { updatedAt: now });
3028
- return;
3029
- }
3263
+ if (existing && stableEquals(existing.rankNamespace, namespace) && stableEquals(existing.rankKey, key) && (existing.rankSumValue ?? 0) === sumValue) return;
3030
3264
  if (existing?.rankKey !== void 0) {
3031
3265
  await aggregate.replace(ctx, {
3032
3266
  id: docId,
@@ -3093,14 +3327,13 @@ const toPublicRankItem = (plan, item) => ({
3093
3327
  key: toPublicRankKey(plan.definition, item.key),
3094
3328
  sumValue: item.sumValue
3095
3329
  });
3096
- const readRankAt = async (db, plan, offset) => {
3097
- const aggregate = rankAggregate(plan.tableName, plan.indexName);
3098
- const count = await aggregate.count(rankCtx(db), { namespace: plan.namespace });
3330
+ const readRankAtWithCount = async (db, plan, offset, count) => {
3099
3331
  if (count <= 0) return null;
3100
3332
  const normalizedOffset = offset < 0 ? count + offset : offset;
3101
3333
  if (normalizedOffset < 0 || normalizedOffset >= count) return null;
3102
- return toPublicRankItem(plan, await aggregate.at(rankCtx(db), offset, { namespace: plan.namespace }));
3334
+ return toPublicRankItem(plan, await rankAggregate(plan.tableName, plan.indexName).at(rankCtx(db), offset, { namespace: plan.namespace }));
3103
3335
  };
3336
+ const readRankAt = async (db, plan, offset) => await readRankAtWithCount(db, plan, offset, await readRankCount(db, plan));
3104
3337
  const readRankIndexOf = async (db, plan, args) => {
3105
3338
  const member = await getRankMemberByDoc(db, plan.tableName, plan.indexName, args.id);
3106
3339
  if (!member || member.rankKey === void 0) return -1;
@@ -3122,12 +3355,18 @@ const readRankPaginate = async (db, plan, cursor, limit) => {
3122
3355
  page: paged.page.map((item) => toPublicRankItem(plan, item))
3123
3356
  };
3124
3357
  };
3125
- const readRankMin = async (db, plan) => await readRankAt(db, plan, 0);
3126
- const readRankMax = async (db, plan) => await readRankAt(db, plan, -1);
3358
+ const readRankMin = async (db, plan) => {
3359
+ const item = await rankAggregate(plan.tableName, plan.indexName).min(rankCtx(db), { namespace: plan.namespace });
3360
+ return item ? toPublicRankItem(plan, item) : null;
3361
+ };
3362
+ const readRankMax = async (db, plan) => {
3363
+ const item = await rankAggregate(plan.tableName, plan.indexName).max(rankCtx(db), { namespace: plan.namespace });
3364
+ return item ? toPublicRankItem(plan, item) : null;
3365
+ };
3127
3366
  const readRankRandom = async (db, plan) => {
3128
3367
  const count = await readRankCount(db, plan);
3129
3368
  if (count <= 0) return null;
3130
- return await readRankAt(db, plan, Math.floor(Math.random() * count));
3369
+ return await readRankAtWithCount(db, plan, Math.floor(Math.random() * count), count);
3131
3370
  };
3132
3371
 
3133
3372
  //#endregion
@@ -3227,65 +3466,115 @@ const requiresMetricBackfill = (existingMetricDefinitionHash, targetMetricDefini
3227
3466
  };
3228
3467
  return hasMissing(targetStorage.sumFields, existingStorage.sumFields) || hasMissing(targetStorage.nonNullCountFields, existingStorage.nonNullCountFields) || hasMissing(targetStorage.extremaFields, existingStorage.extremaFields);
3229
3468
  };
3230
- const listDistinctIndexTuples = async (db, tableName, indexName, fields) => {
3231
- const tuples = [];
3232
- let current = await db.query(tableName).withIndex(indexName).first();
3233
- while (current) {
3234
- const row = current;
3235
- const tuple = {};
3236
- for (const field of fields) tuple[field] = row[field];
3237
- tuples.push(tuple);
3238
- current = null;
3239
- for (let depth = fields.length - 1; depth >= 0 && !current; depth -= 1) current = await db.query(tableName).withIndex(indexName, (q) => {
3240
- let range = q;
3241
- for (let i = 0; i < depth; i += 1) range = range.eq(fields[i], tuple[fields[i]]);
3242
- return range.gt(fields[depth], tuple[fields[depth]]);
3243
- }).first();
3244
- }
3245
- return tuples;
3246
- };
3247
3469
  function createCountBackfillHandlers(schema, getChunkRef) {
3248
3470
  const serializeKey = (kind, tableName, indexName) => `${kind}\u0000${tableName}\u0000${indexName}`;
3249
- const pruneRemovedState = async (ctx, args, targets) => {
3471
+ const stateKindFor = (kind) => kind === "rank" ? AGGREGATE_STATE_KIND_RANK : AGGREGATE_STATE_KIND_METRIC;
3472
+ const scheduleChunk = async (ctx, tableName, indexName, batchSize) => {
3473
+ const chunkRef = getChunkRef?.();
3474
+ if (ctx.scheduler && chunkRef) await ctx.scheduler.runAfter(0, chunkRef, {
3475
+ tableName,
3476
+ indexName,
3477
+ batchSize
3478
+ });
3479
+ };
3480
+ /**
3481
+ * Drains an index's stored state until it is empty or the shared per-mutation
3482
+ * budget runs out. Returns true only when the index is fully cleared, so the
3483
+ * caller knows whether to advance the state machine or reschedule.
3484
+ */
3485
+ const drainIndexClear = async (ctx, kind, tableName, indexName, batchSize, budget) => {
3486
+ while (budget.remaining > 0) {
3487
+ const limit = Math.min(batchSize, budget.remaining);
3488
+ const step = kind === "rank" ? await clearRankIndexChunk(ctx.db, tableName, indexName, limit) : await clearCountIndexChunk(ctx.db, tableName, indexName, limit);
3489
+ if (step.done) return true;
3490
+ budget.remaining -= Math.max(step.processed, 1);
3491
+ }
3492
+ return false;
3493
+ };
3494
+ const pruneRemovedState = async (ctx, args, targets, batchSize, clearBudget) => {
3250
3495
  const targetKeys = new Set(targets.map((target) => serializeKey(target.kind, target.tableName, target.indexName)));
3251
3496
  const states = await ctx.db.query(AGGREGATE_STATE_TABLE).collect();
3252
3497
  const stateByKey = new Map(states.map((state) => [serializeKey(state.kind === AGGREGATE_STATE_KIND_RANK ? "rank" : "metric", state.tableKey, state.indexName), state]));
3253
- const [bucketRows, memberRows, extremaRows] = await Promise.all([
3254
- listDistinctIndexTuples(ctx.db, AGGREGATE_BUCKET_TABLE, "by_table_index", ["tableKey", "indexName"]),
3255
- listDistinctIndexTuples(ctx.db, AGGREGATE_MEMBER_TABLE, "by_kind_table_index", [
3256
- "kind",
3257
- "tableKey",
3258
- "indexName"
3259
- ]),
3260
- listDistinctIndexTuples(ctx.db, AGGREGATE_EXTREMA_TABLE, "by_table_index", ["tableKey", "indexName"])
3261
- ]);
3262
3498
  const existingKeys = /* @__PURE__ */ new Set();
3263
3499
  for (const state of states) existingKeys.add(serializeKey(state.kind === AGGREGATE_STATE_KIND_RANK ? "rank" : "metric", state.tableKey, state.indexName));
3264
- for (const row of bucketRows) existingKeys.add(serializeKey("metric", row.tableKey, row.indexName));
3265
- for (const row of memberRows) {
3266
- const kind = row.kind === AGGREGATE_STATE_KIND_RANK ? "rank" : "metric";
3267
- existingKeys.add(serializeKey(kind, row.tableKey, row.indexName));
3500
+ if (args.tableName && args.indexName) {
3501
+ const tableName = args.tableName;
3502
+ const indexName = args.indexName;
3503
+ const [bucket, extrema, metricMember, rankMember] = await Promise.all([
3504
+ ctx.db.query(AGGREGATE_BUCKET_TABLE).withIndex("by_table_index", (q) => q.eq("tableKey", tableName).eq("indexName", indexName)).first(),
3505
+ ctx.db.query(AGGREGATE_EXTREMA_TABLE).withIndex("by_table_index", (q) => q.eq("tableKey", tableName).eq("indexName", indexName)).first(),
3506
+ ctx.db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index", (q) => q.eq("kind", AGGREGATE_STATE_KIND_METRIC).eq("tableKey", tableName).eq("indexName", indexName)).first(),
3507
+ ctx.db.query(AGGREGATE_MEMBER_TABLE).withIndex("by_kind_table_index", (q) => q.eq("kind", AGGREGATE_STATE_KIND_RANK).eq("tableKey", tableName).eq("indexName", indexName)).first()
3508
+ ]);
3509
+ if (bucket || extrema || metricMember) existingKeys.add(serializeKey("metric", tableName, indexName));
3510
+ if (rankMember) existingKeys.add(serializeKey("rank", tableName, indexName));
3268
3511
  }
3269
- for (const row of extremaRows) existingKeys.add(serializeKey("metric", row.tableKey, row.indexName));
3270
3512
  let pruned = 0;
3513
+ let pruning = 0;
3271
3514
  for (const key of existingKeys) {
3272
3515
  const [kind, tableName, indexName] = key.split("\0");
3273
3516
  if (args.tableName && tableName !== args.tableName) continue;
3274
3517
  if (args.indexName && indexName !== args.indexName) continue;
3275
3518
  if (targetKeys.has(key)) continue;
3276
- if (kind === "metric") await clearCountIndexData(ctx.db, tableName, indexName);
3277
- else await clearRankIndexData(ctx.db, tableName, indexName);
3278
3519
  const state = stateByKey.get(key);
3520
+ if (!await drainIndexClear(ctx, kind, tableName, indexName, batchSize, clearBudget)) {
3521
+ const now = Date.now();
3522
+ await setCountState(ctx.db, {
3523
+ tableName,
3524
+ indexName,
3525
+ kind: stateKindFor(kind),
3526
+ keyDefinitionHash: state?.keyDefinitionHash ?? "",
3527
+ metricDefinitionHash: state?.metricDefinitionHash ?? "",
3528
+ status: COUNT_STATUS_CLEARING,
3529
+ cursor: null,
3530
+ processed: 0,
3531
+ startedAt: now,
3532
+ updatedAt: now,
3533
+ completedAt: null,
3534
+ lastError: null
3535
+ }, stateKindFor(kind));
3536
+ await scheduleChunk(ctx, tableName, indexName, batchSize);
3537
+ pruning += 1;
3538
+ continue;
3539
+ }
3279
3540
  if (state) await ctx.db.delete(AGGREGATE_STATE_TABLE, state._id);
3280
3541
  pruned += 1;
3281
3542
  }
3282
- return pruned;
3543
+ return {
3544
+ pruned,
3545
+ pruning
3546
+ };
3547
+ };
3548
+ /**
3549
+ * Finishes clearing an index that no longer exists in the schema. Orphans have
3550
+ * no backfill target, so they are driven purely by their CLEARING state row.
3551
+ * Returns true when this invocation handled one.
3552
+ */
3553
+ const drainOrphanClear = async (ctx, args, targets, batchSize) => {
3554
+ const targetKeys = new Set(targets.map((target) => serializeKey(target.kind, target.tableName, target.indexName)));
3555
+ const states = await ctx.db.query(AGGREGATE_STATE_TABLE).collect();
3556
+ for (const state of states) {
3557
+ if (state.status !== COUNT_STATUS_CLEARING) continue;
3558
+ const kind = state.kind === AGGREGATE_STATE_KIND_RANK ? "rank" : "metric";
3559
+ if (targetKeys.has(serializeKey(kind, state.tableKey, state.indexName))) continue;
3560
+ if (args.tableName && state.tableKey !== args.tableName) continue;
3561
+ if (args.indexName && state.indexName !== args.indexName) continue;
3562
+ if (await drainIndexClear(ctx, kind, state.tableKey, state.indexName, batchSize, { remaining: batchSize })) {
3563
+ await ctx.db.delete(AGGREGATE_STATE_TABLE, state._id);
3564
+ return true;
3565
+ }
3566
+ await ctx.db.patch(AGGREGATE_STATE_TABLE, state._id, { updatedAt: Date.now() });
3567
+ await scheduleChunk(ctx, state.tableKey, state.indexName, batchSize);
3568
+ return true;
3569
+ }
3570
+ return false;
3283
3571
  };
3284
3572
  const kickoff = async (ctx, args) => {
3285
3573
  const targets = getTargets(schema, args);
3286
3574
  const mode = getBackfillMode(args.mode);
3287
3575
  const batchSize = getBackfillBatchSize(args.batchSize);
3288
- const pruned = await pruneRemovedState(ctx, args, targets);
3576
+ const clearBudget = { remaining: batchSize };
3577
+ const { pruned, pruning } = await pruneRemovedState(ctx, args, targets, batchSize, clearBudget);
3289
3578
  if (mode === "prune") return {
3290
3579
  targets: targets.length,
3291
3580
  mode,
@@ -3293,6 +3582,7 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3293
3582
  skippedReady: 0,
3294
3583
  needsRebuild: 0,
3295
3584
  pruned,
3585
+ pruning,
3296
3586
  status: "ok"
3297
3587
  };
3298
3588
  const now = Date.now();
@@ -3311,12 +3601,11 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3311
3601
  metricDefinitionHash
3312
3602
  };
3313
3603
  if (mode === "rebuild") {
3314
- if (target.kind === "rank") await clearRankIndexData(ctx.db, target.tableName, target.indexName);
3315
- else await clearCountIndexData(ctx.db, target.tableName, target.indexName);
3604
+ const cleared = await drainIndexClear(ctx, target.kind, target.tableName, target.indexName, batchSize, clearBudget);
3316
3605
  await setCountState(ctx.db, {
3317
3606
  ...nextStateBase,
3318
3607
  kind: stateKind,
3319
- status: COUNT_STATUS_BUILDING,
3608
+ status: cleared ? COUNT_STATUS_BUILDING : COUNT_STATUS_CLEARING,
3320
3609
  cursor: null,
3321
3610
  processed: 0,
3322
3611
  startedAt: now,
@@ -3371,12 +3660,7 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3371
3660
  completedAt: null,
3372
3661
  lastError: null
3373
3662
  }, stateKind);
3374
- const chunkRef = getChunkRef?.();
3375
- if (ctx.scheduler && chunkRef) await ctx.scheduler.runAfter(0, chunkRef, {
3376
- tableName: target.tableName,
3377
- indexName: target.indexName,
3378
- batchSize
3379
- });
3663
+ await scheduleChunk(ctx, target.tableName, target.indexName, batchSize);
3380
3664
  scheduled += 1;
3381
3665
  }
3382
3666
  return {
@@ -3386,12 +3670,14 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3386
3670
  skippedReady,
3387
3671
  needsRebuild,
3388
3672
  pruned,
3673
+ pruning,
3389
3674
  status: "ok"
3390
3675
  };
3391
3676
  };
3392
3677
  const chunk = async (ctx, args) => {
3393
3678
  const batchSize = getBackfillBatchSize(args.batchSize);
3394
3679
  const targets = getTargets(schema, args);
3680
+ if (!(Boolean(args.tableName && args.indexName) && targets.length === 1) && await drainOrphanClear(ctx, args, targets, batchSize)) return { status: "ok" };
3395
3681
  if (targets.length > 1) {
3396
3682
  for (const target of targets) {
3397
3683
  const stateKind = target.kind === "rank" ? AGGREGATE_STATE_KIND_RANK : AGGREGATE_STATE_KIND_METRIC;
@@ -3409,11 +3695,32 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3409
3695
  try {
3410
3696
  const state = await getCountState(ctx.db, target.tableName, target.indexName, stateKind);
3411
3697
  if (!state || state.status === COUNT_STATUS_READY) continue;
3698
+ if (state.status === COUNT_STATUS_CLEARING) {
3699
+ const cleared = await drainIndexClear(ctx, target.kind, target.tableName, target.indexName, batchSize, { remaining: batchSize });
3700
+ const clearedAt = Date.now();
3701
+ await setCountState(ctx.db, {
3702
+ tableName: target.tableName,
3703
+ indexName: target.indexName,
3704
+ kind: stateKind,
3705
+ keyDefinitionHash: state.keyDefinitionHash,
3706
+ metricDefinitionHash: state.metricDefinitionHash,
3707
+ status: cleared ? COUNT_STATUS_BUILDING : COUNT_STATUS_CLEARING,
3708
+ cursor: null,
3709
+ processed: 0,
3710
+ startedAt: state.startedAt,
3711
+ updatedAt: clearedAt,
3712
+ completedAt: null,
3713
+ lastError: null
3714
+ }, stateKind);
3715
+ await scheduleChunk(ctx, target.tableName, target.indexName, batchSize);
3716
+ continue;
3717
+ }
3412
3718
  const cursor = state.cursor ?? null;
3413
3719
  const page = await ctx.db.query(target.tableName).withIndex("by_creation_time").paginate({
3414
3720
  cursor,
3415
3721
  numItems: batchSize
3416
3722
  });
3723
+ const pendingDeltas = [];
3417
3724
  for (const doc of page.page) if (target.kind === "rank") await reconcileRankMembership(ctx.db, {
3418
3725
  tableName: target.tableName,
3419
3726
  definition: {
@@ -3425,7 +3732,7 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3425
3732
  docId: String(doc._id),
3426
3733
  doc
3427
3734
  });
3428
- else await reconcileAggregateMembership(ctx.db, {
3735
+ else pendingDeltas.push(await computeAggregateMembershipDelta(ctx.db, {
3429
3736
  tableName: target.tableName,
3430
3737
  indexName: target.indexName,
3431
3738
  docId: String(doc._id),
@@ -3439,7 +3746,8 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3439
3746
  minFields: target.minFields ?? [],
3440
3747
  maxFields: target.maxFields ?? []
3441
3748
  })
3442
- });
3749
+ }));
3750
+ if (pendingDeltas.length > 0) await flushAggregateMembershipDeltas(ctx.db, target.tableName, target.indexName, pendingDeltas);
3443
3751
  const now = Date.now();
3444
3752
  const nextProcessed = state.processed + page.page.length;
3445
3753
  if (page.isDone) {
@@ -3473,12 +3781,7 @@ function createCountBackfillHandlers(schema, getChunkRef) {
3473
3781
  completedAt: null,
3474
3782
  lastError: null
3475
3783
  }, stateKind);
3476
- const chunkRef = getChunkRef?.();
3477
- if (ctx.scheduler && chunkRef) await ctx.scheduler.runAfter(0, chunkRef, {
3478
- tableName: target.tableName,
3479
- indexName: target.indexName,
3480
- batchSize
3481
- });
3784
+ await scheduleChunk(ctx, target.tableName, target.indexName, batchSize);
3482
3785
  } catch (error) {
3483
3786
  await setCountStateError(ctx.db, target.tableName, target.indexName, error, stateKind);
3484
3787
  throw error;
@@ -4585,6 +4888,42 @@ var BufferedQueryStream = class BufferedQueryStream extends QueryStream {
4585
4888
  return this.equalityIndexFilter;
4586
4889
  }
4587
4890
  };
4891
+ const ID_LIST_POSITION_FIELD = "__kitcn_id_list_position";
4892
+ /**
4893
+ * Reads an `id` / `id in [...]` where one document at a time, in the order the
4894
+ * ids were given.
4895
+ *
4896
+ * The index key is the position in the de-duplicated id list, so a cursor names
4897
+ * a position and `narrow` drops entries without reading them. A page reads only
4898
+ * the listed positions it visits, not every id in the list on every page.
4899
+ */
4900
+ var LazyIdListQueryStream = class LazyIdListQueryStream extends QueryStream {
4901
+ constructor(readId, entries, order) {
4902
+ super();
4903
+ this.readId = readId;
4904
+ this.entries = entries;
4905
+ this.order = order;
4906
+ }
4907
+ iterWithKeys() {
4908
+ const entries = this.entries;
4909
+ const readId = this.readId;
4910
+ return { async *[Symbol.asyncIterator]() {
4911
+ for (const [position, id] of entries) yield [await readId(id), [position]];
4912
+ } };
4913
+ }
4914
+ narrow(indexBounds) {
4915
+ return new LazyIdListQueryStream(this.readId, this.entries.filter(([position]) => indexKeyWithinBounds([position], indexBounds)), this.order);
4916
+ }
4917
+ getOrder() {
4918
+ return this.order;
4919
+ }
4920
+ getIndexFields() {
4921
+ return [ID_LIST_POSITION_FIELD];
4922
+ }
4923
+ getEqualityIndexFilter() {
4924
+ return [];
4925
+ }
4926
+ };
4588
4927
  const PIPELINE_LIMIT_ORDINAL_FIELD = "__kitcn_limit_ordinal";
4589
4928
  /** Cap a stream at its first `limit` matching documents without eager reads. */
4590
4929
  var LimitedMatchesQueryStream = class LimitedMatchesQueryStream extends QueryStream {
@@ -5787,9 +6126,12 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5787
6126
  * The where-clause compiler is built from declared indexes only, and `_id` is
5788
6127
  * never one of them, so an `id` filter can never be index-selected: it lands
5789
6128
  * in the post-filters and the stream walks the creation-time index until it
5790
- * happens on the row. `db.get()` reads exactly the rows asked for, so the ids
5791
- * are fetched directly and replayed as a creation-time-ordered stream — the
5792
- * order the scan would have produced, so stage order and cursors are the same.
6129
+ * happens on the row. `db.get()` reads exactly the rows asked for.
6130
+ *
6131
+ * Rows come back in the order the ids were given, one read at a time. Missing
6132
+ * or policy-filtered ids still cost a read, but a page does not reread the
6133
+ * complete list. An `orderBy` on creation time is the exception: an id carries
6134
+ * no creation time, so every id must be read before the first row is placed.
5793
6135
  *
5794
6136
  * Returns null when something else already owns the read: a pinned index, an
5795
6137
  * index the compiler did select, a `where(predicate)`, or an `orderBy` that
@@ -5801,13 +6143,26 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5801
6143
  const primaryOrder = queryConfig.order?.[0];
5802
6144
  if (primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD) return null;
5803
6145
  const ids = idLookup.kind === "in" ? Array.from(new Map(idLookup.ids.map((id) => [String(id), id])).values()) : [idLookup.id];
5804
- const rows = (await this._mapWithConcurrency(ids, async (id) => {
5805
- return this._getById(this.tableConfig.name, id);
5806
- })).filter((row) => !!row);
6146
+ const readId = (id) => this._getById(this.tableConfig.name, id);
6147
+ if (!primaryOrder) {
6148
+ const entries = ids.map((id, position) => [position, id]);
6149
+ if (order === "desc") entries.reverse();
6150
+ return new LazyIdListQueryStream(readId, entries, order);
6151
+ }
6152
+ const rows = (await this._mapWithConcurrency(ids, readId)).filter((row) => !!row);
5807
6153
  rows.sort((a, b) => compareValues(a[INTERNAL_CREATION_TIME_FIELD], b[INTERNAL_CREATION_TIME_FIELD]) || compareValues(a[INTERNAL_ID_FIELD], b[INTERNAL_ID_FIELD]));
5808
6154
  if (order === "desc") rows.reverse();
5809
6155
  return new BufferedQueryStream(rows.map((row) => [row, [row[INTERNAL_CREATION_TIME_FIELD], row[INTERNAL_ID_FIELD]]]), order, [INTERNAL_CREATION_TIME_FIELD, INTERNAL_ID_FIELD], []);
5810
6156
  }
6157
+ /**
6158
+ * The declared index a stream read can walk to emit `field` in order.
6159
+ *
6160
+ * A stream orders by the index it scans, so only an index that leads with
6161
+ * the field produces that order.
6162
+ */
6163
+ _findStreamOrderIndex(field) {
6164
+ return getIndexes(this.tableConfig.table).find((index) => index.fields[0] === field);
6165
+ }
5811
6166
  _buildBasePipelineStream(queryConfig, wherePredicate, configuredIndex) {
5812
6167
  const schemaDefinition = this._getSchemaDefinitionOrThrow();
5813
6168
  let streamQuery = stream(this.db, schemaDefinition).query(this.tableConfig.name);
@@ -5819,8 +6174,8 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
5819
6174
  return indexQuery;
5820
6175
  });
5821
6176
  else if (configuredIndex?.name) streamQuery = streamQuery.withIndex(configuredIndex.name, configuredIndex.range ? configuredIndex.range : (q) => q);
5822
- else if (primaryOrder && primaryOrder.field !== "_creationTime") {
5823
- const orderIndex = getIndexes(this.tableConfig.table).find((idx) => idx.fields[0] === primaryOrder.field);
6177
+ else if (primaryOrder && primaryOrder.field !== INTERNAL_CREATION_TIME_FIELD) {
6178
+ const orderIndex = this._findStreamOrderIndex(primaryOrder.field);
5824
6179
  if (orderIndex) streamQuery = streamQuery.withIndex(orderIndex.name, (q) => q);
5825
6180
  }
5826
6181
  streamQuery = streamQuery.order(primaryOrderDirection);
@@ -6978,7 +7333,11 @@ var GelRelationalQuery = class GelRelationalQuery extends QueryPromise {
6978
7333
  streamQuery = mergedStream(streams, pipeline.interleaveBy.map((field) => this._normalizePublicFieldName(field)));
6979
7334
  }
6980
7335
  } else {
6981
- if (isCursorPaginated && maxScan !== void 0 && idLookup?.kind === "in") throw new Error("An id IN pipeline cannot use maxScan because creation-order replay requires reading the complete ID list.");
7336
+ const orderedIdList = isCursorPaginated && maxScan !== void 0 && idLookup?.kind === "in" ? queryConfig.order?.[0] : void 0;
7337
+ if (orderedIdList) {
7338
+ if (orderedIdList.field === INTERNAL_CREATION_TIME_FIELD) throw new Error("An id IN pipeline cannot combine orderBy on createdAt with maxScan, because ordering an id list by creation time requires reading every id in the list. Drop maxScan, or drop orderBy to page in id-list order at one read per row.");
7339
+ if (queryConfig.order?.length !== 1 || configuredIndex?.name || queryConfig.index || !this._findStreamOrderIndex(orderedIdList.field)) throw new Error(`An id IN pipeline cannot combine orderBy on ${this._toPublicFilterFieldName(orderedIdList.field)} with maxScan, because the scan cannot produce that order. It needs a single orderBy field that an index leads with, and no index pinned by withIndex().`);
7340
+ }
6982
7341
  streamQuery = await this._buildIdLookupStream({
6983
7342
  configuredIndex,
6984
7343
  idLookup,
@@ -9336,8 +9695,26 @@ function createOrmDbLifecycle(schema, triggerDefinitions) {
9336
9695
  if (aggregateIndexes.length === 0 && rankIndexes.length === 0) continue;
9337
9696
  const existing = tableHooks.get(tableConfig.name) ?? {};
9338
9697
  const existingChange = existing.change;
9698
+ const metricIndexNames = aggregateIndexes.map((entry) => entry.name);
9699
+ const rankIndexNames = rankIndexes.map((entry) => entry.name);
9700
+ const prependWriteBarrier = (before) => async (data, ctx) => {
9701
+ await assertAggregateIndexesWritable(ctx.db, tableConfig.name, metricIndexNames, rankIndexNames);
9702
+ return before?.(data, ctx);
9703
+ };
9339
9704
  tableHooks.set(tableConfig.name, {
9340
9705
  ...existing,
9706
+ create: {
9707
+ ...existing.create,
9708
+ before: prependWriteBarrier(existing.create?.before)
9709
+ },
9710
+ update: {
9711
+ ...existing.update,
9712
+ before: prependWriteBarrier(existing.update?.before)
9713
+ },
9714
+ delete: {
9715
+ ...existing.delete,
9716
+ before: prependWriteBarrier(existing.delete?.before)
9717
+ },
9341
9718
  change: async (change, ctx) => {
9342
9719
  if (change.operation === "delete") {
9343
9720
  if (aggregateIndexes.length > 0) await applyAggregateIndexesForChange(ctx.db, tableConfig.name, aggregateIndexes, {