221 lines
6.7 KiB
TypeScript
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 };
|