@beignet/core
Version:
Core framework primitives for Beignet
279 lines (250 loc) • 5.7 kB
text/typescript
/**
* Sort direction for list queries.
*/
export type SortDirection = "asc" | "desc";
/**
* Sort option for a whitelisted field.
*/
export type SortOption<Field extends string = string> = {
/**
* Field to sort by.
*/
field: Field;
/**
* Sort direction.
*/
direction: SortDirection;
};
/**
* Normalized offset pagination request.
*/
export interface OffsetPage {
/**
* Pagination mode.
*/
readonly kind: "offset";
/**
* Page size after defaulting and max-limit clamping.
*/
readonly limit: number;
/**
* Zero-based item offset.
*/
readonly offset: number;
}
/**
* Normalized cursor pagination request.
*/
export interface CursorPage {
/**
* Pagination mode.
*/
readonly kind: "cursor";
/**
* Page size after defaulting and max-limit clamping.
*/
readonly limit: number;
/**
* Cursor for the current page, or null for the first page.
*/
readonly cursor: string | null;
}
/**
* Normalized pagination request.
*/
export type Page = OffsetPage | CursorPage;
/**
* Offset pagination response metadata.
*/
export type OffsetPageInfo = OffsetPage & {
/**
* Total item count.
*/
readonly total: number;
/**
* Whether another page exists.
*/
readonly hasMore: boolean;
};
/**
* Cursor pagination response metadata.
*/
export type CursorPageInfo = CursorPage & {
/**
* Cursor for the next page, or null when there is no next page.
*/
readonly nextCursor: string | null;
/**
* Whether another page exists.
*/
readonly hasMore: boolean;
};
/**
* Pagination response metadata.
*/
export type PageInfo = OffsetPageInfo | CursorPageInfo;
/**
* Paginated list result.
*/
export interface PageResult<TItem, TPage extends PageInfo = PageInfo> {
/**
* Page items. Result helpers clone the input array.
*/
readonly items: TItem[];
/**
* Page metadata.
*/
readonly page: TPage;
}
/**
* Options for normalizing pagination input.
*/
export interface NormalizePageOptions {
/**
* Limit used when the caller does not provide one.
*/
readonly defaultLimit: number;
/**
* Maximum allowed limit. Larger caller values are clamped.
*/
readonly maxLimit: number;
}
/**
* Raw offset pagination input.
*/
export interface OffsetPageInput {
/**
* Requested page size.
*/
readonly limit?: number | null;
/**
* Requested zero-based offset.
*/
readonly offset?: number | null;
}
/**
* Raw cursor pagination input.
*/
export interface CursorPageInput {
/**
* Requested page size.
*/
readonly limit?: number | null;
/**
* Requested cursor.
*/
readonly cursor?: string | null;
}
/**
* Error thrown when pagination input is invalid.
*/
export class PaginationError extends Error {
constructor(message: string) {
super(message);
this.name = "PaginationError";
}
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new PaginationError(`${name} must be a positive integer.`);
}
}
function normalizeLimit(
input: number | null | undefined,
options: NormalizePageOptions,
): number {
assertPositiveInteger("defaultLimit", options.defaultLimit);
assertPositiveInteger("maxLimit", options.maxLimit);
if (options.defaultLimit > options.maxLimit) {
throw new PaginationError(
"defaultLimit must be less than or equal to maxLimit.",
);
}
if (input == null) return options.defaultLimit;
if (!Number.isInteger(input) || input < 1) {
throw new PaginationError("limit must be a positive integer.");
}
return Math.min(input, options.maxLimit);
}
/**
* Normalize offset pagination input.
*
* The limit is defaulted and clamped to `maxLimit`; offset must be a
* non-negative integer.
*/
export function normalizeOffsetPage(
input: OffsetPageInput,
options: NormalizePageOptions,
): OffsetPage {
const limit = normalizeLimit(input.limit, options);
const offset = input.offset ?? 0;
if (!Number.isInteger(offset) || offset < 0) {
throw new PaginationError("offset must be a non-negative integer.");
}
return {
kind: "offset",
limit,
offset,
};
}
/**
* Normalize cursor pagination input.
*
* The limit is defaulted and clamped to `maxLimit`; cursor must be a string or
* null.
*/
export function normalizeCursorPage(
input: CursorPageInput,
options: NormalizePageOptions,
): CursorPage {
const limit = normalizeLimit(input.limit, options);
const cursor = input.cursor ?? null;
if (cursor !== null && typeof cursor !== "string") {
throw new PaginationError("cursor must be a string or null.");
}
return {
kind: "cursor",
limit,
cursor,
};
}
/**
* Create an offset page result and derive `hasMore`.
*/
export function offsetPageResult<TItem>(
items: readonly TItem[],
page: OffsetPage,
total: number,
): PageResult<TItem, OffsetPageInfo> {
if (!Number.isInteger(total) || total < 0) {
throw new PaginationError("total must be a non-negative integer.");
}
return {
items: [...items],
page: {
...page,
total,
hasMore: page.offset + items.length < total,
},
};
}
/**
* Create a cursor page result and derive `hasMore` from `nextCursor`.
*/
export function cursorPageResult<TItem>(
items: readonly TItem[],
page: CursorPage,
nextCursor: string | null,
): PageResult<TItem, CursorPageInfo> {
if (nextCursor !== null && typeof nextCursor !== "string") {
throw new PaginationError("nextCursor must be a string or null.");
}
return {
items: [...items],
page: {
...page,
nextCursor,
hasMore: nextCursor !== null,
},
};
}