import { it, describe, expect } from 'vitest';
import { Effect, Schema } from 'effect';
import { ESchema, EntityESchema, ValueESchema, toSchema } from '../index.js';
import { Snapshot } from '../../snapshot/index.js';
const itEffect = (name: string, fn: () => Effect.Effect) =>
it(name, () => Effect.runPromise(fn()));
const Address = ESchema.make('Address', {
street: Schema.String,
city: Schema.String,
}).build();
const LineItem = EntityESchema.make('LineItem', 'id', {
name: Schema.String,
price: Schema.Number,
}).build();
const Order = EntityESchema.make('Order', 'orderId', {
customer: Schema.String,
items: Schema.Array(toSchema(LineItem)),
shippingAddress: toSchema(Address),
}).build();
describe('ESchema', () => {
describe('Composition', () => {
describe('toSchema', () => {
describe('basic composition', () => {
itEffect('encodes parent with nested schemas', () =>
Effect.gen(function* () {
const encoded = yield* Order.encode({
orderId: 'o1',
customer: 'Alice',
items: [
{ id: 'li1', name: 'Widget', price: 10 },
{ id: 'li2', name: 'Gadget', price: 20 },
],
shippingAddress: { street: '123 Main', city: 'NYC' },
});
expect(encoded).toMatchObject({
_v: 'v1',
orderId: 'o1',
customer: 'Alice',
});
const items = (encoded as any).items;
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
_v: 'v1',
id: 'li1',
name: 'Widget',
price: 10,
});
expect(items[1]).toMatchObject({
_v: 'v1',
id: 'li2',
name: 'Gadget',
price: 20,
});
const address = (encoded as any).shippingAddress;
expect(address).toMatchObject({
_v: 'v1',
street: '123 Main',
city: 'NYC',
});
}),
);
itEffect('decodes parent with nested schemas', () =>
Effect.gen(function* () {
const decoded = yield* Order.decode({
_v: 'v1',
orderId: 'o1',
customer: 'Alice',
items: [{ _v: 'v1', id: 'li1', name: 'Widget', price: 10 }],
shippingAddress: { _v: 'v1', street: '123 Main', city: 'NYC' },
});
expect(decoded).toEqual({
orderId: 'o1',
customer: 'Alice',
items: [{ id: 'li1', name: 'Widget', price: 10 }],
shippingAddress: { street: '123 Main', city: 'NYC' },
});
}),
);
itEffect('roundtrip encode then decode', () =>
Effect.gen(function* () {
const original = {
orderId: 'o1',
customer: 'Bob',
items: [{ id: 'li1', name: 'Widget', price: 42 }],
shippingAddress: { street: '456 Oak', city: 'LA' },
};
const encoded = yield* Order.encode(original);
const decoded = yield* Order.decode(encoded);
expect(decoded).toEqual(original);
}),
);
});
describe('independent versioning', () => {
const LineItemV2 = EntityESchema.make('LineItem', 'id', {
name: Schema.String,
price: Schema.Number,
})
.evolve('v2', { taxRate: Schema.Number }, (prev) => ({
...prev,
taxRate: 0,
}))
.build();
const OrderWithV2Items = EntityESchema.make('Order', 'orderId', {
customer: Schema.String,
items: Schema.Array(toSchema(LineItemV2)),
}).build();
itEffect('migrates nested schema independently of parent', () =>
Effect.gen(function* () {
const decoded = yield* OrderWithV2Items.decode({
_v: 'v1',
orderId: 'o1',
customer: 'Alice',
items: [{ _v: 'v1', id: 'li1', name: 'Widget', price: 10 }],
});
expect(decoded.items[0]).toEqual({
id: 'li1',
name: 'Widget',
price: 10,
taxRate: 0,
});
}),
);
itEffect('handles array elements at different nested versions', () =>
Effect.gen(function* () {
const decoded = yield* OrderWithV2Items.decode({
_v: 'v1',
orderId: 'o1',
customer: 'Alice',
items: [
{ _v: 'v1', id: 'li1', name: 'Old', price: 5 },
{
_v: 'v2',
id: 'li2',
name: 'New',
price: 15,
taxRate: 0.1,
},
],
});
expect(decoded.items[0]).toEqual({
id: 'li1',
name: 'Old',
price: 5,
taxRate: 0,
});
expect(decoded.items[1]).toEqual({
id: 'li2',
name: 'New',
price: 15,
taxRate: 0.1,
});
}),
);
});
describe('parent evolution with nested schemas', () => {
const Child = ESchema.make('Child', {
value: Schema.String,
}).build();
const Parent = ESchema.make('Parent', {
name: Schema.String,
})
.evolve('v2', { child: toSchema(Child) }, (prev) => ({
...prev,
child: { value: 'default' },
}))
.build();
itEffect('migrates parent and decodes nested in new field', () =>
Effect.gen(function* () {
const decoded = yield* Parent.decode({
_v: 'v1',
name: 'test',
});
expect(decoded).toEqual({
name: 'test',
child: { value: 'default' },
});
}),
);
itEffect('decodes latest version with nested schema', () =>
Effect.gen(function* () {
const decoded = yield* Parent.decode({
_v: 'v2',
name: 'test',
child: { _v: 'v1', value: 'hello' },
});
expect(decoded).toEqual({
name: 'test',
child: { value: 'hello' },
});
}),
);
});
describe('deep nesting', () => {
const Leaf = ESchema.make('Leaf', {
value: Schema.String,
}).build();
const Branch = ESchema.make('Branch', {
label: Schema.String,
leaf: toSchema(Leaf),
}).build();
const Root = ESchema.make('Root', {
title: Schema.String,
branch: toSchema(Branch),
}).build();
itEffect('encodes and decodes three levels deep', () =>
Effect.gen(function* () {
const original = {
title: 'root',
branch: {
label: 'mid',
leaf: { value: 'bottom' },
},
};
const encoded = yield* Root.encode(original);
expect((encoded as any).branch.leaf._v).toBe('v1');
expect((encoded as any).branch._v).toBe('v1');
const decoded = yield* Root.decode(encoded);
expect(decoded).toEqual(original);
}),
);
});
describe('all three variants compose', () => {
const plain = ESchema.make('Plain', { x: Schema.Number }).build();
const named = ESchema.make('Config', {
y: Schema.String,
}).build();
const entity = EntityESchema.make('Item', 'id', {
z: Schema.Boolean,
}).build();
const Composite = ESchema.make('Composite', {
p: toSchema(plain),
n: toSchema(named),
e: toSchema(entity),
}).build();
itEffect('roundtrips all three nested variant types', () =>
Effect.gen(function* () {
const original = {
p: { x: 42 },
n: { y: 'hello' },
e: { id: 'i1', z: true },
};
const encoded = yield* Composite.encode(original);
const decoded = yield* Composite.decode(encoded);
expect(decoded).toEqual(original);
}),
);
});
describe('nullable nested schema', () => {
const Child = ESchema.make('Child', { val: Schema.String }).build();
const WithNullable = ESchema.make('WithNullable', {
name: Schema.String,
child: Schema.NullOr(toSchema(Child)),
}).build();
itEffect('roundtrips with child present', () =>
Effect.gen(function* () {
const original = { name: 'a', child: { val: 'b' } };
const encoded = yield* WithNullable.encode(original);
const decoded = yield* WithNullable.decode(encoded);
expect(decoded).toEqual(original);
}),
);
itEffect('roundtrips with child null', () =>
Effect.gen(function* () {
const original = { name: 'a', child: null };
const encoded = yield* WithNullable.encode(original);
const decoded = yield* WithNullable.decode(encoded);
expect(decoded).toEqual(original);
}),
);
itEffect('snapshot includes the nested definition', () => {
const snapshot = Snapshot.capture(WithNullable);
const identities = snapshot.schemas.map((d) => d.identity);
expect(identities).toContain('Child');
return Effect.void;
});
});
describe('nested decode error', () => {
itEffect('propagates nested validation failure', () =>
Effect.gen(function* () {
const result = yield* Effect.flip(
Order.decode({
_v: 'v1',
orderId: 'o1',
customer: 'Alice',
items: [{ _v: 'v1', id: 'li1', name: 123, price: 10 }],
shippingAddress: { _v: 'v1', street: '123', city: 'NYC' },
}),
);
expect(result.message).toBe('Decode failed');
}),
);
});
describe('toSchema identifier', () => {
const plainNamed = ESchema.make('Order', {
x: Schema.Number,
}).build();
const namedEntity = EntityESchema.make('Item', 'id', {
z: Schema.Boolean,
}).build();
const namedValue = ValueESchema.make('Status', Schema.String).build();
// The identifier annotation is what JSON Schema generation uses as the
// `$defs` key, so assert through that observable surface.
const idOf = (schema: Schema.Codec) =>
Object.keys(Schema.toJsonSchemaDocument(schema).definitions)[0];
it("uses a plain eschema's own name", () => {
expect(idOf(toSchema(plainNamed))).toBe('ESchema_Order');
});
it("uses an entity eschema's own name", () => {
expect(idOf(toSchema(namedEntity))).toBe('ESchema_Item');
});
it("uses a value eschema's own name", () => {
expect(idOf(toSchema(namedValue))).toBe('ValueESchema_Status');
});
it('preserves the nested schema definition shape', () => {
const Child = ESchema.make('Child', {
value: Schema.String,
}).build();
const Parent = ESchema.make('Parent', {
child: toSchema(Child),
}).build();
const descriptor = Parent.getDescriptor();
expect(descriptor.$defs?.ESchema_Child).toMatchObject({
type: 'object',
properties: {
value: { type: 'string' },
},
});
});
});
});
});
});