@convex-dev/aggregate
Version:
[](https://badge.fury.io/js/@convex-dev%2Faggregate)
214 lines • 8.42 kB
JavaScript
import { positionToKey, boundToPosition, keyToPosition } from "./positions.js";
/**
* Write data to be aggregated, and read aggregated data.
*
* The data structure is effectively a key-value store sorted by key, where the
* value is an ID and an optional summand.
* 1. The key can be any Convex value (number, string, array, etc.).
* 2. The ID is a string which should be unique.
* 3. The summand is a number which is aggregated by summing. If not provided,
* it's assumed to be zero.
*
* Once values have been added to the data structure, you can query for the
* count and sum of items between a range of keys.
*/
export class Aggregate {
component;
constructor(component) {
this.component = component;
}
/// Aggregate queries.
/**
* Returns the item at the given offset/index/rank in the order of key.
*/
async at(ctx, offset) {
const { k, s } = await ctx.runQuery(this.component.btree.atOffset, { offset });
const { key, id } = positionToKey(k);
return {
key: key,
id: id,
summand: s,
};
}
/**
* Returns the rank/offset/index of the given key.
* Specifically, it returns the index of the first item with a key >= the given key.
*/
async offsetOf(ctx, key, id) {
return await ctx.runQuery(this.component.btree.offset, { key: boundToPosition("lower", { key, id, inclusive: true }) });
}
/**
* Counts items between the given lower and upper bounds.
*/
async count(ctx, bounds) {
const { count } = await ctx.runQuery(this.component.btree.aggregateBetween, { k1: boundToPosition("lower", bounds?.lower), k2: boundToPosition("upper", bounds?.upper) });
return count;
}
/**
* Adds up the summands of items between the given lower and upper bounds.
*/
async sum(ctx, bounds) {
const { sum } = await ctx.runQuery(this.component.btree.aggregateBetween, { k1: boundToPosition("lower", bounds?.lower), k2: boundToPosition("upper", bounds?.upper) });
return sum;
}
/**
* Gets the minimum item within the given bounds.
*/
async min(ctx, bounds) {
const count = await this.count(ctx, bounds);
if (count === 0) {
return null;
}
const countUpToBound = await this.count(ctx, { ...bounds, lower: undefined });
return await this.at(ctx, countUpToBound - count);
}
/**
* Gets the maximum item within the given bounds.
*/
async max(ctx, bounds) {
const count = await this.count(ctx, bounds);
if (count === 0) {
return null;
}
const countUpToBound = await this.count(ctx, { ...bounds, lower: undefined });
return await this.at(ctx, countUpToBound - 1);
}
/**
* Gets a uniformly random item within the given bounds.
*/
async random(ctx, bounds) {
const count = await this.count(ctx, bounds);
if (count === 0) {
return null;
}
const countUpToBound = await this.count(ctx, { ...bounds, lower: undefined });
const index = Math.floor(Math.random() * count);
return await this.at(ctx, countUpToBound - count + index);
}
// TODO: iter items between keys
// For now you can use `rankOf` and `at` to iterate.
/// Write operations.
/**
* Insert a new key into the data structure.
* The id should be unique.
* If not provided, the summand is assumed to be zero.
* If the tree does not exist yet, it will be initialized with the default
* maxNodeSize and lazyRoot=true.
*/
async insert(ctx, key, id, summand) {
await ctx.runMutation(this.component.public.insert, { key: keyToPosition(key, id), summand, value: id });
}
/**
* Delete the key with the given ID from the data structure.
* Throws if the given key and ID do not exist.
*/
async delete(ctx, key, id) {
await ctx.runMutation(this.component.public.delete_, { key: keyToPosition(key, id) });
}
/**
* Update an existing item in the data structure.
* This is effectively a delete followed by an insert, but it's performed
* atomically so it's impossible to view the data structure with the key missing.
*/
async replace(ctx, currentKey, newKey, id, summand) {
await ctx.runMutation(this.component.public.replace, {
currentKey: keyToPosition(currentKey, id),
newKey: keyToPosition(newKey, id),
summand,
value: id,
});
}
/**
* Equivalents to `insert`, `delete`, and `replace` where the item may or may not exist.
* This can be useful for live backfills:
* 1. Update live writes to use these methods to write into the new Aggregate.
* 2. Run a background backfill, paginating over existing data, calling `insertIfDoesNotExist` on each item.
* 3. Once the backfill is complete, use `insert`, `delete`, and `replace` for live writes.
* 4. Begin using the Aggregate read methods.
*/
async insertIfDoesNotExist(ctx, key, id, summand) {
await this.replaceOrInsert(ctx, key, key, id, summand);
}
async deleteIfExists(ctx, key, id) {
await ctx.runMutation(this.component.public.deleteIfExists, { key: keyToPosition(key, id) });
}
async replaceOrInsert(ctx, currentKey, newKey, id, summand) {
await ctx.runMutation(this.component.public.replaceOrInsert, {
currentKey: keyToPosition(currentKey, id),
newKey: keyToPosition(newKey, id),
summand,
value: id,
});
}
/// Initialization and maintenance.
/**
* (re-)initialize the data structure, removing all items if it exists.
*
* Change the maxNodeSize if provided, otherwise keep it the same.
* maxNodeSize is how you tune the data structure's width and depth.
* Larger values can reduce write contention but increase read latency.
* Default is 16.
* Set rootLazy = false to eagerly compute aggregates on the root node, which
* improves aggregation latency at the expense of making all writes contend
* with each other, so it's only recommended for read-heavy workloads.
* Default is true.
*/
async clear(ctx, maxNodeSize, rootLazy) {
await ctx.runMutation(this.component.public.clear, { maxNodeSize, rootLazy });
}
/**
* If rootLazy is false (the default is true but it can be set to false by
* `clear`), the aggregates data structure writes to a single root node on
* every insert/delete/replace, which can cause contention.
*
* If your data structure has frequent writes, you can reduce contention by
* calling makeRootLazy, which removes the frequent writes to the root node.
* With a lazy root node, updates will only contend with other updates to the
* same shard of the tree. The number of shards is determined by maxNodeSize,
* so larger maxNodeSize can also help.
*/
async makeRootLazy(ctx) {
await ctx.runMutation(this.component.public.makeRootLazy);
}
}
/**
* Simplified Aggregate API that doesn't have keys or summands, so it's
* simpler to use for counting all items or getting a random item.
*
* See docstrings on Aggregate for more details.
*/
export class Randomize {
component;
aggregate;
constructor(component) {
this.component = component;
this.aggregate = new Aggregate(component);
}
async count(ctx) {
return await this.aggregate.count(ctx);
}
async at(ctx, offset) {
const item = await this.aggregate.at(ctx, offset);
return item.id;
}
async random(ctx) {
const item = await this.aggregate.random(ctx);
return item ? item.id : null;
}
async insert(ctx, id) {
await this.aggregate.insert(ctx, null, id);
}
async delete(ctx, id) {
await this.aggregate.delete(ctx, null, id);
}
async insertIfDoesNotExist(ctx, id) {
await this.aggregate.insertIfDoesNotExist(ctx, null, id);
}
async deleteIfExists(ctx, id) {
await this.aggregate.deleteIfExists(ctx, null, id);
}
async clear(ctx, maxNodeSize, rootLazy) {
await this.aggregate.clear(ctx, maxNodeSize, rootLazy);
}
}
//# sourceMappingURL=index.js.map