Skip to content
Merged
12 changes: 6 additions & 6 deletions packages/runner/src/link-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,19 @@ export function resolveLink(

if (nextLink) {
const remainingPath = link.path.slice(lastValid.length);
let linkSchema = nextLink.schema;
if (linkSchema !== undefined && remainingPath.length > 0) {
let { schema, ...restLink } = nextLink;
if (schema !== undefined && remainingPath.length > 0) {
const cfc = new ContextualFlowControl();
linkSchema = cfc.getSchemaAtPath(
linkSchema,
schema = cfc.getSchemaAtPath(
schema,
remainingPath,
nextLink.rootSchema,
);
}
nextLink = {
...nextLink,
...restLink,
path: [...nextLink.path, ...remainingPath],
...(linkSchema ? { schema: linkSchema } : {}),
...(schema !== undefined && { schema }),
};
}
}
Expand Down
65 changes: 65 additions & 0 deletions packages/runner/test/link-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,71 @@ describe("link-resolution", () => {
const resolved = resolveLink(tx, parsedLink);
expect(resolved.schema).toEqual(schema2);
});

it("should remove schema when remaining path field is not in schema", () => {
// This tests the corner case where:
// 1. An intermediate link has a schema
// 2. There's a remaining path to follow after the link
// 3. The field in the remaining path is NOT defined in the schema
// In this case, schema should become undefined (removed) rather than
// keeping the parent schema
//
// We use an array schema and access .length - the array schema defines
// items but not a "length" property, so getSchemaAtPath returns undefined.
const schema = {
type: "array",
items: { type: "number" },
} as const satisfies JSONSchema;

const targetCell = runtime.getCell<any>(
space,
"undefined-schema-field-target",
schema,
tx,
);
targetCell.set([1, 2, 3]);

const sourceCell = runtime.getCell<any>(
space,
"undefined-schema-field-source",
undefined,
tx,
);

// Create a link at "data" pointing to targetCell with schema included
sourceCell.setRaw({
data: targetCell.getAsLink({ includeSchema: true }),
});
tx.commit();
tx = runtime.edit();

// First verify the link to targetCell has the schema
const dataLink = parseLink(sourceCell.key("data"), sourceCell)!;
const dataResolved = resolveLink(tx, dataLink);
expect(dataResolved.schema).toEqual(schema);

// Now resolve a path that goes through the link to "length".
// The resolver will:
// 1. Try to resolve sourceCell/data/length
// 2. Find that "length" doesn't exist (NotFoundError)
// 3. Find the link at "data" (lastValid = ["data"])
// 4. Calculate remainingPath = ["length"]
// 5. Call getSchemaAtPath(schema, ["length"]) which returns undefined
// because array schema has no "length" property defined
// 6. The fix ensures we DON'T spread the original schema back in
const linkThroughToLength = {
...sourceCell.getAsNormalizedFullLink(),
path: ["data", "length"],
schema: undefined, // Start without schema to test inheritance
};
const resolved = resolveLink(tx, linkThroughToLength);

// The resolved link should NOT have a schema since "length" is not
// defined in the array schema
expect(resolved.schema).toBeUndefined();
// Verify we can still read the value
expect(tx.readValueOrThrow(resolved)).toBe(3);
});
});

