import { it, describe, expect } from 'vitest';
import { Effect, Schema } from 'effect';
import { ESchema, EntityESchema, toSchema } from '../index.js';
const itEffect = (name: string, fn: () => Effect.Effect) =>
it(name, () => Effect.runPromise(fn()));
describe('ESchema', () => {
describe('Adoption', () => {
describe('Plain to evolving schema', () => {
describe('top-level adoption', () => {
itEffect('unstamped legacy row folds forward from v1 to latest', () =>
Effect.gen(function* () {
// A schema that was a plain { name } struct before adoption,
// then wrapped as an ESchema and evolved twice.
const User = ESchema.make('User', { name: Schema.String })
.evolve(
'v2',
{ name: Schema.String, email: Schema.String },
(p) => ({
...p,
email: 'unknown@example.com',
}),
)
.evolve(
'v3',
{
name: Schema.String,
email: Schema.String,
verified: Schema.Boolean,
},
(p) => ({ ...p, verified: false }),
)
.build();
// Legacy data persisted under the plain schema — no `_v`.
const decoded = yield* User.decode({ name: 'Bob' });
expect(decoded).toEqual({
name: 'Bob',
email: 'unknown@example.com',
verified: false,
});
}),
);
itEffect('unstamped that does not match v1 fails loudly', () =>
Effect.gen(function* () {
const User = ESchema.make('User', { name: Schema.String })
.evolve(
'v2',
{ name: Schema.String, age: Schema.Number },
(p) => ({
...p,
age: 0,
}),
)
.build();
// `name` is the wrong type for v1 — no fallback, decode rejects.
const error = yield* Effect.flip(User.decode({ name: 123 }));
expect(error.message).toBe('Decode failed');
}),
);
itEffect(
'no-op until evolved: unstamped == latest on a v1-only schema',
() =>
Effect.gen(function* () {
const User = ESchema.make('User', {
name: Schema.String,
}).build();
const decoded = yield* User.decode({ name: 'Bob' });
expect(decoded).toEqual({ name: 'Bob' });
}),
);
});
describe('adoption under composition', () => {
// Address was a plain nested struct before being wrapped as an evolving
// schema and evolved to add a `country` field.
const Address = ESchema.make('Address', {
street: Schema.String,
city: Schema.String,
})
.evolve(
'v2',
{
street: Schema.String,
city: Schema.String,
country: Schema.String,
},
(p) => ({ ...p, country: 'US' }),
)
.build();
itEffect(
'unstamped nested value folds forward through its own chain (parent stamped)',
() =>
Effect.gen(function* () {
// Order has always been evolving (stamped), but Address was adopted
// later: old orders carry an unstamped nested address.
const Order = EntityESchema.make('Order', 'id', {
customer: Schema.String,
shippingAddress: toSchema(Address),
}).build();
const decoded = yield* Order.decode({
_v: 'v1',
id: 'o1',
customer: 'Alice',
shippingAddress: { street: '123 Main', city: 'NYC' },
});
expect(decoded).toEqual({
id: 'o1',
customer: 'Alice',
shippingAddress: {
street: '123 Main',
city: 'NYC',
country: 'US',
},
});
}),
);
itEffect(
'non-isolation: nested evolution changes parent output without parent version change',
() =>
Effect.gen(function* () {
// The parent itself is a single-version (v1) schema and was never
// evolved, yet decoding folds the nested address to its latest shape.
const Order = EntityESchema.make('Order', 'id', {
shippingAddress: toSchema(Address),
}).build();
const decoded = yield* Order.decode({
_v: 'v1',
id: 'o1',
shippingAddress: { street: '1 A St', city: 'LA' },
});
// country injected by Address's own migration, parent still v1.
expect(decoded.shippingAddress).toEqual({
street: '1 A St',
city: 'LA',
country: 'US',
});
}),
);
itEffect(
'whole-tree pre-adoption: both parent and nested value are unstamped',
() =>
Effect.gen(function* () {
const Order = EntityESchema.make('Order', 'id', {
customer: Schema.String,
shippingAddress: toSchema(Address),
})
.evolve(
'v2',
{
customer: Schema.String,
shippingAddress: toSchema(Address),
priority: Schema.Boolean,
},
(p) => ({ ...p, priority: false }),
)
.build();
// The entire row predates adoption — neither level is stamped.
const decoded = yield* Order.decode({
id: 'o1',
customer: 'Alice',
shippingAddress: { street: '123 Main', city: 'NYC' },
});
expect(decoded).toEqual({
id: 'o1',
customer: 'Alice',
shippingAddress: {
street: '123 Main',
city: 'NYC',
country: 'US',
},
priority: false,
});
}),
);
itEffect(
'array of nested values at mixed adoption vintages decode independently',
() =>
Effect.gen(function* () {
const Order = EntityESchema.make('Order', 'id', {
addresses: Schema.Array(toSchema(Address)),
}).build();
const decoded = yield* Order.decode({
_v: 'v1',
id: 'o1',
addresses: [
{ street: 'legacy', city: 'NYC' }, // unstamped -> v1 -> v2
{ _v: 'v2', street: 'new', city: 'LA', country: 'CA' }, // stamped
],
});
expect(decoded.addresses).toEqual([
{ street: 'legacy', city: 'NYC', country: 'US' },
{ street: 'new', city: 'LA', country: 'CA' },
]);
}),
);
itEffect(
'nested unstamped that does not match nested v1 fails loudly',
() =>
Effect.gen(function* () {
const Order = EntityESchema.make('Order', 'id', {
shippingAddress: toSchema(Address),
}).build();
const error = yield* Effect.flip(
Order.decode({
_v: 'v1',
id: 'o1',
shippingAddress: { street: 123, city: 'NYC' },
}),
);
expect(error.message).toBe('Decode failed');
}),
);
});
});
});
});