convex-helpers
Version:
A collection of useful code to complement the official convex package.
221 lines (212 loc) • 7.3 kB
text/typescript
import type { Value } from "convex/values";
import type {
DataModelFromSchemaDefinition,
DocumentByName,
GenericDataModel,
GenericDatabaseReader,
IndexNames,
NamedTableInfo,
SchemaDefinition,
TableNamesInDataModel,
} from "convex/server";
import {
getIndexFields,
StreamDatabaseReader,
stream,
streamIndexRange,
} from "./stream.js";
export type IndexKey = (Value | undefined)[];
export type PageRequest<
DataModel extends GenericDataModel,
T extends TableNamesInDataModel<DataModel>,
> = {
/** Request a page of documents from this table. */
table: T;
/** Where the page starts. Default or empty array is the start of the table. */
startIndexKey?: IndexKey;
/** Whether the startIndexKey is inclusive. Default is false. */
startInclusive?: boolean;
/** Where the page ends. If provided, all documents up to this key will be
* included, if possible. targetMaxRows will be ignored (but absoluteMaxRows
* will not). This ensures adjacent pages stay adjacent, even as they grow.
* An empty array means the end of the table.
*/
endIndexKey?: IndexKey;
/** Whether the endIndexKey is inclusive. Default is true.*/
endInclusive?: boolean;
/** Maximum number of rows to return, as long as endIndexKey is not provided.
* Default is 100.
*/
targetMaxRows?: number;
/** Absolute maximum number of rows to return, even if endIndexKey is
* provided. Use this to prevent a single page from growing too large, but
* watch out because gaps can form between pages.
* Default is unlimited.
*/
absoluteMaxRows?: number;
/** Whether the index is walked in ascending or descending order. Default is
* ascending.
*/
order?: "asc" | "desc";
/** Which index to walk.
* Default is by_creation_time.
*/
index?: IndexNames<NamedTableInfo<DataModel, T>>;
/** If index is not by_creation_time or by_id,
* you need to provide the index fields, either directly or from the schema.
* schema can be found with
* `import schema from "./schema";`
*/
schema?: SchemaDefinition<any, boolean>;
/** The fields of the index, if you specified an index and not a schema. */
indexFields?: string[];
};
export type PageResponse<
DataModel extends GenericDataModel,
T extends TableNamesInDataModel<DataModel>,
> = {
/** Page of documents in the table.
* Order is by the `index`, possibly reversed by `order`.
*/
page: DocumentByName<DataModel, T>[];
/** hasMore is true if this page did not exhaust the queried range.*/
hasMore: boolean;
/** indexKeys[i] is the index key for the document page[i].
* indexKeys can be used as `startIndexKey` or `endIndexKey` to fetch pages
* relative to this one.
*/
indexKeys: IndexKey[];
};
/**
* Get a single page of documents from a table.
* See examples in README.
* @param ctx A ctx from a query or mutation context.
* @param request What page to get.
* @returns { page, hasMore, indexKeys }.
*/
export async function getPage<
DataModel extends GenericDataModel,
T extends TableNamesInDataModel<DataModel>,
>(
ctx: { db: GenericDatabaseReader<DataModel> },
request: PageRequest<DataModel, T>,
): Promise<PageResponse<DataModel, T>> {
const absoluteMaxRows = request.absoluteMaxRows ?? Infinity;
const targetMaxRows = request.targetMaxRows ?? DEFAULT_TARGET_MAX_ROWS;
const absoluteLimit = request.endIndexKey
? absoluteMaxRows
: Math.min(absoluteMaxRows, targetMaxRows);
const page: DocumentByName<DataModel, T>[] = [];
const indexKeys: IndexKey[] = [];
const stream = streamQuery(ctx, request);
for await (const [doc, indexKey] of stream) {
if (page.length >= absoluteLimit) {
return {
page,
hasMore: true,
indexKeys,
};
}
page.push(doc);
indexKeys.push(indexKey);
}
return {
page,
hasMore: false,
indexKeys,
};
}
export async function* streamQuery<
DataModel extends GenericDataModel,
T extends TableNamesInDataModel<DataModel>,
>(
ctx: { db: GenericDatabaseReader<DataModel> },
request: Omit<PageRequest<DataModel, T>, "targetMaxRows" | "absoluteMaxRows">,
): AsyncGenerator<[DocumentByName<DataModel, T>, IndexKey]> {
const index = request.index ?? "by_creation_time";
const indexFields = getIndexFields(
request.table,
request.index as any,
request.schema,
);
const startIndexKey = request.startIndexKey ?? [];
const endIndexKey = request.endIndexKey ?? [];
const startInclusive = request.startInclusive ?? false;
const order = request.order === "desc" ? "desc" : "asc";
const endInclusive = request.endInclusive ?? true;
if (
indexFields.length < startIndexKey.length ||
indexFields.length < endIndexKey.length
) {
throw new Error("Index key length exceeds index fields length");
}
const bounds = {
lowerBound: order === "asc" ? startIndexKey : endIndexKey,
lowerBoundInclusive: order === "asc" ? startInclusive : endInclusive,
upperBound: order === "asc" ? endIndexKey : startIndexKey,
upperBoundInclusive: order === "asc" ? endInclusive : startInclusive,
};
const stream = streamIndexRange(
ctx.db as any,
request.schema as any,
request.table,
index as any,
bounds,
order,
).iterWithKeys();
for await (const [doc, indexKey] of stream) {
yield [doc, indexKey];
}
}
/**
* Simpified version of `getPage` that you can use for one-off queries that
* don't need to be reactive.
*
* These two queries are roughly equivalent:
*
* ```ts
* await db.query(table)
* .withIndex(index, q=>q.eq(field, value))
* .order("desc")
* .paginate(opts)
*
* await paginator(db, schema)
* .query(table)
* .withIndex(index, q=>q.eq(field, value))
* .order("desc")
* .paginate(opts)
* ```
*
* Differences:
*
* - `paginator` does not automatically track the end of the page for when
* the query reruns. The standard `paginate` call will record the end of the page,
* so a client can have seamless reactive pagination. To pin the end of the page,
* you can use the `endCursor` option. This does not happen automatically.
* Read more [here](https://stack.convex.dev/pagination#stitching-the-pages-together)
* - `paginator` can be called multiple times in a query or mutation,
* and within Convex components.
* - Cursors are not encrypted.
* - `.filter()` and the `filter()` convex-helper are not supported.
* Filter the returned `page` in TypeScript instead.
* - System tables like _storage and _scheduled_functions are not supported.
* - Having a schema is required.
*
* @argument opts.cursor Where to start the page. This should come from
* `continueCursor` in the previous page.
* @argument opts.endCursor Where to end the page. This should from from
* `continueCursor` in the *current* page.
* If not provided, the page will end when it reaches `options.opts.numItems`.
* @argument options.schema If you use an index that is not by_creation_time
* or by_id, you need to provide the schema.
*/
export function paginator<Schema extends SchemaDefinition<any, boolean>>(
db: GenericDatabaseReader<DataModelFromSchemaDefinition<Schema>>,
schema: Schema,
): StreamDatabaseReader<Schema> {
return stream(db, schema);
}
//
// Helper functions
//
const DEFAULT_TARGET_MAX_ROWS = 100;