import { Effect, Schema } from 'effect'; import type { ESchemaDescriptor, ValueDraftDefinition, ValueEnvelopeEncoded, ValueEvolution, ValueSchema, ValueSchemaDecoded, } from '../../domain/schema-model/index.js'; import { metaSchema, schemaDescriptor, } from '../../domain/schema-model/index.js'; import { ESchemaError, UnrepresentableFieldError, } from '../../domain/eschema-error/index.js'; import { findUnrepresentableField, registerESchemaIntrospection, } from '../../domain/introspection/index.js'; function hasVersionStamp(value: unknown): value is Record { return typeof value === 'object' && value !== null && '_v' in value; } export function makeValueSchemaRuntime< TVersion extends string, TLatest extends ValueSchema, >(input: { readonly owner: object; readonly name: string; readonly latestVersion: TVersion; readonly evolutions: readonly ValueEvolution[]; }) { const evolutions = [...input.evolutions]; for (const evolution of evolutions) { const found = findUnrepresentableField(evolution.schema.ast); if (found !== undefined) { throw new UnrepresentableFieldError( input.name, evolution.version, found.path, found.reason, ); } } registerESchemaIntrospection(input.owner, () => ({ name: input.name, kind: 'value', idField: null, evolutions: evolutions.map(({ version, schema }) => ({ version, schema })), })); const schema = (): TLatest => { const latest = evolutions.at(-1); if (latest?.schema === undefined) { throw new Error( 'ValueESchema is not properly initialized. This usually happens when the schema is accessed before module initialization completes. Consider using lazy initialization or avoiding top-level schema computations.', ); } return latest.schema as TLatest; }; const decode = ( value: unknown, ): Effect.Effect, ESchemaError> => Effect.gen(function* () { const isEnvelope = hasVersionStamp(value); const version = isEnvelope ? yield* Schema.decodeUnknownEffect(metaSchema)(value).pipe( Effect.map((metadata) => metadata._v), Effect.mapError( (cause) => new ESchemaError({ message: 'Decode failed', cause }), ), ) : (evolutions[0]?.version ?? input.latestVersion); const index = evolutions.findIndex( (evolution) => evolution.version === version, ); const evolution = evolutions[index]; if (index === -1 || evolution === undefined) { return yield* new ESchemaError({ message: `Unknown schema version: ${version}`, }); } const encoded = isEnvelope ? value.value : value; let data = yield* Schema.decodeUnknownEffect(evolution.schema)( encoded, ).pipe( Effect.mapError( (cause) => new ESchemaError({ message: 'Decode failed', cause }), ), ); for (let next = index + 1; next < evolutions.length; next++) { const migration = evolutions[next]; if (migration === undefined) { return yield* new ESchemaError({ message: 'Migration not found' }); } data = migration.migration!(data); } return data as ValueSchemaDecoded; }); const encode = ( value: ValueSchemaDecoded, ): Effect.Effect, ESchemaError> => Effect.gen(function* () { if (evolutions.length === 0) { return yield* new ESchemaError({ message: 'No evolutions found' }); } const encoded = yield* Schema.encodeEffect(schema())(value).pipe( Effect.mapError( (cause) => new ESchemaError({ message: 'Encode failed', cause }), ), ); return { _v: input.latestVersion, value: encoded, } as ValueEnvelopeEncoded; }); return { schema, decode, encode, descriptor: (): ESchemaDescriptor => schemaDescriptor( Schema.Struct({ _v: Schema.Literal(input.latestVersion), value: schema(), }), ), } as const; } /** * Wraps the base value runtime with a draft overlay, mirroring * `makeDraftedObjectSchemaRuntime`: decode folds through published * migrations then the draft's forward migration; encode applies the draft's * backward migration first, then encodes against the last published schema. */ export function makeDraftedValueSchemaRuntime< TVersion extends string, TLatest extends ValueSchema, TDraft extends ValueSchema, >(input: { readonly owner: object; readonly name: string; readonly latestVersion: TVersion; readonly evolutions: readonly ValueEvolution[]; readonly draft: ValueDraftDefinition; }) { const base = makeValueSchemaRuntime(input); const found = findUnrepresentableField(input.draft.schema.ast); if (found !== undefined) { throw new UnrepresentableFieldError( input.name, 'draft', found.path, found.reason, ); } const decode = ( value: unknown, ): Effect.Effect, ESchemaError> => base .decode(value) .pipe( Effect.map( (data) => input.draft.forward(data) as ValueSchemaDecoded, ), ); const encode = ( value: ValueSchemaDecoded, ): Effect.Effect, ESchemaError> => base.encode(input.draft.backward(value) as ValueSchemaDecoded); return { schema: base.schema, decode, encode, descriptor: base.descriptor, } as const; }