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/aggregate/index.d.ts +7 -1
- package/dist/aggregate/index.js +1 -1
- package/dist/auth/generated/index.d.ts +1 -1
- package/dist/auth/index.d.ts +14 -14
- package/dist/auth/index.js +1 -1
- package/dist/cli.mjs +4 -2
- package/dist/{generated-contract-disabled-LOAfma7_.d.ts → generated-contract-disabled-BbPCefQM.d.ts} +12 -12
- package/dist/{local-env-DykABjCt.mjs → local-env-yFKub75x.mjs} +5 -1
- package/dist/orm/index.d.ts +1 -1
- package/dist/orm/index.js +548 -171
- package/dist/{query-context-CJE_hYA3.js → query-context-BzihIpnM.js} +208 -102
- package/dist/{runtime-DmVSOe24.js → runtime-6DJxpDfb.js} +73 -37
- package/dist/watcher.mjs +1 -1
- package/dist/{where-clause-compiler-B1weGkP4.d.ts → where-clause-compiler-BGBNBNit.d.ts} +46 -35
- package/package.json +1 -1
- package/skills/kitcn/references/features/aggregates.md +10 -1
- package/skills/kitcn/references/features/orm.md +1 -1
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-
|
|
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-
|
|
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) => [
|
|
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,
|
|
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
|
|
2437
|
-
const
|
|
2438
|
-
const
|
|
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
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
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
|
-
|
|
2557
|
-
|
|
2558
|
-
const
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
if (!
|
|
2577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
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
|
|
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
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
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
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
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
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
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
|
-
|
|
2832
|
+
extrema.push(...toExtremaDeltas(existing.keyHash, normalizeExtremaValues(existing.extremaValues), -1));
|
|
2759
2833
|
}
|
|
2760
|
-
|
|
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
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
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
|
|
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
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
3126
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
3265
|
-
|
|
3266
|
-
const
|
|
3267
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5791
|
-
*
|
|
5792
|
-
*
|
|
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
|
|
5805
|
-
|
|
5806
|
-
|
|
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 !==
|
|
5823
|
-
const orderIndex =
|
|
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
|
-
|
|
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, {
|