feat(books): allow sorting by custom metadata fields

Extend book sorting to support custom metadata field references (custom:<id>). Join custom metadata values in BookSortBuilder based on field data type, resolve active field types in BookService, and expose custom fields in BookSortBuilder UI.

Closes #829
This commit is contained in:
neon
2026-07-30 20:46:33 +00:00
parent d9057fed3b
commit 50ba5f151b
31 changed files with 518 additions and 40 deletions
+3
View File
@@ -4,6 +4,9 @@ export type CustomMetadataFieldType = (typeof CUSTOM_METADATA_FIELD_TYPES)[numbe
export type CustomMetadataPrimitiveValue = string | number | boolean | null;
/** Active field id to type, resolved per query so sorting can pick the matching value column. */
export type CustomMetadataFieldTypeMap = ReadonlyMap<number, CustomMetadataFieldType>;
export interface CustomMetadataFieldDefinition {
id: number;
key: string;
+6 -3
View File
@@ -1,4 +1,5 @@
import type { BookQuery, SortField, SortSpec } from "./query";
import { isCustomSortField } from "./query";
import type { BookQuery, SortSpec, StaticSortField } from "./query";
export type JumpBucketKind = "letter" | "temporal" | "category";
@@ -37,7 +38,7 @@ export type TemporalJumpBucketPrecision = "date" | "year";
export type JumpRailStrategy = { kind: "letter" } | { kind: "temporal"; precision: TemporalJumpBucketPrecision } | { kind: "category" };
const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpRailStrategy>> = {
const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<StaticSortField, JumpRailStrategy>> = {
title: { kind: "letter" },
author: { kind: "letter" },
series: { kind: "letter" },
@@ -55,7 +56,9 @@ const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpRailStrategy
};
export function jumpRailStrategyForSort(sort: SortSpec[]): JumpRailStrategy | null {
const primary = sort[0] ?? { field: "title", dir: "asc" };
const primary = sort[0] ?? { field: "title" as const, dir: "asc" as const };
// Custom metadata fields carry no bucketing semantics, so the rail is hidden for them.
if (isCustomSortField(primary.field)) return null;
return STRATEGY_BY_PRIMARY_SORT_FIELD[primary.field] ?? null;
}
+48 -2
View File
@@ -189,8 +189,10 @@ export type GroupRule = {
*
* Fields marked "per-user, correlated subquery" require an authenticated userId and
* execute a subquery per result row; they are slower on large result sets.
*
* User-defined custom metadata fields are sortable too, as `CustomSortField`.
*/
export type SortField =
export type StaticSortField =
| "author"
| "title"
| "series"
@@ -213,7 +215,21 @@ export type SortField =
| "language"
| "metadataScore";
export const SORT_FIELDS: SortField[] = [
/**
* A user-defined custom metadata field, referenced by its numeric id.
*
* Values live in `book_custom_metadata_values`, which stores one typed column
* per value kind, so the server resolves the field's type at query time to pick
* the column to order by. Ids that no longer resolve to an active field are
* dropped from the sort rather than rejected, so a saved sort survives the
* field being archived or deleted.
*/
export type CustomSortField = `custom:${number}`;
export type SortField = StaticSortField | CustomSortField;
/** Built-in sort fields only. Custom fields are resolved at runtime, not enumerable here. */
export const SORT_FIELDS: StaticSortField[] = [
"author",
"title",
"series",
@@ -242,6 +258,36 @@ export type SortSpec = {
dir: "asc" | "desc";
};
// Bounded to 9 digits so a parsed id always fits the int4 field id column.
const CUSTOM_SORT_FIELD_PATTERN = /^custom:([1-9]\d{0,8})$/;
export function customSortField(fieldId: number): CustomSortField {
return `custom:${fieldId}`;
}
export function isCustomSortField(field: string): field is CustomSortField {
return CUSTOM_SORT_FIELD_PATTERN.test(field);
}
/** Returns the custom metadata field id a sort field references, or null when it is not a custom sort. */
export function parseCustomSortFieldId(field: string): number | null {
const match = CUSTOM_SORT_FIELD_PATTERN.exec(field);
return match ? Number(match[1]) : null;
}
export function isSortField(field: string): field is SortField {
return (SORT_FIELDS as string[]).includes(field) || isCustomSortField(field);
}
export function customSortFieldIds(sort: SortSpec[]): number[] {
const ids = new Set<number>();
for (const { field } of sort) {
const id = parseCustomSortFieldId(field);
if (id !== null) ids.add(id);
}
return [...ids];
}
export type BookQuery = {
filter?: GroupRule;
sort: SortSpec[];