You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
268 lines
6.5 KiB
268 lines
6.5 KiB
import type { MetaAnnotations, Schema, SchemaEffect, SchemaKind, SchemaWrapper } from './types';
|
|
|
|
export interface SerializedSchema {
|
|
kind: SchemaKind;
|
|
wrappers: SchemaWrapper[];
|
|
effects: SchemaEffect[];
|
|
async: boolean;
|
|
meta: Record<string, unknown>;
|
|
shape?: SerializedShape;
|
|
}
|
|
|
|
export type SerializedShape =
|
|
| { kind: 'truncated'; inner: string }
|
|
| { kind: 'object'; unknownKeys: string; fields: Record<string, SerializedSchema> }
|
|
| { kind: 'array'; item: SerializedSchema }
|
|
| { kind: 'tuple'; members: SerializedSchema[] }
|
|
| { kind: 'record'; value: SerializedSchema }
|
|
| { kind: 'union'; members: SerializedSchema[] }
|
|
| { kind: 'discriminated'; key: string; members: SerializedSchema[] }
|
|
| { kind: 'optional' | 'nullable' | 'defaulted'; inner: SerializedSchema }
|
|
| { kind: 'enum'; values: Array<string | number> }
|
|
| { kind: 'literal'; value: unknown };
|
|
|
|
function serializeMetaValue(value: unknown, seen: WeakSet<object>, depth: number): unknown {
|
|
if (
|
|
value === null ||
|
|
typeof value === 'string' ||
|
|
typeof value === 'number' ||
|
|
typeof value === 'boolean'
|
|
) {
|
|
return value;
|
|
}
|
|
|
|
if (value === undefined) return undefined;
|
|
|
|
if (typeof value === 'function') {
|
|
return `[Function ${(value as Function).name || 'anonymous'}]`;
|
|
}
|
|
|
|
if (typeof value === 'symbol') {
|
|
return String(value);
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
if (depth <= 0) return '[Array]';
|
|
return value.map((entry) => serializeMetaValue(entry, seen, depth - 1));
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
if (seen.has(value)) return '[Circular]';
|
|
if (depth <= 0) return '[Object]';
|
|
|
|
seen.add(value);
|
|
const out = Object.fromEntries(
|
|
Object.entries(value).map(([key, entry]) => [key, serializeMetaValue(entry, seen, depth - 1)])
|
|
);
|
|
seen.delete(value);
|
|
return out;
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
function serializeMeta(meta: MetaAnnotations): Record<string, unknown> {
|
|
const seen = new WeakSet<object>();
|
|
return Object.fromEntries(
|
|
Object.entries(meta).map(([key, value]) => [key, serializeMetaValue(value, seen, 2)])
|
|
);
|
|
}
|
|
|
|
function serializeSchemaInner(
|
|
schema: Schema<unknown, unknown>,
|
|
maxDepth: number,
|
|
depth: number,
|
|
seen: WeakSet<object>
|
|
): SerializedSchema {
|
|
const sium = schema['~sium'];
|
|
const base: SerializedSchema = {
|
|
kind: sium.kind,
|
|
wrappers: [...sium.wrappers],
|
|
effects: [...sium.effects],
|
|
async: sium.async,
|
|
meta: serializeMeta(sium.meta)
|
|
};
|
|
|
|
if (!sium.shape) return base;
|
|
if (depth >= maxDepth) {
|
|
return {
|
|
...base,
|
|
shape: { kind: 'truncated', inner: sium.shape.kind }
|
|
};
|
|
}
|
|
|
|
if (seen.has(schema as object)) {
|
|
return {
|
|
...base,
|
|
shape: { kind: 'truncated', inner: 'cycle' }
|
|
};
|
|
}
|
|
|
|
seen.add(schema as object);
|
|
|
|
try {
|
|
switch (sium.shape.kind) {
|
|
case 'object':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'object',
|
|
unknownKeys: sium.shape.unknownKeys,
|
|
fields: Object.fromEntries(
|
|
Object.entries(sium.shape.fields).map(([key, child]) => [
|
|
key,
|
|
serializeSchemaInner(child, maxDepth, depth + 1, seen)
|
|
])
|
|
)
|
|
}
|
|
};
|
|
case 'array':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'array',
|
|
item: serializeSchemaInner(sium.shape.item, maxDepth, depth + 1, seen)
|
|
}
|
|
};
|
|
case 'tuple':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'tuple',
|
|
members: sium.shape.members.map((member) =>
|
|
serializeSchemaInner(member, maxDepth, depth + 1, seen)
|
|
)
|
|
}
|
|
};
|
|
case 'record':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'record',
|
|
value: serializeSchemaInner(sium.shape.value, maxDepth, depth + 1, seen)
|
|
}
|
|
};
|
|
case 'union':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'union',
|
|
members: sium.shape.members.map((member) =>
|
|
serializeSchemaInner(member, maxDepth, depth + 1, seen)
|
|
)
|
|
}
|
|
};
|
|
case 'discriminated':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'discriminated',
|
|
key: sium.shape.key,
|
|
members: sium.shape.members.map((member) =>
|
|
serializeSchemaInner(member, maxDepth, depth + 1, seen)
|
|
)
|
|
}
|
|
};
|
|
case 'optional':
|
|
case 'nullable':
|
|
case 'defaulted':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: sium.shape.kind,
|
|
inner: serializeSchemaInner(sium.shape.inner, maxDepth, depth + 1, seen)
|
|
}
|
|
};
|
|
case 'enum':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'enum',
|
|
values: [...sium.shape.values]
|
|
}
|
|
};
|
|
case 'literal':
|
|
return {
|
|
...base,
|
|
shape: {
|
|
kind: 'literal',
|
|
value: sium.shape.value
|
|
}
|
|
};
|
|
}
|
|
} finally {
|
|
seen.delete(schema as object);
|
|
}
|
|
}
|
|
|
|
/** Serialize a schema into a JSON-friendly tree for debug panels and tooling. */
|
|
export function serializeSchema(schema: Schema<unknown, unknown>, maxDepth = 6): SerializedSchema {
|
|
return serializeSchemaInner(schema, maxDepth, 0, new WeakSet<object>());
|
|
}
|
|
|
|
/** Count field leaves in a schema tree. Objects recurse; arrays/unions count as one slot. */
|
|
export function countLeafFields(schema: Schema<unknown, unknown>): number {
|
|
const shape = schema['~sium'].shape;
|
|
if (!shape) return 1;
|
|
|
|
switch (shape.kind) {
|
|
case 'object':
|
|
return Object.values(shape.fields).reduce((acc, child) => acc + countLeafFields(child), 0);
|
|
case 'optional':
|
|
case 'nullable':
|
|
case 'defaulted':
|
|
return countLeafFields(shape.inner);
|
|
case 'tuple':
|
|
return shape.members.reduce((acc, child) => acc + countLeafFields(child), 0);
|
|
case 'array':
|
|
case 'record':
|
|
case 'union':
|
|
case 'discriminated':
|
|
case 'enum':
|
|
case 'literal':
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Visit a schema tree in pre-order. Object fields, array items and wrappers recurse automatically.
|
|
* Union/discriminated members are intentionally not recursed in v1.
|
|
*/
|
|
export function walkSchema(
|
|
schema: Schema<unknown, unknown>,
|
|
visit: (schema: Schema<unknown, unknown>, path: ReadonlyArray<string | number>) => void,
|
|
basePath: ReadonlyArray<string | number> = []
|
|
): void {
|
|
visit(schema, basePath);
|
|
|
|
const shape = schema['~sium'].shape;
|
|
if (!shape) return;
|
|
|
|
switch (shape.kind) {
|
|
case 'object':
|
|
for (const [key, child] of Object.entries(shape.fields)) {
|
|
walkSchema(child, visit, [...basePath, key]);
|
|
}
|
|
return;
|
|
case 'array':
|
|
walkSchema(shape.item, visit, [...basePath, 0]);
|
|
return;
|
|
case 'tuple':
|
|
shape.members.forEach((member, index) => {
|
|
walkSchema(member, visit, [...basePath, index]);
|
|
});
|
|
return;
|
|
case 'optional':
|
|
case 'nullable':
|
|
case 'defaulted':
|
|
walkSchema(shape.inner, visit, basePath);
|
|
return;
|
|
case 'record':
|
|
case 'union':
|
|
case 'discriminated':
|
|
case 'enum':
|
|
case 'literal':
|
|
return;
|
|
}
|
|
}
|