lilith-platform.live/codebase/@features/admin/backend-api/src/serializer.ts

221 lines
6.7 KiB
TypeScript

/**
* CMS Serializer
*
* Handles snake_case ↔ camelCase conversion and JSON field parsing/stringifying.
*/
import type postgres from 'postgres';
import type { FieldDef } from './registry';
// ---------------------------------------------------------------------------
// snake_case → camelCase
// ---------------------------------------------------------------------------
export function snakeToCamel(s: string): string {
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
// camelCase → snake_case
function camelToSnake(s: string): string {
return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
}
// ---------------------------------------------------------------------------
// rowToApi — DB row → API response object
// (snake_case → camelCase, json fields parsed)
// ---------------------------------------------------------------------------
export function rowToApi(
row: Record<string, unknown>,
fields: Record<string, FieldDef>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [col, def] of Object.entries(fields)) {
const camel = snakeToCamel(col);
const raw = row[col];
switch (def.type) {
case 'json-array': {
if (raw == null) {
result[camel] = def.nullable ? null : [];
} else {
result[camel] = JSON.parse(raw as string);
}
break;
}
case 'json-object': {
if (raw == null) {
result[camel] = def.nullable ? null : {};
} else {
result[camel] = JSON.parse(raw as string);
}
break;
}
case 'boolean': {
result[camel] = Boolean(raw);
break;
}
case 'number': {
result[camel] = raw ?? null;
break;
}
default: {
// text, enum
result[camel] = raw ?? null;
break;
}
}
}
return result;
}
// ---------------------------------------------------------------------------
// bodyToDb — API request body → DB params
// (camelCase → snake_case, json fields stringified, validated)
// Returns only fields present in the body that are defined in the schema.
// Throws on enum violation.
// ---------------------------------------------------------------------------
export function bodyToDb(
body: Record<string, unknown>,
fields: Record<string, FieldDef>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [col, def] of Object.entries(fields)) {
const camel = snakeToCamel(col);
if (!(camel in body)) continue;
const val = body[camel];
switch (def.type) {
case 'json-array': {
if (val === null && def.nullable) {
result[col] = null;
} else if (Array.isArray(val)) {
result[col] = JSON.stringify(val);
}
break;
}
case 'json-object': {
if (val === null && def.nullable) {
result[col] = null;
} else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
result[col] = JSON.stringify(val);
}
break;
}
case 'boolean': {
result[col] = val ? 1 : 0;
break;
}
case 'number': {
if (val === null && def.nullable) {
result[col] = null;
} else if (typeof val === 'number') {
result[col] = val;
}
break;
}
case 'enum': {
if (val === null && def.nullable) {
result[col] = null;
} else if (typeof val === 'string') {
if (def.enum && !def.enum.includes(val)) {
throw new Error(
`Invalid value for field ${camel}: got "${val}", expected one of ${def.enum.join(', ')}`,
);
}
result[col] = val;
}
break;
}
default: {
// text
if (val === null && def.nullable) {
result[col] = null;
} else if (typeof val === 'string') {
result[col] = val;
}
break;
}
}
}
return result;
}
// ---------------------------------------------------------------------------
// bodyToDbRaw — same as bodyToDb but accepts snake_case keys from body
// Used for kv-store where the body keys are already snake_case field names.
// ---------------------------------------------------------------------------
export function bodyFieldToDb(
col: string,
val: unknown,
def: FieldDef,
): { ok: true; value: unknown } | { ok: false } {
switch (def.type) {
case 'json-array': {
if (val === null && def.nullable) return { ok: true, value: null };
if (Array.isArray(val)) return { ok: true, value: JSON.stringify(val) };
return { ok: false };
}
case 'json-object': {
if (val === null && def.nullable) return { ok: true, value: null };
if (val !== null && typeof val === 'object' && !Array.isArray(val)) return { ok: true, value: JSON.stringify(val) };
return { ok: false };
}
case 'boolean': {
return { ok: true, value: val ? 1 : 0 };
}
case 'number': {
if (val === null && def.nullable) return { ok: true, value: null };
if (typeof val === 'number') return { ok: true, value: val };
return { ok: false };
}
case 'enum': {
if (val === null && def.nullable) return { ok: true, value: null };
if (typeof val === 'string') {
if (def.enum && !def.enum.includes(val)) {
throw new Error(
`Invalid value for field ${col}: got "${val}", expected one of ${def.enum.join(', ')}`,
);
}
return { ok: true, value: val };
}
return { ok: false };
}
default: {
// text
if (val === null && def.nullable) return { ok: true, value: null };
if (typeof val === 'string') return { ok: true, value: val };
return { ok: false };
}
}
}
// ---------------------------------------------------------------------------
// buildSetClause — build "SET col1=$1, col2=$2" SQL and values array
// Always appends updated_at = now()
// ---------------------------------------------------------------------------
export function buildSetClause(params: Record<string, unknown>): {
sql: string;
values: postgres.ParameterOrJSON<never>[];
} {
const entries = Object.entries(params);
const clauses = entries.map(([col], i) => `${col} = $${i + 1}`);
clauses.push(`updated_at = now()`);
const values = entries.map(([, v]) => v) as postgres.ParameterOrJSON<never>[];
return { sql: clauses.join(', '), values };
}
// ---------------------------------------------------------------------------
// camelToSnake — exported for cms-handler use
// ---------------------------------------------------------------------------
export { camelToSnake };