describe("overwrite field removal", () => {
Expand Down
26 changes: 25 additions & 1 deletion packages/schema-generator/src/formatters/primitive-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export class PrimitiveFormatter implements TypeFormatter {
(flags & ts.TypeFlags.BooleanLiteral) !== 0 ||
(flags & ts.TypeFlags.StringLiteral) !== 0 ||
(flags & ts.TypeFlags.NumberLiteral) !== 0 ||
(flags & ts.TypeFlags.BigInt) !== 0 ||
(flags & ts.TypeFlags.BigIntLiteral) !== 0 ||
(flags & ts.TypeFlags.Null) !== 0 ||
(flags & ts.TypeFlags.Undefined) !== 0 ||
(flags & ts.TypeFlags.Void) !== 0 ||
Expand All @@ -27,28 +29,47 @@ export class PrimitiveFormatter implements TypeFormatter {
(flags & ts.TypeFlags.Any) !== 0;
}

formatType(type: ts.Type, _context: GenerationContext): SchemaDefinition {
formatType(type: ts.Type, context: GenerationContext): SchemaDefinition {
const flags = type.flags;

// Handle literal types first (more specific)
// If widenLiterals flag is set, skip enum generation and return base type
if (flags & ts.TypeFlags.StringLiteral) {
if (context.widenLiterals) {
return { type: "string" };
}
return {
type: "string",
enum: [(type as ts.StringLiteralType).value],
};
}
if (flags & ts.TypeFlags.NumberLiteral) {
if (context.widenLiterals) {
return { type: "number" };
}
return {
type: "number",
enum: [(type as ts.NumberLiteralType).value],
};
}
if (flags & ts.TypeFlags.BooleanLiteral) {
if (context.widenLiterals) {
return { type: "boolean" };
}
return {
type: "boolean",
enum: [(type as TypeWithInternals).intrinsicName === "true"],
};
}
if (flags & ts.TypeFlags.BigIntLiteral) {
if (context.widenLiterals) {
return { type: "integer" };
}
return {
type: "integer",
enum: [Number((type as ts.BigIntLiteralType).value.base10Value)],
};
}

// Handle general primitive types
if (flags & ts.TypeFlags.String) {
Expand All @@ -60,6 +81,9 @@ export class PrimitiveFormatter implements TypeFormatter {
if (flags & ts.TypeFlags.Boolean) {
return { type: "boolean" };
}
if (flags & ts.TypeFlags.BigInt) {
return { type: "integer" };
}
if (flags & ts.TypeFlags.Null) {
return { type: "null" };
}
Expand Down
172 changes: 170 additions & 2 deletions packages/schema-generator/src/formatters/union-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getNativeTypeSchema,
TypeWithInternals,
} from "../type-utils.ts";
import { isRecord } from "@commontools/utils/types";

export class UnionFormatter implements TypeFormatter {
constructor(private schemaGenerator: SchemaGenerator) {}
Expand Down Expand Up @@ -95,14 +96,181 @@ export class UnionFormatter implements TypeFormatter {
}

// Fallback: anyOf of member schemas (excluding null/undefined handled above)
const anyOf = nonNull.map((m) => generate(m));
let anyOf = nonNull.map((m) => generate(m));
if (hasNull) anyOf.push({ type: "null" });

// If only one schema remains after filtering, return it directly without anyOf wrapper
// When widenLiterals is true, try to merge structurally identical schemas
// that only differ in literal enum values
if (context.widenLiterals && anyOf.length > 1) {
anyOf = this.mergeIdenticalSchemas(anyOf);
}

// If only one schema remains after filtering/merging, return it directly without anyOf wrapper
if (anyOf.length === 1) {
return anyOf[0]!;
}

return { anyOf };
}

/**
* Merge schemas that are structurally identical except for literal enum values.
* Used when widenLiterals is true to collapse unions like
* {x: {enum: [10]}} | {x: {enum: [20]}} into {x: {type: "number"}}
*/
private mergeIdenticalSchemas(
schemas: SchemaDefinition[],
): SchemaDefinition[] {
if (schemas.length <= 1) return schemas;

// Group schemas by their structure (ignoring enum values)
const groups = new Map<string, SchemaDefinition[]>();

for (const schema of schemas) {
const normalized = this.normalizeSchemaForComparison(schema);
const key = JSON.stringify(normalized);
const group = groups.get(key) ?? [];
group.push(schema);
groups.set(key, group);
}

// For each group with multiple schemas, try to merge them
const result: SchemaDefinition[] = [];
for (const group of groups.values()) {
if (group.length === 1) {
result.push(group[0]!);
} else {
// Multiple schemas with same structure - merge them
result.push(this.mergeSchemaGroup(group));
}
}

return result;
}

/**
* Normalize a schema for structural comparison by removing enum values
* and converting them to base types
*/
private normalizeSchemaForComparison(
schema: SchemaDefinition,
): Record<string, unknown> {
if (typeof schema === "boolean") return { _bool: schema };

const result: Record<string, unknown> = {};

// Convert enum to base type for comparison
if ("enum" in schema && schema.enum) {
const firstValue = schema.enum[0];
if (typeof firstValue === "string") {
result.type = "string";
} else if (typeof firstValue === "number") {
result.type = "number";
} else if (typeof firstValue === "boolean") {
result.type = "boolean";
}
} else if ("type" in schema) {
result.type = schema.type;
}

// Recursively normalize properties
if ("properties" in schema && isRecord(schema.properties)) {
const props: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema.properties)) {
props[key] = this.normalizeSchemaForComparison(
value as SchemaDefinition,
);
}
result.properties = props;
}

// Recursively normalize items
if ("items" in schema && schema.items) {
result.items = this.normalizeSchemaForComparison(
schema.items as SchemaDefinition,
);
}

// Copy other structural fields
if ("required" in schema) result.required = schema.required;
if ("additionalProperties" in schema) {
result.additionalProperties = schema.additionalProperties;
}

return result;
}

/**
* Merge a group of structurally identical schemas by widening their enums
*/
private mergeSchemaGroup(schemas: SchemaDefinition[]): SchemaDefinition {
if (schemas.length === 0) {
throw new Error("Cannot merge empty schema group");
}

const first = schemas[0]!;
if (typeof first === "boolean") return first;

const result: SchemaDefinition = {};

// Handle enum -> base type conversion
if ("enum" in first && first.enum) {
const firstValue = first.enum[0];
if (typeof firstValue === "string") {
result.type = "string";
} else if (typeof firstValue === "number") {
result.type = "number";
} else if (typeof firstValue === "boolean") {
result.type = "boolean";
}
} else if ("type" in first) {
result.type = first.type;
}

// Recursively merge properties
if ("properties" in first && isRecord(first.properties)) {
const props: Record<string, SchemaDefinition> = {};
for (const key of Object.keys(first.properties)) {
const propSchemas = schemas
.map((s) =>
isRecord(s) && isRecord(s.properties)
? s.properties[key]
: undefined
)
.filter((p): p is Exclude<SchemaDefinition, undefined> =>
p !== undefined
);

if (propSchemas.length > 0) {
props[key] = this.mergeSchemaGroup(propSchemas);
}
}
result.properties = props;
}

// Recursively merge items
if ("items" in first && first.items !== undefined) {
const itemSchemas = schemas
.map((s) =>
isRecord(s) && "items" in s && s.items !== undefined
? s.items
: undefined
)
.filter((i): i is Exclude<SchemaDefinition, undefined> =>
i !== undefined
);

if (itemSchemas.length > 0) {
result.items = this.mergeSchemaGroup(itemSchemas);
}
}

// Copy other structural fields from first schema
if ("required" in first) result.required = first.required;
if ("additionalProperties" in first) {
result.additionalProperties = first.additionalProperties;
}

return result;
}
}
3 changes: 3 additions & 0 deletions packages/schema-generator/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface GenerationContext {
typeNode?: ts.TypeNode;
/** Optional type registry for synthetic nodes */
typeRegistry?: WeakMap<ts.Node, ts.Type>;
/** Widen literal types to base types during schema generation */
widenLiterals?: boolean;
}

/**
Expand Down Expand Up @@ -68,6 +70,7 @@ export interface SchemaGenerator {
type: ts.Type,
checker: ts.TypeChecker,
typeNode?: ts.TypeNode,
options?: { widenLiterals?: boolean },
): SchemaDefinition;

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/schema-generator/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export function createSchemaTransformerV2() {
type: ts.Type,
checker: ts.TypeChecker,
typeArg?: ts.TypeNode,
options?: { widenLiterals?: boolean },
) {
return generator.generateSchema(type, checker, typeArg);
return generator.generateSchema(type, checker, typeArg, options);
},

generateSchemaFromSyntheticTypeNode(
Expand Down
Loading