/** * @factory/database-engine/filter — the pure filter engine (TASK-0067). * * Evaluates the shared `FilterNode` tree (and/or/not + conditions) over * `RecordRow`s for optimistic client-side UI and the server's residual path. * No Prisma imports: the push-down to `WHERE` lives in apps/api. Empty * semantics per type (missing EAV row = empty for text/select/number; checkbox * missing = false; multi_select missing = []). Special value tokens * ("me"/"today"/"now") resolve against the EvalContext. */ import type { FilterNode, FilterOperator } from "@factory/shared/dto"; import type { RecordRow, PropertyInfo, EvalContext, PropertyValue, DateRangeValue, } from "../values/types.js"; import { dateToMs, dateBucket, DAY_MS } from "../values/dates.js"; // --- token resolution ---------------------------------------------------------------- /** Resolve "me"/"today"/"now" tokens in a condition value against ctx. */ function resolveToken(v: unknown, ctx: EvalContext): unknown { if (v === "me") return ctx.actorUserId ?? null; if (v === "now") return ctx.now; if (v === "today") return new Date(ctx.now).toISOString().slice(0, 10); return v; } /** Relative date windows for `isRelative`-style comparisons ("this week", …). */ function relativeWindow(token: string, ctx: EvalContext): { start: number; end: number } | null { const now = new Date(ctx.now); const startOfDay = (d: Date) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())).getTime(); const dow = (now.getUTCDay() + 6) % 7; // Monday = 0 if (token === "today") { const s = startOfDay(now); return { start: s, end: s + DAY_MS - 1 }; } if (token === "yesterday") { const s = startOfDay(now) - DAY_MS; return { start: s, end: s + DAY_MS - 1 }; } if (token === "tomorrow") { const s = startOfDay(now) + DAY_MS; return { start: s, end: s + DAY_MS - 1 }; } if (token === "this week") { const s = startOfDay(now) - dow * DAY_MS; return { start: s, end: s + 7 * DAY_MS - 1 }; } if (token === "last week") { const s = startOfDay(now) - dow * DAY_MS - 7 * DAY_MS; return { start: s, end: s + 7 * DAY_MS - 1 }; } if (token === "next week") { const s = startOfDay(now) - dow * DAY_MS + 7 * DAY_MS; return { start: s, end: s + 7 * DAY_MS - 1 }; } if (token === "this month") { const s = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).getTime(); const e = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).getTime() - 1; return { start: s, end: e }; } if (token === "last month") { const s = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)).getTime(); const e = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).getTime() - 1; return { start: s, end: e }; } if (token === "next month") { const s = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).getTime(); const e = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1)).getTime() - 1; return { start: s, end: e }; } if (token === "this year") { const s = Date.UTC(now.getUTCFullYear(), 0, 1); const e = Date.UTC(now.getUTCFullYear() + 1, 0, 1) - 1; return { start: s, end: e }; } return null; } // --- value normalization for comparison --------------------------------------------- function rawValue(row: RecordRow, propertyId: string): PropertyValue | undefined { return row.values[propertyId]; } function isMissing(v: PropertyValue | undefined, type: string): boolean { if (v === undefined || v === null) return true; if (type === "checkbox") return v === false ? false : false; // checkbox missing=false is handled by caller if (Array.isArray(v)) return v.length === 0; if (typeof v === "string") return v.length === 0; return false; } function toComparable( type: string, v: PropertyValue | undefined, ): number | string | boolean | null | unknown[] | DateRangeValue { if (v === undefined || v === null) return null; switch (type) { case "number": case "created_time": case "last_edited_time": case "unique_id": return typeof v === "number" ? v : null; case "checkbox": return v === true; case "select": case "status": case "date": case "text": case "rich_text": case "title": case "email": case "phone": case "url": case "created_by": case "last_edited_by": return typeof v === "string" ? v : null; case "multi_select": case "person": case "relation": case "files": return Array.isArray(v) ? v : null; case "date_range": return v; // {start,end} default: return v; } } function dateMs(v: PropertyValue | undefined): number | null { if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) { try { return dateToMs(v); } catch { return null; } } return null; } function rangeStartMs(v: PropertyValue | undefined): number | null { if (v && typeof v === "object" && !Array.isArray(v)) { const o = v as DateRangeValue; if (typeof o.start === "string") { try { return dateToMs(o.start); } catch { return null; } } } return null; } // --- condition evaluation -------------------------------------------------------------- export function evaluateCondition( row: RecordRow, property: PropertyInfo, condition: Extract, ctx: EvalContext, ): boolean { const op = condition.operator as FilterOperator; const token = resolveToken(condition.value, ctx); const value = toComparable(property.type, rawValue(row, property.id)); // empty operators are type-agnostic if (op === "is_empty") return isMissing(rawValue(row, property.id), property.type); if (op === "is_not_empty") return !isMissing(rawValue(row, property.id), property.type); if (op === "has_files") return Array.isArray(value) && value.length > 0; if (op === "has_no_files") return !(Array.isArray(value) && value.length > 0); if (op === "isChecked") return value === true; if (op === "isUnchecked") return value !== true; // relative date windows (value is a window token, e.g. "this week") if ( property.type === "date" || property.type === "date_range" || property.type === "created_time" ) { const win = typeof token === "string" ? relativeWindow(token, ctx) : null; if (win) { const startMs = property.type === "date_range" ? rangeStartMs(rawValue(row, property.id)) : dateMs(rawValue(row, property.id)); if (startMs === null) return false; return startMs >= win.start && startMs <= win.end; } } // missing value + comparison op → false (except is_not_empty handled above) if (value === null) return false; switch (property.type) { case "number": case "created_time": case "last_edited_time": case "unique_id": { const a = value as number; if (op === "=") return a === (token as number); if (op === "!=") return a !== (token as number); if (op === ">") return a > (token as number); if (op === ">=") return a >= (token as number); if (op === "<") return a < (token as number); if (op === "<=") return a <= (token as number); if (op === "isBetween") { if (Array.isArray(token) && token.length === 2) return a >= (token[0] as number) && a <= (token[1] as number); return false; } return false; } case "date": case "date_range": { const aMs = property.type === "date_range" ? rangeStartMs(rawValue(row, property.id))! : dateMs(rawValue(row, property.id))!; let bMs: number | null = null; if (typeof token === "string" && /^\d{4}-\d{2}-\d{2}/.test(token)) { try { bMs = dateToMs(token); } catch { bMs = null; } } else if (typeof token === "number") bMs = token; if (bMs === null) return false; if (op === "=") return aMs === bMs; if (op === "!=") return aMs !== bMs; if (op === ">") return aMs > bMs; if (op === ">=" || op === "isOnOrAfter") return aMs >= bMs; if (op === "<") return aMs < bMs; if (op === "<=" || op === "isOnOrBefore") return aMs <= bMs; if (op === "isBefore") return aMs < bMs; if (op === "isAfter") return aMs > bMs; return false; } case "checkbox": { if (op === "=") return (value as boolean) === (token === true || token === "true" || token === 1); if (op === "!=") return (value as boolean) !== (token === true || token === "true" || token === 1); return false; } case "multi_select": case "person": case "relation": case "files": { const arr = value as unknown[]; const t = token; if (op === "=") return Array.isArray(t) ? t.every((x) => arr.includes(x)) : arr.includes(t); if (op === "!=") return Array.isArray(t) ? !t.every((x) => arr.includes(x)) : !arr.includes(t); if (op === "contains") return Array.isArray(t) ? t.some((x) => arr.includes(x)) : arr.includes(t); if (op === "doesNotContain") return Array.isArray(t) ? !t.some((x) => arr.includes(x)) : !arr.includes(t); return false; } default: { // text-family string comparison const a = String(value ?? ""); const b = typeof token === "string" ? token : String(token ?? ""); if (op === "=") return a === b; if (op === "!=") return a !== b; if (op === "contains") return a.toLowerCase().includes(b.toLowerCase()); if (op === "doesNotContain") return !a.toLowerCase().includes(b.toLowerCase()); if (op === "starts_with") return a.toLowerCase().startsWith(b.toLowerCase()); if (op === "ends_with") return a.toLowerCase().endsWith(b.toLowerCase()); if (op === ">") return a > b; if (op === ">=") return a >= b; if (op === "<") return a < b; if (op === "<=") return a <= b; return false; } } } export interface GroupNode { node: FilterNode; property: PropertyInfo; condition: Extract; } export function evaluateTree( row: RecordRow, tree: FilterNode, properties: Map, ctx: EvalContext, ): boolean { if ("op" in tree) { const op = tree.op; const children = tree.conditions; if (children.length === 0) return op !== "not"; if (op === "and") return children.every((c) => evaluateTree(row, c, properties, ctx)); if (op === "or") return children.some((c) => evaluateTree(row, c, properties, ctx)); if (op === "not") return !evaluateTree(row, children[0]!, properties, ctx); return false; } const property = properties.get(tree.propertyId); if (!property) return false; return evaluateCondition(row, property, tree, ctx); } /** Apply a filter tree to a set of records (the optimistic-UI + residual path). */ export function applyFilter( rows: RecordRow[], tree: FilterNode | undefined | null, properties: Map, ctx: EvalContext, ): RecordRow[] { if (!tree) return rows; return rows.filter((r) => evaluateTree(r, tree, properties, ctx)); } export function propertiesMap(list: PropertyInfo[]): Map { const m = new Map(); for (const p of list) m.set(p.id, p); return m; } export { dateBucket, DAY_MS };