/** * @factory/database-engine/values — the value codec registry (TASK-0064 R1). * * One codec per PropertyType (24 types in the prisma enum — recorded conflict: * spec header says 22). Contract per codec: `fromWire`/`toWire` round-trip, * `display` (locale-aware), `compare` (sort order). Select-family codecs * validate against `config.options` ids; orphan option ids read as EMPTY * (never an error). Tokens ("today"/"me") are rejected at value level — they * are filter tokens (filter/tokens.ts). */ import type { PropertyType } from "@factory/shared/dto"; import type { ValueCodec, PropertyValue, DateRangeValue, FileValue } from "./types.js"; import { normalizeDate, dateToMs, dateBucket } from "./dates.js"; const optionId = (v: unknown): v is string => typeof v === "string" && v.length > 0; function optionIds(config: Record): Set { const opts = Array.isArray(config["options"]) ? (config["options"] as Array>) : []; return new Set(opts.filter((o) => typeof o["id"] === "string").map((o) => o["id"] as string)); } function optionName(config: Record, id: string): string { const opts = Array.isArray(config["options"]) ? (config["options"] as Array>) : []; const hit = opts.find((o) => o["id"] === id); return typeof hit?.["name"] === "string" ? hit["name"] : id; } function optionIndex(config: Record, id: string): number { const opts = Array.isArray(config["options"]) ? (config["options"] as Array>) : []; return opts.findIndex((o) => o["id"] === id); } export class ValueCodecError extends Error { readonly type: PropertyType; constructor(type: PropertyType, message: string) { super(message); this.type = type; this.name = "ValueCodecError"; } } const fail = (type: PropertyType, msg: string): never => { throw new ValueCodecError(type, msg); }; /** Display config for NUMBER (format + precision). */ interface NumberConfig { format?: "number" | "integer" | "percent" | "currency"; precision?: number; currency?: string; } const numFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }); const intFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); function displayNumber(v: number, config: Record, locale: string): string { const cfg = config as NumberConfig; const format = cfg.format ?? "number"; const precision = Math.min(Math.max(cfg.precision ?? (format === "integer" ? 0 : 8), 0), 8); if (format === "integer") return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(v); if (format === "percent") return `${numFmt.format(v * 100)} %`; if (format === "currency") return new Intl.NumberFormat(locale, { style: "currency", currency: cfg.currency ?? "USD", maximumFractionDigits: precision, }).format(v); return new Intl.NumberFormat(locale, { maximumFractionDigits: precision }).format(v); } const ISO_DAY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/; const isDateStr = (v: unknown): v is string => typeof v === "string" && ISO_DAY.test(v); const isDayStr = (v: unknown): v is string => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v); const codecs: Record = { // --- text family ----------------------------------------------------------------- title: { type: "title", fromWire: (j) => j === null || j === undefined ? "" : typeof j === "string" ? j : fail("title", "title value must be a string"), toWire: (v) => (typeof v === "string" ? v : ""), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, text: { type: "text", fromWire: (j) => j === null || j === undefined ? "" : typeof j === "string" ? j : fail("text", "text value must be a string"), toWire: (v) => (typeof v === "string" ? v : ""), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, rich_text: { type: "rich_text", // RICH_TEXT value = markdown source string (recorded: shared codec with TEXT). fromWire: (j) => j === null || j === undefined ? "" : typeof j === "string" ? j : fail("rich_text", "rich_text value must be a markdown string"), toWire: (v) => (typeof v === "string" ? v : ""), display: (v) => (typeof v === "string" ? v.replace(/[*_`>#[\]()!-]/g, "").trim() : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, // --- number ---------------------------------------------------------------------- number: { type: "number", fromWire: (j) => j === null || j === undefined ? null : typeof j === "number" && Number.isFinite(j) ? j : typeof j === "string" && j.trim() !== "" && Number.isFinite(Number(j)) ? Number(j) : fail("number", "number value must be a finite number"), toWire: (v) => (typeof v === "number" ? v : null), display: (v, config, locale = "en-US") => typeof v === "number" ? displayNumber(v, config, locale) : "", compare: (a, b) => { const x = typeof a === "number" ? a : NaN; const y = typeof b === "number" ? b : NaN; const xN = Number.isNaN(x), yN = Number.isNaN(y); if (xN && yN) return 0; if (xN) return -1; if (yN) return 1; return x - y; }, }, // --- select family ----------------------------------------------------------------- select: { type: "select", fromWire: (j) => (j === null || j === undefined || !optionId(j) ? null : j), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v, config) => { if (typeof v !== "string" || !optionId(v)) return ""; if (!optionIds(config).has(v)) return ""; // orphan option → empty return optionName(config, v); }, compare: (a, b, config) => { const ia = typeof a === "string" ? optionIndex(config, a) : -1; const ib = typeof b === "string" ? optionIndex(config, b) : -1; // missing/unknown option sorts last const xa = ia < 0 ? Number.MAX_SAFE_INTEGER : ia; const xb = ib < 0 ? Number.MAX_SAFE_INTEGER : ib; return xa - xb; }, }, status: { type: "status", fromWire: (j) => (j === null || j === undefined || !optionId(j) ? null : j), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v, config) => { if (typeof v !== "string" || !optionId(v)) return ""; if (!optionIds(config).has(v)) return ""; return optionName(config, v); }, compare: (a, b, config) => { const ia = typeof a === "string" ? optionIndex(config, a) : -1; const ib = typeof b === "string" ? optionIndex(config, b) : -1; const xa = ia < 0 ? Number.MAX_SAFE_INTEGER : ia; const xb = ib < 0 ? Number.MAX_SAFE_INTEGER : ib; return xa - xb; }, }, multi_select: { type: "multi_select", fromWire: (j) => { if (j === null || j === undefined) return []; if (Array.isArray(j)) return j.filter((x) => optionId(x)); if (optionId(j)) return [j]; fail("multi_select", "multi_select value must be an array of option ids"); }, toWire: (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []), display: (v, config) => Array.isArray(v) ? v .filter((x): x is string => typeof x === "string" && optionIds(config).has(x)) .map((x) => optionName(config, x)) .join(", ") : "", compare: (a, b, config) => { const rank = (v: PropertyValue) => Array.isArray(v) ? v .filter((x) => typeof x === "string") .map((x) => { const i = optionIndex(config, x); return i < 0 ? Number.MAX_SAFE_INTEGER : i; }) .sort((x, y) => x - y) : []; const ra = rank(a), rb = rank(b); for (let i = 0; i < Math.max(ra.length, rb.length); i++) { const x = ra[i] ?? Number.MAX_SAFE_INTEGER + 1; const y = rb[i] ?? Number.MAX_SAFE_INTEGER + 1; if (x !== y) return x - y; } return 0; }, }, // --- dates --------------------------------------------------------------------- date: { type: "date", fromWire: (j) => j === null || j === undefined ? null : isDayStr(j) ? j : fail("date", "date value must be ISO YYYY-MM-DD"), toWire: (v) => (typeof v === "string" && isDayStr(v) ? v : null), display: (v, config, locale = "en-US") => { if (typeof v !== "string" || !isDayStr(v)) return ""; const g = typeof config["groupGranularity"] === "string" ? config["groupGranularity"] : undefined; return g === "month" ? dateBucket(v, "month") : g === "week" ? dateBucket(v, "week") : new Date(v + "T00:00:00Z").toLocaleDateString(locale, { timeZone: "UTC" }); }, compare: (a, b) => { const x = typeof a === "string" ? dateToMs(a) : NaN; const y = typeof b === "string" ? dateToMs(b) : NaN; const xN = Number.isNaN(x), yN = Number.isNaN(y); if (xN && yN) return 0; if (xN) return -1; if (yN) return 1; return x - y; }, }, date_range: { type: "date_range", fromWire: (j) => { if (j === null || j === undefined) return null; if (typeof j === "object" && !Array.isArray(j)) { const o = j as Record; const s = o["start"]; const e = o["end"]; if (isDayStr(s)) { const end = isDayStr(e) ? e : s; if (dateToMs(end) < dateToMs(s)) return { start: end, end: s }; return { start: s, end }; } } if (isDayStr(j)) return { start: j, end: j }; fail("date_range", "date_range value must be {start, end} or a single ISO day"); }, toWire: (v) => { if (v && typeof v === "object" && !Array.isArray(v)) { const o = v as Record; if (typeof o["start"] === "string" && typeof o["end"] === "string") return o; } return null; }, display: (v, _config, locale = "en-US") => { const vr = v as DateRangeValue | null; if (!vr || typeof vr !== "object") return ""; const d = (s: string) => new Date(s + "T00:00:00Z").toLocaleDateString(locale, { timeZone: "UTC", month: "short", day: "numeric", }); return vr.start === vr.end ? d(vr.start) : `${d(vr.start)} – ${d(vr.end)}`; }, compare: (a, b) => { const s = (v: PropertyValue) => v && typeof v === "object" && typeof (v as DateRangeValue).start === "string" ? dateToMs((v as DateRangeValue).start) : NaN; const x = s(a), y = s(b); if (Number.isNaN(x) && Number.isNaN(y)) return 0; if (Number.isNaN(x)) return -1; if (Number.isNaN(y)) return 1; return x - y; }, }, // --- people / contact ----------------------------------------------------------- person: { type: "person", fromWire: (j) => { if (j === null || j === undefined) return []; if (Array.isArray(j)) return j.filter((x) => typeof x === "string" && x.length > 0); if (typeof j === "string" && j.length > 0) return [j]; fail("person", "person value must be an array of user ids"); }, toWire: (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []), display: (v, config) => Array.isArray(v) ? v .map((x) => { const users = Array.isArray(config["users"]) ? (config["users"] as Array>) : []; const hit = users.find((u) => u["id"] === x); return typeof hit?.["name"] === "string" ? hit["name"] : typeof x === "string" ? x : ""; }) .filter(Boolean) .join(", ") : "", compare: (a, b) => { const ra = Array.isArray(a) ? a.filter((x) => typeof x === "string") : []; const rb = Array.isArray(b) ? b.filter((x) => typeof x === "string") : []; for (let i = 0; i < Math.max(ra.length, rb.length); i++) { const x = ra[i] ?? "\uffff"; const y = rb[i] ?? "\uffff"; if (x !== y) return String(x).localeCompare(String(y)); } return 0; }, }, email: { type: "email", fromWire: (j) => j === null || j === undefined ? null : typeof j === "string" ? j : fail("email", "email must be a string"), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, phone: { type: "phone", fromWire: (j) => j === null || j === undefined ? null : typeof j === "string" ? j : fail("phone", "phone must be a string"), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, url: { type: "url", fromWire: (j) => j === null || j === undefined ? null : typeof j === "string" ? j : fail("url", "url must be a string"), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, // --- checkbox -------------------------------------------------------------------- checkbox: { type: "checkbox", // missing EAV row = false (TASK-0067 R1). fromWire: (j) => j === null || j === undefined ? false : typeof j === "boolean" ? j : fail("checkbox", "checkbox must be a boolean"), toWire: (v) => (typeof v === "boolean" ? v : false), display: (v) => (v === true ? "✓" : ""), compare: (a, b) => Number(a === true) - Number(b === true), }, // --- files ------------------------------------------------------------------------ files: { type: "files", fromWire: (j) => { if (j === null || j === undefined) return []; // Positive guard (not `if (!Array.isArray(j)) fail(…)`): TS 5.6 doesn't // narrow `unknown` through a `never` statement in the negative branch, // so the array path must be the guarded block. if (Array.isArray(j)) { return j .filter( (x): x is FileValue => x !== null && typeof x === "object" && typeof (x as Record)["id"] === "string", ) .map( (x): FileValue => ({ id: x.id, name: typeof x.name === "string" ? x.name : x.id, ...(x.url !== undefined ? { url: x.url } : {}), ...(x.size !== undefined ? { size: x.size } : {}), }), ); } return fail("files", "files value must be an array"); }, toWire: (v) => (Array.isArray(v) ? v : []), // files values are FileValue[] (fromWire guarantees the shape); the array // constituents of PropertyValue narrow to the generic `PropertyValue[] | // FileValue[]`, so downcast to the codec's own type. display: (v) => (Array.isArray(v) ? (v as FileValue[]).map((f) => f.name).join(", ") : ""), compare: (a, b) => (Array.isArray(a) ? a.length : 0) - (Array.isArray(b) ? b.length : 0), }, // --- auto (read-only; fromWire accepts, API ignores client values) ----------------- created_time: { type: "created_time", fromWire: (j) => j === null || j === undefined ? null : typeof j === "number" ? j : typeof j === "string" && !Number.isNaN(Date.parse(j)) ? Date.parse(j) : fail("created_time", "created_time must be epoch ms"), toWire: (v) => (typeof v === "number" ? v : null), display: (v, _c, locale = "en-US") => typeof v === "number" ? new Date(v).toLocaleDateString(locale) : "", compare: (a, b) => (typeof a === "number" ? a : NaN) - (typeof b === "number" ? b : NaN) || 0, }, created_by: { type: "created_by", fromWire: (j) => j === null || j === undefined ? null : typeof j === "string" ? j : fail("created_by", "created_by must be a user id"), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, last_edited_time: { type: "last_edited_time", fromWire: (j) => j === null || j === undefined ? null : typeof j === "number" ? j : typeof j === "string" && !Number.isNaN(Date.parse(j)) ? Date.parse(j) : fail("last_edited_time", "last_edited_time must be epoch ms"), toWire: (v) => (typeof v === "number" ? v : null), display: (v, _c, locale = "en-US") => typeof v === "number" ? new Date(v).toLocaleDateString(locale) : "", compare: (a, b) => (typeof a === "number" ? a : NaN) - (typeof b === "number" ? b : NaN) || 0, }, last_edited_by: { type: "last_edited_by", fromWire: (j) => j === null || j === undefined ? null : typeof j === "string" ? j : fail("last_edited_by", "last_edited_by must be a user id"), toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null), display: (v) => (typeof v === "string" ? v : ""), compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")), }, // --- computed (Phase 6) — value is the materialized result ----------------------- relation: { type: "relation", // value = array of linked record ids (the join-table read). fromWire: (j) => j === null || j === undefined ? [] : Array.isArray(j) ? j.filter((x) => typeof x === "string") : fail("relation", "relation value must be an array of record ids"), toWire: (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []), display: (v) => (Array.isArray(v) ? String(v.length) : ""), compare: (a, b) => (Array.isArray(a) ? a.length : 0) - (Array.isArray(b) ? b.length : 0), }, rollup: { type: "rollup", fromWire: (j) => j === null || j === undefined ? null : typeof j === "number" || typeof j === "string" || Array.isArray(j) ? j : fail("rollup", "rollup value must be number|string|array"), toWire: (v) => v, display: (v, _c, locale = "en-US") => Array.isArray(v) ? v.length.toString() : typeof v === "number" ? new Intl.NumberFormat(locale).format(v) : typeof v === "string" ? v : "", compare: (a, b) => { if (Array.isArray(a) && Array.isArray(b)) return a.length - b.length; if (typeof a === "number" && typeof b === "number") return a - b; return String(a ?? "").localeCompare(String(b ?? "")); }, }, formula: { type: "formula", fromWire: (j) => j === null || j === undefined || typeof j === "number" || typeof j === "string" || typeof j === "boolean" ? j : fail("formula", "formula value must be a scalar"), toWire: (v) => v, display: (v, _c, locale = "en-US") => typeof v === "number" ? new Intl.NumberFormat(locale).format(v) : typeof v === "string" ? v : v === true ? "true" : v === false ? "false" : "", compare: (a, b) => { if (typeof a === "number" && typeof b === "number") return a - b; return String(a ?? "").localeCompare(String(b ?? "")); }, }, button: { type: "button", // Buttons have no value (action-only). Stored value is always null. fromWire: () => null, toWire: () => null, display: () => "", compare: () => 0, }, // --- unique id --------------------------------------------------------------------- unique_id: { type: "unique_id", fromWire: (j) => j === null || j === undefined ? null : typeof j === "number" ? j : fail("unique_id", "unique_id must be a number"), toWire: (v) => (typeof v === "number" ? v : null), display: (v, config) => { if (typeof v !== "number") return ""; const prefix = typeof config["prefix"] === "string" ? config["prefix"] : ""; const pad = typeof config["padding"] === "number" ? config["padding"] : 0; const digits = Math.min(Math.max(pad, 0), 12); return `${prefix}${String(v).padStart(digits, "0")}`; }, compare: (a, b) => (typeof a === "number" ? a : NaN) - (typeof b === "number" ? b : NaN) || 0, }, }; export const CODECS = codecs; /** Get the codec for a property type. */ export function codec(type: PropertyType): ValueCodec { return codecs[type] ?? fail("title", `unknown property type: ${type}`); } /** All property types (24 — the prisma enum; spec says 22, recorded conflict). */ export const PROPERTY_TYPES: readonly PropertyType[] = Object.keys(codecs) as PropertyType[]; /** * Type-change conversion matrix (TASK-0064 R2). `canConvert(from, to)` — * exact allowed set: TEXT↔RICH_TEXT (identity), NUMBER→TEXT (decimal string), * everything else rejected. Computed types (relation/rollup/formula/button) * are never convertible. */ const CONVERTIBLE: ReadonlySet = new Set([ "text:rich_text", "rich_text:text", "number:text", ]); export function canConvert(from: PropertyType, to: PropertyType): boolean { return CONVERTIBLE.has(`${from}:${to}`); } /** Apply a conversion to a value (returns the converted value or null). */ export function convertValue( from: PropertyType, to: PropertyType, value: PropertyValue, ): PropertyValue | null { if (!canConvert(from, to)) return null; if (from === "number" && to === "text") return typeof value === "number" ? String(value) : ""; return value; // text↔rich_text is identity } export { normalizeDate, dateToMs, dateBucket };