/** * @factory/database-engine/query — stable multi-key sort (TASK-0068). * * `sortRecords` is pure and stable; the final tiebreak is (position, id) so * the result order is deterministic for the same input regardless of initial * order. Comparators come from the codec registry (`compare`). Nulls go to the * end for ASC, the start for DESC (`nullOrder: "last" | "first"`, default last). * At most 5 keys (the spec cap). Duplicate property keys are rejected. */ import type { SortSpec } from "@factory/shared/dto"; import type { RecordRow, PropertyInfo } from "../values/types.js"; import { codec } from "../values/registry.js"; export const MAX_SORT_KEYS = 5; export interface SortKey extends SortSpec { nullOrder?: "last" | "first"; } export class SortValidationError extends Error { constructor(message: string) { super(message); this.name = "SortValidationError"; } } export function validateSortKeys( keys: readonly SortKey[], properties: Map, ): void { if (keys.length > MAX_SORT_KEYS) throw new SortValidationError(`at most ${MAX_SORT_KEYS} sort keys`); const seen = new Set(); for (const k of keys) { if (!properties.has(k.propertyId)) throw new SortValidationError(`unknown sort property: ${k.propertyId}`); if (seen.has(k.propertyId)) throw new SortValidationError(`duplicate sort property: ${k.propertyId}`); seen.add(k.propertyId); } } function hasValue(v: unknown): boolean { if (v === undefined || v === null) return false; if (Array.isArray(v)) return v.length > 0; if (typeof v === "string") return v.length > 0; return true; } export function sortRecords( rows: T[], keys: readonly SortKey[], properties: Map, validate = true, ): T[] { if (validate) validateSortKeys(keys, properties); const out = rows.slice(); out.sort((a, b) => { for (const key of keys) { const property = properties.get(key.propertyId); if (!property) continue; const c = codec(property.type); const av = a.values[key.propertyId]; const bv = b.values[key.propertyId]; const aNull = !hasValue(av); const bNull = !hasValue(bv); const nullOrder = key.nullOrder ?? "last"; if (aNull && bNull) continue; if (aNull) return nullOrder === "last" ? 1 : -1; if (bNull) return nullOrder === "last" ? -1 : 1; let r = c.compare(av, bv, property.config); if (key.direction === "desc") r = -r; if (r !== 0) return r; } // deterministic tiebreak if (a.position !== b.position) return a.position < b.position ? -1 : 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); return out; }