release: 1.0.0

This commit is contained in:
2026-10-02 22:48:16 +02:00
parent e470b670c2
commit a1d6f471e2
30 changed files with 4424 additions and 134 deletions
+164
View File
@@ -0,0 +1,164 @@
import type { FastifyReply } from 'fastify';
/**
* Shared list-query handling for management routes, so every endpoint that
* lists things offers the same sort, filter and pagination the data tables
* expect. The frontend speaks a PostgREST-style querystring
* (`?name=ilike.*ada*&order=created_at.desc&limit=25&offset=0`); this parses
* that into SQL fragments the route composes into its own, already
* permission-scoped, query.
*
* Columns are allowlisted, values are parameterised, and the caller supplies
* the parameter start index so the filter params slot after its own.
*/
/**
* Maps an API column name (what the client sends) to its SQL expression (what
* the query uses, e.g. 'r.name' in a joined query). Values are developer
* authored and trusted; keys are matched against the request and never
* interpolated.
*/
export type ColumnMap = Record<string, string>;
export interface ListParamsConfig {
/** Columns allowed in ORDER BY, mapped to their SQL expression. */
sortable: ColumnMap;
/** Columns allowed to filter on, mapped to their SQL expression. */
filterable: ColumnMap;
/** Applied when the request sets no order, e.g. 'name.asc'. */
defaultOrder?: string;
/** Largest page a caller may request. Default 200. */
maxLimit?: number;
}
export interface ParsedListParams {
/** True when the request asked to paginate (sent limit or offset). */
paginated: boolean;
/** The page size, or the returned-row count when unpaginated. */
limit: number;
offset: number;
/** 'ORDER BY "col" ASC' or ''. */
orderSql: string;
/** Conditions joined by AND, without a WHERE or leading AND, or ''. The route
* composes these with its own scope conditions. */
filterSql: string;
/** Filter values, numbered from startIndex. */
params: unknown[];
/** ' LIMIT $n OFFSET $m', or '' when the request did not ask to paginate. */
pageSql: string;
/** All params in order: filters, then limit and offset when paginating. */
allParams: unknown[];
}
export class ListQueryError extends Error {
constructor(message: string) { super(message); this.name = 'ListQueryError'; }
}
const RESERVED = new Set(['order', 'limit', 'offset', 'select', 'include_archived']);
const OPERATORS: Record<string, (col: string, i: number) => { sql: string; wildcard?: boolean }> = {
eq: (c, i) => ({ sql: `${c} = $${i}` }),
neq: (c, i) => ({ sql: `${c} <> $${i}` }),
gt: (c, i) => ({ sql: `${c} > $${i}` }),
gte: (c, i) => ({ sql: `${c} >= $${i}` }),
lt: (c, i) => ({ sql: `${c} < $${i}` }),
lte: (c, i) => ({ sql: `${c} <= $${i}` }),
like: (c, i) => ({ sql: `${c} LIKE $${i}`, wildcard: true }),
ilike: (c, i) => ({ sql: `${c} ILIKE $${i}`, wildcard: true }),
};
function parseInteger(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const n = Number(value);
if (!Number.isInteger(n) || n < 0) throw new ListQueryError(`invalid_integer:${value}`);
return n;
}
function buildOrder(raw: string | undefined, sortable: ColumnMap, fallback: string | undefined): string {
const spec = raw ?? fallback;
if (!spec) return '';
const parts: string[] = [];
for (const segment of spec.split(',')) {
const [col, dir = 'asc'] = segment.trim().split('.');
const expr = sortable[col];
if (!expr) throw new ListQueryError(`unsortable_column:${col}`);
const direction = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
parts.push(`${expr} ${direction}`);
}
return parts.length ? `ORDER BY ${parts.join(', ')}` : '';
}
/**
* Parses the querystring into SQL fragments. `qs` is req.query. `startIndex` is
* one past the count of params the route already uses, so filter params do not
* collide with the route's own.
*/
export function parseListParams(
qs: Record<string, unknown>,
config: ListParamsConfig,
startIndex = 1,
): ParsedListParams {
const maxLimit = config.maxLimit ?? 200;
const first = (v: unknown): string | undefined =>
Array.isArray(v) ? (v[0] as string | undefined) : (v as string | undefined);
// Pagination is opt-in: a request that sends neither limit nor offset gets
// every matching row, so existing clients that expect the full list are
// unchanged. A request that paginates is clamped to the max page.
const paginated = first(qs.limit) !== undefined || first(qs.offset) !== undefined;
const requestedLimit = parseInteger(first(qs.limit), maxLimit);
const limit = Math.min(requestedLimit, maxLimit);
const offset = parseInteger(first(qs.offset), 0);
const orderSql = buildOrder(first(qs.order), config.sortable, config.defaultOrder);
const conditions: string[] = [];
const params: unknown[] = [];
let index = startIndex;
for (const [key, rawValue] of Object.entries(qs)) {
if (RESERVED.has(key)) continue;
const value = first(rawValue);
if (value === undefined || value === '') continue;
const expr = config.filterable[key];
if (!expr) throw new ListQueryError(`unfilterable_column:${key}`);
const dot = value.indexOf('.');
const op = dot === -1 ? 'eq' : value.slice(0, dot).toLowerCase();
const raw = dot === -1 ? value : value.slice(dot + 1);
const build = OPERATORS[op];
if (!build) throw new ListQueryError(`unsupported_operator:${op}`);
const { sql, wildcard } = build(expr, index);
conditions.push(sql);
params.push(wildcard ? raw.replace(/\*/g, '%') : raw);
index += 1;
}
const filterSql = conditions.join(' AND ');
const pageSql = paginated ? ` LIMIT $${index} OFFSET $${index + 1}` : '';
return {
paginated,
limit,
offset,
orderSql,
filterSql,
params,
pageSql,
allParams: paginated ? [...params, limit, offset] : [...params],
};
}
/** Joins scope conditions with the parsed filter conditions into a WHERE, or ''. */
export function composeWhere(...conditions: Array<string | undefined | null>): string {
const kept = conditions.filter((c): c is string => Boolean(c && c.trim()));
return kept.length ? `WHERE ${kept.join(' AND ')}` : '';
}
/** Sets the Content-Range header the frontend reads for the total row count. */
export function setContentRange(reply: FastifyReply, offset: number, returned: number, total: number): void {
const end = returned > 0 ? offset + returned - 1 : offset;
reply.header('Content-Range', `${offset}-${end}/${total}`);
}