@adonis-agora/filter
Version:
Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.
107 lines • 4.42 kB
JavaScript
/**
* Encodes keyset cursor values into a compact, URL-safe opaque string
* (base64url of a JSON array). The shape is intentionally opaque to clients —
* only this module reads it back.
*
* `Date` values are encoded as `{ $d: <iso> }` so they round-trip to `Date`
* instances on decode (plain JSON would yield a string and break date keyset
* comparisons).
*/
export function encodeCursor(values) {
// Pre-map Date values to a tagged form. We cannot detect dates in the
// JSON.stringify replacer because Date.toJSON() has already converted them to
// strings by the time the replacer sees the value.
const tagged = values.map((v) => (v instanceof Date ? { $d: v.toISOString() } : v));
const json = JSON.stringify(tagged);
return Buffer.from(json, 'utf8').toString('base64url');
}
/**
* Decodes an opaque cursor string back into its keyset values. Returns `null`
* when the cursor is malformed (bad base64, bad JSON, or not an array) so the
* caller can ignore an invalid cursor instead of crashing.
*/
export function decodeCursor(cursor) {
try {
const json = Buffer.from(cursor, 'base64url').toString('utf8');
const parsed = JSON.parse(json, (_key, value) => {
if (value && typeof value === 'object' && typeof value.$d === 'string') {
return new Date(value.$d);
}
return value;
});
return Array.isArray(parsed) ? parsed : null;
}
catch {
return null;
}
}
/**
* Builds the keyset {@link SortItem}[] for cursor pagination: the caller's
* effective sorts, with a stable primary-key tiebreaker appended if it is not
* already present. The tiebreaker inherits the direction of the last sort column
* so the overall ordering stays monotonic (important for a correct
* `(cols, pk) > (...)` comparison).
*/
export function buildKeyset(sorts, primaryKey) {
const hasPk = sorts.some((s) => s.field === primaryKey);
if (hasPk)
return sorts;
const lastDirection = sorts[sorts.length - 1]?.direction ?? 'asc';
return [...sorts, { field: primaryKey, direction: lastDirection }];
}
/** Flips every keyset column's direction (for backward cursor paging). */
export function reverseKeyset(keyset) {
return keyset.map((s) => ({
field: s.field,
direction: s.direction === 'asc' ? 'desc' : 'asc',
}));
}
/**
* Extracts the keyset values from a fetched row, in keyset column order.
* Supports dotted relation paths (e.g. `author.name`) by walking the object.
*/
export function extractCursorValues(row, keyset) {
return keyset.map((s) => {
if (!s.field.includes('.'))
return row[s.field];
let current = row;
for (const segment of s.field.split('.')) {
if (current == null || typeof current !== 'object')
return undefined;
current = current[segment];
}
return current;
});
}
/**
* Assembles a {@link CursorPage} from the rows fetched for a keyset query.
*
* The query is expected to have been built by {@link applyCursor}, which fetches
* one extra row (`limit = size + 1`) so we can detect a further page. For
* backward paging the rows come back reversed and are flipped here to restore
* the caller's requested order. Boundary cursors are computed from the base
* keyset so they round-trip regardless of paging direction.
*/
export function buildCursorPage(rows, resolved) {
const { keyset, size, backward, hasCursor } = resolved;
const hasExtra = rows.length > size;
let pageRows = hasExtra ? rows.slice(0, size) : rows;
if (backward)
pageRows = pageRows.slice().reverse();
const firstRow = pageRows[0];
const lastRow = pageRows[pageRows.length - 1];
const startCursor = firstRow ? encodeCursor(extractCursorValues(firstRow, keyset)) : null;
const endCursor = lastRow ? encodeCursor(extractCursorValues(lastRow, keyset)) : null;
// With a cursor present, the opposite-direction page is known to exist; the
// same-direction page exists iff we saw the extra row.
const hasNext = backward ? hasCursor : hasExtra;
const hasPrev = backward ? hasExtra : hasCursor;
return {
items: pageRows,
nextCursor: hasNext ? endCursor : null,
prevCursor: hasPrev ? startCursor : null,
hasNext,
hasPrev,
};
}
//# sourceMappingURL=cursor.js.map