UNPKG

@convex-dev/aggregate

Version:

[![npm version](https://badge.fury.io/js/@convex-dev%2Faggregate.svg?)](https://badge.fury.io/js/@convex-dev%2Faggregate)

260 lines (244 loc) 9.77 kB
import { GenericDataModel, GenericMutationCtx, GenericQueryCtx, } from "convex/server"; import { Key } from "../component/btree.js"; import { api } from "../component/_generated/api.js"; import { UseApi } from "./useApi.js"; import { Position, positionToKey, boundToPosition, keyToPosition, Bound } from "./positions.js"; export type UsedAPI = UseApi<typeof api>; // e.g. `ctx` from a Convex query or mutation or action. export type RunQueryCtx = { runQuery: GenericQueryCtx<GenericDataModel>["runQuery"]; }; // e.g. `ctx` from a Convex mutation or action. export type RunMutationCtx = { runMutation: GenericMutationCtx<GenericDataModel>["runMutation"]; }; export type Item<K extends Key, ID extends string> = { key: K; id: ID; summand: number; }; export type Bounds<K extends Key, ID extends string> = { lower?: Bound<K, ID>; upper?: Bound<K, ID>; }; export type { Key, Bound }; /** * 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< K extends Key, ID extends string, > { constructor(private component: UsedAPI) {} /// Aggregate queries. /** * Returns the item at the given offset/index/rank in the order of key. */ async at(ctx: RunQueryCtx, offset: number): Promise<Item<K, ID>> { const { k, s } = await ctx.runQuery(this.component.btree.atOffset, { offset }); const { key, id } = positionToKey(k as Position); return { key: key as K, id: id as 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: RunQueryCtx, key: K, id?: ID): Promise<number> { 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: RunQueryCtx, bounds?: Bounds<K, ID>): Promise<number> { 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: RunQueryCtx, bounds?: Bounds<K, ID>): Promise<number> { 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: RunQueryCtx, bounds?: Bounds<K, ID>): Promise<Item<K, ID> | null> { 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: RunQueryCtx, bounds?: Bounds<K, ID>): Promise<Item<K, ID> | null> { 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: RunQueryCtx, bounds?: Bounds<K, ID>): Promise<Item<K, ID> | null> { 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: RunMutationCtx, key: K, id: ID, summand?: number): Promise<void> { 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: RunMutationCtx, key: K, id: ID): Promise<void> { 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: RunMutationCtx, currentKey: K, newKey: K, id: ID, summand?: number): Promise<void> { 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: RunMutationCtx, key: K, id: ID, summand?: number): Promise<void> { await this.replaceOrInsert(ctx, key, key, id, summand); } async deleteIfExists(ctx: RunMutationCtx, key: K, id: ID): Promise<void> { await ctx.runMutation(this.component.public.deleteIfExists, { key: keyToPosition(key, id) }); } async replaceOrInsert(ctx: RunMutationCtx, currentKey: K, newKey: K, id: ID, summand?: number): Promise<void> { 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: RunMutationCtx, maxNodeSize?: number, rootLazy?: boolean): Promise<void> { 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: RunMutationCtx): Promise<void> { 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< ID extends string, > { private aggregate: Aggregate<null, ID>; constructor(private component: UsedAPI) { this.aggregate = new Aggregate(component); } async count(ctx: RunQueryCtx): Promise<number> { return await this.aggregate.count(ctx); } async at(ctx: RunQueryCtx, offset: number): Promise<ID> { const item = await this.aggregate.at(ctx, offset); return item.id; } async random(ctx: RunQueryCtx): Promise<ID | null> { const item = await this.aggregate.random(ctx); return item ? item.id : null; } async insert(ctx: RunMutationCtx, id: ID): Promise<void> { await this.aggregate.insert(ctx, null, id); } async delete(ctx: RunMutationCtx, id: ID): Promise<void> { await this.aggregate.delete(ctx, null, id); } async insertIfDoesNotExist(ctx: RunMutationCtx, id: ID): Promise<void> { await this.aggregate.insertIfDoesNotExist(ctx, null, id); } async deleteIfExists(ctx: RunMutationCtx, id: ID): Promise<void> { await this.aggregate.deleteIfExists(ctx, null, id); } async clear(ctx: RunMutationCtx, maxNodeSize?: number, rootLazy?: boolean): Promise<void> { await this.aggregate.clear(ctx, maxNodeSize, rootLazy); } }