import type { ColineRecordAggregateGroup, ColineRecordAggregateInput, ColineRecordQuery, } from "./types"; /** * In-memory interpretation of the public collection query contract. The * server compiles the same contract to SQL; the test workspace and the * optimistic live view use this module so all three agree on filtering, * ordering, and aggregation. */ const OPERATORS = new Set([ "eq", "ne", "gt", "gte", "lt", "lte", "contains", "startsWith", "in", "notIn", "exists", ]); function isOperatorObject(value: unknown): value is Record { return ( typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0 && Object.keys(value).every((key) => OPERATORS.has(key)) ); } function scalarEquals(left: unknown, right: unknown): boolean { if (left === undefined) left = null; return left === right; } function matchesCondition(value: unknown, condition: unknown): boolean { if (!isOperatorObject(condition)) return scalarEquals(value, condition); return Object.entries(condition).every(([operator, expected]) => { switch (operator) { case "eq": return scalarEquals(value, expected); case "ne": return !scalarEquals(value, expected); case "exists": return expected ? value !== undefined && value !== null : value === undefined || value === null; case "in": return Array.isArray(expected) && expected.some((item) => scalarEquals(value, item)); case "notIn": return Array.isArray(expected) && !expected.some((item) => scalarEquals(value, item)); case "contains": return ( typeof value === "string" && typeof expected === "string" && value.toLowerCase().includes(expected.toLowerCase()) ); case "startsWith": return ( typeof value === "string" && typeof expected === "string" && value.toLowerCase().startsWith(expected.toLowerCase()) ); default: { if ( typeof value !== typeof expected || (typeof value !== "string" && typeof value !== "number") ) return false; const comparison = compareRecordValues(value, expected); if (operator === "gt") return comparison > 0; if (operator === "gte") return comparison >= 0; if (operator === "lt") return comparison < 0; if (operator === "lte") return comparison <= 0; return false; } } }); } export function matchesRecordWhere(data: Record, where: unknown): boolean { if (!where || typeof where !== "object") return true; return Object.entries(where).every(([key, condition]) => { if (key === "and") { return Array.isArray(condition) && condition.every((nested) => matchesRecordWhere(data, nested)); } if (key === "or") { return Array.isArray(condition) && condition.some((nested) => matchesRecordWhere(data, nested)); } return matchesCondition(data[key], condition); }); } /** Numeric fields sort by value; mixed scalar types keep numbers together. */ export function compareRecordValues(a: unknown, b: unknown): number { if (typeof a === "number" && typeof b === "number") return a - b; if (typeof a === "number") return -1; if (typeof b === "number") return 1; const left = String(a ?? ""), right = String(b ?? ""); // PostgreSQL's C collation follows Unicode code points for valid UTF-8. // JavaScript's default comparison instead sorts surrogate code units. let i = 0, j = 0; while (i < left.length && j < right.length) { const leftPoint = left.codePointAt(i)!, rightPoint = right.codePointAt(j)!; if (leftPoint !== rightPoint) return leftPoint - rightPoint; i += leftPoint > 0xffff ? 2 : 1; j += rightPoint > 0xffff ? 2 : 1; } return left.length - right.length; } export interface NormalizedRecordSort { field: string; order: "asc" | "desc"; } /** The effective sort keys of a query. The default is most recently updated first. */ export function normalizeRecordSort( query: Pick, "orderBy" | "order"> | undefined, ): NormalizedRecordSort[] { const orderBy = query?.orderBy; if (Array.isArray(orderBy)) { return orderBy.map((sort: { field: unknown; order?: "asc" | "desc" }) => ({ field: String(sort.field), order: sort.order ?? "asc", })); } if (typeof orderBy === "string") return [{ field: orderBy, order: query?.order ?? "desc" }]; return [{ field: "updatedAt", order: query?.order ?? "desc" }]; } interface SortableRecord { id: string; createdAt: string; updatedAt: string; data: unknown; } function sortValue(record: SortableRecord, field: string): unknown { if (field === "createdAt") return record.createdAt; if (field === "updatedAt") return record.updatedAt; return (record.data as Record)[field]; } export function compareRecords( a: SortableRecord, b: SortableRecord, sorts: readonly NormalizedRecordSort[], ): number { for (const sort of sorts) { const comparison = compareRecordValues(sortValue(a, sort.field), sortValue(b, sort.field)); if (comparison !== 0) return sort.order === "asc" ? comparison : -comparison; } const last = sorts.at(-1)?.order ?? "desc"; const tie = a.id < b.id ? -1 : a.id > b.id ? 1 : 0; return last === "asc" ? tie : -tie; } type GroupKeyValue = string | number | boolean | null; function toGroupValue(value: unknown): GroupKeyValue { if (value === undefined || value === null) return null; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return value; } return JSON.stringify(value); } /** In-memory aggregation with the server's semantics (null for empty numeric metrics). */ export function aggregateRecords( rows: Array<{ data: T }>, input: ColineRecordAggregateInput, ): { groups: ColineRecordAggregateGroup[] } { const groupBy = (input.groupBy ?? []).map(String); const buckets = new Map; rows: Array> }>(); for (const row of rows) { const data = row.data as Record; if (!matchesRecordWhere(data, input.where)) continue; const key: Record = {}; for (const field of groupBy) key[field] = toGroupValue(data[field]); const id = JSON.stringify(groupBy.map((field) => key[field])); const bucket = buckets.get(id) ?? { key, rows: [] }; bucket.rows.push(data); buckets.set(id, bucket); } if (groupBy.length === 0 && buckets.size === 0) buckets.set("[]", { key: {}, rows: [] }); const groups: ColineRecordAggregateGroup[] = [...buckets.values()].map((bucket) => { const values: Record = {}; for (const [name, metric] of Object.entries(input.metrics)) { if ("count" in metric) { values[name] = bucket.rows.length; continue; } if ("countDistinct" in metric) { const field = String(metric.countDistinct); values[name] = new Set( bucket.rows .map((row) => row[field]) .filter((value) => value !== undefined && value !== null) .map((value) => JSON.stringify(value)), ).size; continue; } const [kind, field] = Object.entries(metric)[0] as [string, string]; const numbers = bucket.rows .map((row) => row[field]) .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); if (numbers.length === 0) { values[name] = kind === "sum" ? 0 : null; continue; } if (kind === "sum") values[name] = numbers.reduce((total, value) => total + value, 0); else if (kind === "avg") values[name] = numbers.reduce((total, value) => total + value, 0) / numbers.length; else if (kind === "min") values[name] = Math.min(...numbers); else if (kind === "max") values[name] = Math.max(...numbers); } return { key: bucket.key, values }; }); const orderKey = input.orderBy?.key; if (orderKey) { const direction = input.orderBy?.order === "asc" ? 1 : -1; groups.sort((a, b) => { const left = orderKey in a.values ? a.values[orderKey] : a.key[orderKey]; const right = orderKey in b.values ? b.values[orderKey] : b.key[orderKey]; return compareRecordValues(left, right) * direction; }); } return { groups: groups.slice(0, input.limit ?? 1000) }; }