mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
refactor(db): JSON columns are decoded through one declared schema per column
decodeJson/decodeJsonResult/encodeJson read and write a JSON-in-TEXT column against the schema and fallback declared for it in db/json-columns.ts, and log a row that does not decode. reservations.metadata, the OAuth scope and redirect lists, consents, API token scopes, MFA backup codes, journey entry tags and pros/cons and poll options go through it. Readers that used to throw on a broken row (a hotel update, the ICS export, the price mirror, the OAuth token and session reads, a consent, a journey entry, a poll) now take the tolerant fallback the other readers of the same column already used.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import { logWarn } from '../nest/audit/audit-log.logger';
|
||||
|
||||
/**
|
||||
* One JSON-in-TEXT column: where it lives, the shape it holds, and what a reader
|
||||
* gets when the stored text is missing or broken.
|
||||
*
|
||||
* The storage model keeps JSON in TEXT (see server/CLAUDE.md, "SQL dialect"), and
|
||||
* the service boundary turns it into a value. Declaring the column once keeps
|
||||
* every reader of it on the same answer: one schema, one fallback, one log line
|
||||
* when a row does not hold what it should, instead of a `JSON.parse` per call
|
||||
* site that throws in one place and shrugs in the next.
|
||||
*/
|
||||
export interface JsonColumn<T> {
|
||||
/** `table.column`, for the log line. */
|
||||
readonly column: string;
|
||||
readonly schema: ZodType<T>;
|
||||
/** What a reader gets for an empty column or a value that does not decode. A function, so a mutable default is fresh per call. */
|
||||
readonly fallback: () => T;
|
||||
/**
|
||||
* The stored text may be a JSON string of the JSON (an old double-encoding
|
||||
* bug). When set, one level of that is unwrapped before the schema check.
|
||||
*/
|
||||
readonly unwrapDoubleEncoded?: boolean;
|
||||
}
|
||||
|
||||
export type JsonDecodeFailure = 'empty' | 'invalid-json' | 'schema';
|
||||
|
||||
export type JsonDecodeResult<T> =
|
||||
| { ok: true; value: T; reason?: never }
|
||||
| { ok: false; value?: never; reason: JsonDecodeFailure };
|
||||
|
||||
/**
|
||||
* Decode a stored value without a fallback, for the readers that react to a
|
||||
* broken row themselves (flag it for review, skip it). A value a driver already
|
||||
* handed back parsed (an object rather than text) is checked as it is.
|
||||
*/
|
||||
export function decodeJsonResult<T>(col: JsonColumn<T>, raw: unknown): JsonDecodeResult<T> {
|
||||
if (raw === null || raw === undefined || raw === '') return { ok: false, reason: 'empty' };
|
||||
let value: unknown = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
if (col.unwrapDoubleEncoded && typeof value === 'string') value = JSON.parse(value);
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid-json' };
|
||||
}
|
||||
}
|
||||
const parsed = col.schema.safeParse(value);
|
||||
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, reason: 'schema' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a stored value, falling back to the column's default. An empty column
|
||||
* is ordinary and falls back quietly; text that is not JSON, or JSON of the
|
||||
* wrong shape, falls back too and is logged with `context` (a row id, say), so
|
||||
* broken data shows up in trek.log instead of disappearing.
|
||||
*/
|
||||
export function decodeJson<T>(col: JsonColumn<T>, raw: unknown, context?: string): T {
|
||||
const result = decodeJsonResult(col, raw);
|
||||
if (result.ok) return result.value;
|
||||
if (result.reason !== 'empty') logJsonFailure(col, result.reason, context);
|
||||
return col.fallback();
|
||||
}
|
||||
|
||||
/** Log a row whose JSON did not decode; for callers of {@link decodeJsonResult} that handle the failure themselves. */
|
||||
export function logJsonFailure(col: JsonColumn<unknown>, reason: JsonDecodeFailure, context?: string): void {
|
||||
logWarn(`[json] ${col.column}${context ? ` (${context})` : ''}: stored value ${reason === 'schema' ? 'has the wrong shape' : 'is not valid JSON'}, using the fallback`);
|
||||
}
|
||||
|
||||
/** The text to store for a value, checked against the column's shape first (a mismatch throws: it is a bug in the writer). */
|
||||
export function encodeJson<T>(col: JsonColumn<T>, value: T): string {
|
||||
return JSON.stringify(col.schema.parse(value));
|
||||
}
|
||||
Reference in New Issue
Block a user