diff --git a/.changeset/add-schema-compiler.md b/.changeset/add-schema-compiler.md new file mode 100644 index 00000000000..73331540056 --- /dev/null +++ b/.changeset/add-schema-compiler.md @@ -0,0 +1,27 @@ +--- +"effect": patch +--- + +Add experimental JIT and AOT compilation through the existing `SchemaParser` parsing and construction APIs: + +- Import `effect/unstable/schema/SchemaJITCompiler/enable` for global lazy JIT. +- Use `SchemaJITCompiler.enable(ast)` for one AST and its dependencies. +- Use `SchemaCompiler.set(ast, decoder)` to install a trusted decoder. +- Use `SchemaAOTCompiler.compile(asts)` at build time to generate a module exporting `install(asts)`. Supply runtime ASTs in the same order; regenerate after schema or Effect upgrades. + +Install before parsers' first execution to accelerate them. JIT falls back to the interpreter when dynamic code generation is unavailable or compilation fails; parsing errors keep their normal behavior. AOT runs without dynamic code generation. + +For replay-safe ASTs, a failed fast validation can evaluate checks and property getters twice before producing detailed issues. Checks and Declaration recognizers must have no observable side effects, and getters must be deterministic. + +Construction shares the same cache, initializes independently from decoding, and does not replay defaults or constructors. Installed bundles require `decodeEffect`; optional `makeEffect` supplies construction, otherwise the interpreter handles it. Target `SchemaAST.toType(schema.ast)` for selective JIT or AOT construction. Global JIT must also precede the first maker call to optimize its entry. + +### Breaking changes + +- Schema class `make` methods now use the same constructor parser as `makeOption` and `makeEffect`, preserving existing instances without rerunning their constructors. Use `new MyClass(input)` when a new instance is required. +- `Literal(0)` and `Literal(-0)` preserve the input's zero sign. Normalize explicitly if you relied on canonicalization. +- Structs accept inherited declared fields, except `__proto__`. Record index signatures remain own-only. Check ownership before parsing if required. +- `parseOptions` annotations no longer affect parsing. Pass options to parser APIs instead. +- Remove `propertyOrder`. Output key order is unspecified, including inside checks. Remove order-dependent checks and handle presentation order explicitly. +- Remove `concurrency` from `ParseOptions`; remove it from parser option objects. Composite children parse sequentially. Parallelize independent parse calls explicitly with Effect concurrency combinators. +- Remove `onExcessProperty: "preserve"`. Use `Record` or `StructWithRest` with an explicit value schema. `"error"` rejects keys outside the combined declared-field and index-signature coverage, including on records. +- `SchemaAST.Union` takes `{ mode }` instead of a mode string. Read `ast.options?.mode ?? "anyOf"` instead of `ast.mode`; regenerate persisted representations. Public `Schema.Union` calls are unchanged. diff --git a/migration/annotations/effect__SchemaAST.yaml b/migration/annotations/effect__SchemaAST.yaml index 6627326015c..d975f136a95 100644 --- a/migration/annotations/effect__SchemaAST.yaml +++ b/migration/annotations/effect__SchemaAST.yaml @@ -18,7 +18,7 @@ note: "The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model." "effect/SchemaAST#BatchingAnnotation": replacement: "none" - note: "Per-schema batching annotations were removed; control asynchronous parsing with ParseOptions.concurrency." + note: "Per-schema batching annotations were removed. Composite schemas parse children sequentially; use Effect combinators to coordinate independent parsing operations." "effect/SchemaAST#BatchingAnnotationId": replacement: "none" note: "Symbol annotation IDs were removed and batching is no longer a schema annotation." @@ -53,11 +53,11 @@ replacement: "SchemaAST.Encoding" note: "The marker transformation was replaced by explicit SchemaAST.Link encoding chains." "effect/SchemaAST#ConcurrencyAnnotation": - replacement: "SchemaAST.ParseOptions[\"concurrency\"]" - note: "Concurrency is now a parse option rather than its own annotation type." + replacement: "none" + note: "Schema parsing concurrency was removed. Composite schemas parse children sequentially; use Effect concurrency combinators around independent parsing operations." "effect/SchemaAST#ConcurrencyAnnotationId": - replacement: "Schema.Annotations.Bottom[\"parseOptions\"]" - note: "Symbol annotation IDs were removed; put concurrency inside the parseOptions annotation." + replacement: "none" + note: "Schema parsing concurrency was removed. Composite schemas parse children sequentially; use Effect concurrency combinators around independent parsing operations." "effect/SchemaAST#Declaration": replacement: "SchemaAST.Declaration" note: "The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model." @@ -117,7 +117,7 @@ note: "Resolve string-keyed annotations with resolveAt, or use resolveIdentifier, resolveTitle, and resolveDescription." "effect/SchemaAST#getBatchingAnnotation": replacement: "none" - note: "Batching annotations were removed; read ParseOptions.concurrency when controlling asynchronous parsing." + note: "Batching annotations were removed. Composite schemas parse children sequentially; use Effect combinators to coordinate independent parsing operations." "effect/SchemaAST#getBrandAnnotation": replacement: "SchemaAST.resolveAt(\"brands\")" note: "Resolve the string-keyed brands annotation." @@ -125,8 +125,8 @@ replacement: "none" note: "The Match-based compiler was removed; traverse SchemaAST.AST directly or use the relevant Schema derivation API." "effect/SchemaAST#getConcurrencyAnnotation": - replacement: "SchemaAST.resolveAt(\"parseOptions\")" - note: "Resolve parseOptions and read concurrency from it." + replacement: "none" + note: "Schema parsing concurrency and its annotations were removed. Use Effect concurrency combinators around independent parsing operations." "effect/SchemaAST#getDecodingFallbackAnnotation": replacement: "none" note: "Fallbacks are encoding middleware in v4, not readable annotations; attach them with Schema.catchDecoding." @@ -164,8 +164,8 @@ replacement: "none" note: "Issue-title callbacks were removed; use message or expected annotations and SchemaIssue formatters." "effect/SchemaAST#getParseOptionsAnnotation": - replacement: "SchemaAST.resolveAt(\"parseOptions\")" - note: "Resolve the string-keyed parseOptions annotation." + replacement: "none" + note: "Parse options are no longer schema annotations. Pass options when creating or calling a decoder or encoder; there is no annotation-based override for nested schemas." "effect/SchemaAST#getPropertySignatures": replacement: "SchemaAST.Objects.propertySignatures" note: "Narrow to Objects and read propertySignatures directly." @@ -330,10 +330,10 @@ note: "Use the built-in JSON string codec instead of checking the old schema ID." "effect/SchemaAST#ParseOptions": replacement: "SchemaAST.ParseOptions" - note: "The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model." + note: "Pass parsing options at runtime. onExcessProperty supports ignore or error, not preserve; model extra values with an explicit Record or StructWithRest. The concurrency and propertyOrder options were removed. Output key order is unspecified, including in values passed to checks. Handle required presentation or serialization order explicitly outside the parser." "effect/SchemaAST#ParseOptionsAnnotationId": - replacement: "Schema.Annotations.Bottom[\"parseOptions\"]" - note: "Symbol annotation IDs were removed; use the parseOptions key." + replacement: "none" + note: "Parse options are no longer schema annotations. Pass options when creating or calling a decoder or encoder; there is no annotation-based override for nested schemas." "effect/SchemaAST#partial": replacement: "Schema.mapFields + Struct.map(Schema.optional)" note: "Partial object transforms moved to schema field transforms." @@ -414,7 +414,7 @@ note: "Symbol annotation IDs were removed; use declaration codec annotation keys." "effect/SchemaAST#TypeLiteral": replacement: "SchemaAST.Objects" - note: "The v4 SchemaAST redesign renamed this primitive, collection, or guard while preserving its role." + note: "Use Objects(propertySignatures, indexSignatures, annotations?, checks?, encoding?, context?, encodingChecks?). Output key order is unspecified; there is no property-order option." "effect/SchemaAST#TypeLiteralTransformation": replacement: "SchemaAST.Encoding" note: "Object transformations are encoding links; use Schema.encodeKeys for key mappings." @@ -426,7 +426,7 @@ note: "The v4 SchemaAST redesign renamed this primitive, collection, or guard while preserving its role." "effect/SchemaAST#Union": replacement: "SchemaAST.Union" - note: "The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model." + note: "Pass member ASTs and an optional options object: new SchemaAST.Union(types, { mode: 'oneOf' }). Read options.mode, defaulting to 'anyOf', instead of a direct mode field." "effect/SchemaAST#UniqueSymbol": replacement: "SchemaAST.UniqueSymbol" note: "The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model." diff --git a/migration/v3-to-v4.md b/migration/v3-to-v4.md index 75305f4b8ad..d11b35a1e0c 100644 --- a/migration/v3-to-v4.md +++ b/migration/v3-to-v4.md @@ -4,7 +4,7 @@ Base: `origin/v3` (`2e471d9cec31889cd6548aa5423b64c2b85238be`) -Head: `origin/main` (`5a802043984727b0c5a291af39d1b9bbfa8d7b8b`) +Head: `HEAD` (`23ef385e7082e115d34e4e6f3cc6727e85b6b1ba`) This file is generated from the API diff and `migration/annotations/*.yaml`. @@ -7156,12 +7156,18 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/platform/HttpServer` +- `HttpServer.Address` -> `effect/unstable/net/NetAddress#SocketAddress`: Replaced by the shared concrete internet-or-Unix socket address union. + - `HttpServer.HttpServer` -> `HttpServer.HttpServer`: The interface and tag became one Context.Service class; use its Service member for implementations. - `HttpServer.ServeOptions` -> `none`: The unused respond option model was removed with no shared v4 counterpart. +- `HttpServer.TcpAddress` -> `effect/unstable/net/NetAddress#InetAddress`: Replaced by the shared resolved internet-address model; use address and port instead of hostname and port. + - `HttpServer.TypeId` -> `none`: The public TypeId was removed; HttpServer is now a Context.Service class. +- `HttpServer.UnixAddress` -> `effect/unstable/net/NetAddress#UnixPathAddress`: Replaced by the shared Unix filesystem-path address model. + - `HttpServer.addressWith` -> `HttpServer.HttpServer.use(({ address }) => effect(address))`: The accessor was removed; read the service and pass its Address to the callback. - `HttpServer.layerContext` -> `HttpServer.layerServices`: Renamed; it provides the standard HTTP platform services. @@ -7434,8 +7440,14 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/platform/SocketServer` +- `SocketServer.Address` -> `effect/unstable/net/NetAddress#SocketAddress`: Replaced by the shared concrete internet-or-Unix socket address union. + - `SocketServer.ErrorTypeId` -> `SocketServer.ErrorTypeId`: The API moved to effect/unstable/socket/SocketServer and retains this name. +- `SocketServer.TcpAddress` -> `effect/unstable/net/NetAddress#InetAddress`: Replaced by the shared resolved internet-address model; use address and port instead of hostname and port. + +- `SocketServer.UnixAddress` -> `effect/unstable/net/NetAddress#UnixPathAddress`: Replaced by the shared Unix filesystem-path address model. + ### `@effect/platform/Template` - `Template.Interpolated.Context` -> `Template.Interpolated.Context`: The API moved to effect/unstable/http/Template; v4 interpolation types also account for Effect values. @@ -7460,6 +7472,8 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc) ### `@effect/platform/Url` +- `Url.fromString`: TODO: needs guidance + - `Url.setUrlParams` -> `Url.setUrlParams`: Retained and widened to accept UrlParams.Input. ### `@effect/platform/UrlParams` @@ -14875,7 +14889,7 @@ Schema.toFormatter(schema) - `SchemaAST.ArbitraryAnnotationId` -> `Schema.Annotations.ToArbitrary`: Symbol annotation IDs were removed. Declarations use the toCodecArbitrary annotation; filters use arbitraryConstraint. -- `SchemaAST.BatchingAnnotation` -> `none`: Per-schema batching annotations were removed; control asynchronous parsing with ParseOptions.concurrency. +- `SchemaAST.BatchingAnnotation` -> `none`: Per-schema batching annotations were removed. Composite schemas parse children sequentially; use Effect combinators to coordinate independent parsing operations. - `SchemaAST.BatchingAnnotationId` -> `none`: Symbol annotation IDs were removed and batching is no longer a schema annotation. @@ -14891,9 +14905,9 @@ Schema.toFormatter(schema) - `SchemaAST.ComposeTransformation` -> `SchemaAST.Encoding`: The marker transformation was replaced by explicit SchemaAST.Link encoding chains. -- `SchemaAST.ConcurrencyAnnotation` -> `SchemaAST.ParseOptions["concurrency"]`: Concurrency is now a parse option rather than its own annotation type. +- `SchemaAST.ConcurrencyAnnotation` -> `none`: Schema parsing concurrency was removed. Composite schemas parse children sequentially; use Effect concurrency combinators around independent parsing operations. -- `SchemaAST.ConcurrencyAnnotationId` -> `Schema.Annotations.Bottom["parseOptions"]`: Symbol annotation IDs were removed; put concurrency inside the parseOptions annotation. +- `SchemaAST.ConcurrencyAnnotationId` -> `none`: Schema parsing concurrency was removed. Composite schemas parse children sequentially; use Effect concurrency combinators around independent parsing operations. - `SchemaAST.Declaration` -> `SchemaAST.Declaration`: The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model. @@ -14969,9 +14983,9 @@ Schema.toFormatter(schema) - `SchemaAST.ParseJsonSchemaId` -> `Schema.UnknownFromJsonString`: Use the built-in JSON string codec instead of checking the old schema ID. -- `SchemaAST.ParseOptions` -> `SchemaAST.ParseOptions`: The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model. +- `SchemaAST.ParseOptions` -> `SchemaAST.ParseOptions`: Pass parsing options at runtime. onExcessProperty supports ignore or error, not preserve; model extra values with an explicit Record or StructWithRest. The concurrency and propertyOrder options were removed. Output key order is unspecified, including in values passed to checks. Handle required presentation or serialization order explicitly outside the parser. -- `SchemaAST.ParseOptionsAnnotationId` -> `Schema.Annotations.Bottom["parseOptions"]`: Symbol annotation IDs were removed; use the parseOptions key. +- `SchemaAST.ParseOptionsAnnotationId` -> `none`: Parse options are no longer schema annotations. Pass options when creating or calling a decoder or encoder; there is no annotation-based override for nested schemas. - `SchemaAST.PrettyAnnotationId` -> `Schema.overrideToFormatter`: The symbol annotation was removed; attach custom formatters with Schema.overrideToFormatter. @@ -15015,13 +15029,13 @@ Schema.toFormatter(schema) - `SchemaAST.TypeConstructorAnnotationId` -> `Schema.Annotations.Declaration["toCodec"]`: Symbol annotation IDs were removed; use declaration codec annotation keys. -- `SchemaAST.TypeLiteral` -> `SchemaAST.Objects`: The v4 SchemaAST redesign renamed this primitive, collection, or guard while preserving its role. +- `SchemaAST.TypeLiteral` -> `SchemaAST.Objects`: Use Objects(propertySignatures, indexSignatures, annotations?, checks?, encoding?, context?, encodingChecks?). Output key order is unspecified; there is no property-order option. - `SchemaAST.TypeLiteralTransformation` -> `SchemaAST.Encoding`: Object transformations are encoding links; use Schema.encodeKeys for key mappings. - `SchemaAST.UndefinedKeyword` -> `SchemaAST.Undefined`: The v4 SchemaAST redesign renamed this primitive, collection, or guard while preserving its role. -- `SchemaAST.Union` -> `SchemaAST.Union`: The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model. +- `SchemaAST.Union` -> `SchemaAST.Union`: Pass member ASTs and an optional options object: new SchemaAST.Union(types, { mode: 'oneOf' }). Read options.mode, defaulting to 'anyOf', instead of a direct mode field. - `SchemaAST.UniqueSymbol` -> `SchemaAST.UniqueSymbol`: The name remains, but its constructor and fields changed in the v4 Base/check/context/encoding model. @@ -15047,13 +15061,13 @@ Schema.toFormatter(schema) - `SchemaAST.getAnnotation` -> `SchemaAST.resolveAt`: Resolve string-keyed annotations with resolveAt, or use resolveIdentifier, resolveTitle, and resolveDescription. -- `SchemaAST.getBatchingAnnotation` -> `none`: Batching annotations were removed; read ParseOptions.concurrency when controlling asynchronous parsing. +- `SchemaAST.getBatchingAnnotation` -> `none`: Batching annotations were removed. Composite schemas parse children sequentially; use Effect combinators to coordinate independent parsing operations. - `SchemaAST.getBrandAnnotation` -> `SchemaAST.resolveAt("brands")`: Resolve the string-keyed brands annotation. - `SchemaAST.getCompiler` -> `none`: The Match-based compiler was removed; traverse SchemaAST.AST directly or use the relevant Schema derivation API. -- `SchemaAST.getConcurrencyAnnotation` -> `SchemaAST.resolveAt("parseOptions")`: Resolve parseOptions and read concurrency from it. +- `SchemaAST.getConcurrencyAnnotation` -> `none`: Schema parsing concurrency and its annotations were removed. Use Effect concurrency combinators around independent parsing operations. - `SchemaAST.getDecodingFallbackAnnotation` -> `none`: Fallbacks are encoding middleware in v4, not readable annotations; attach them with Schema.catchDecoding. @@ -15079,7 +15093,7 @@ Schema.toFormatter(schema) - `SchemaAST.getParseIssueTitleAnnotation` -> `none`: Issue-title callbacks were removed; use message or expected annotations and SchemaIssue formatters. -- `SchemaAST.getParseOptionsAnnotation` -> `SchemaAST.resolveAt("parseOptions")`: Resolve the string-keyed parseOptions annotation. +- `SchemaAST.getParseOptionsAnnotation` -> `none`: Parse options are no longer schema annotations. Pass options when creating or calling a decoder or encoder; there is no annotation-based override for nested schemas. - `SchemaAST.getPropertySignatures` -> `SchemaAST.Objects.propertySignatures`: Narrow to Objects and read propertySignatures directly. @@ -16060,6 +16074,8 @@ switch (strategy) { - `TMap.removeAll` -> `TxHashMap.removeMany`: The bulk removal operation was renamed. +- `TMap.set`: TODO: needs guidance + - `TMap.setIfAbsent` -> `Effect.tx + TxHashMap.get/TxHashMap.set`: No direct helper remains; check and conditionally set under one outer transaction. - `TMap.size` -> `TxHashMap.size`: Import TxHashMap from "effect/TxHashMap"; the operation keeps its name. V4 Tx operations return ordinary Effects; compose multiple operations under one outer Effect.tx to keep them atomic. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 3f8b28edcdb..cfa3e9f382f 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -47,28 +47,422 @@ reporting, schema creation, and codecs. The table below compares Effect Schema with the Valibot and Zod cases available in the same suite. -Values are microseconds per operation and lower is better. Results vary between -machines, so they are most useful for understanding relative costs. A dash -means that the library does not provide that benchmark. - -| Scenario | Effect Schema | Valibot | Zod 4 | -| ------------------------------------- | ------------: | ---------: | ---------: | -| Create a schema | 118.23 | **40.24** | 318.56 | -| Create a schema and parser | **130.50** | — | — | -| Validate valid data | **5.415** | 5.63 | — | -| Validate invalid data | 1.348 | **0.2431** | — | -| Parse valid data and collect errors | 5.366 | **5.22** | 7.16 | -| Parse invalid data and collect errors | **9.100** | 15.70 | 41.58 | -| Parse valid data and stop early | **5.294** | 5.37 | — | -| Parse invalid data and stop early | 1.352 | **0.2572** | — | -| Standard Schema, valid data | 5.935 | 5.35 | **3.83** | -| Standard Schema, invalid data | **15.203** | 16.51 | 32.85 | -| Standard Schema, valid, stop early | **5.843** | — | — | -| Standard Schema, invalid, stop early | **2.244** | — | — | -| Encode with a typed codec | 0.3420 | — | **0.0405** | -| Decode with a typed codec | 0.3762 | — | **0.0463** | -| Encode unknown input | **0.3472** | — | — | -| Decode unknown input | **0.3637** | — | — | +Values are median microseconds per operation and lower is better. Results vary +between machines; cross-library comparisons are diagnostic. A dash means that +the upstream adapter does not provide that benchmark. + +Measured on 2026-09-08 with the current construction-registry implementation, +using Node 24.12.0, V8 13.6.233.17-node.37, Apple M3, macOS arm64, +Valibot 1.4.2, and Zod 4.5.4. Each case uses five fresh processes, +300 ms measurement, 100 ms warmup, and automatically calibrated batches. +Effect's compiler is disabled. Zod parsing uses `jitless: true`; +Standard Schema and codec cases use their native APIs. + +```sh +pnpm runtimeperf schema-benchmarks --rounds 5 --time 300 --warmup-time 100 +``` + +| Scenario | Effect interpreted | Valibot | Zod 4 | +| ------------------------------------- | -----------------: | ------: | -----: | +| Create a schema | 99.95 | 32.28 | 100.07 | +| Create a schema and parser | 80.36 | — | — | +| Validate valid data | 4.33 | 5.08 | — | +| Validate invalid data | 0.2514 | 0.2350 | — | +| Parse valid data and collect errors | 5.20 | 5.10 | 7.04 | +| Parse invalid data and collect errors | 7.71 | 15.42 | 22.55 | +| Parse valid data and stop early | 4.39 | 5.10 | — | +| Parse invalid data and stop early | 0.2273 | 0.2458 | — | +| Standard Schema, valid data | 5.51 | 5.15 | 3.56 | +| Standard Schema, invalid data | 12.07 | 15.45 | 17.65 | +| Standard Schema, valid, stop early | 4.73 | — | — | +| Standard Schema, invalid, stop early | 0.7947 | — | — | +| Encode with a typed codec | 0.0833 | — | 0.0424 | +| Decode with a typed codec | 0.0898 | — | 0.0475 | +| Encode unknown input | 0.0832 | — | — | +| Decode unknown input | 0.0889 | — | — | + +### Runtime compilation + +Schema offers experimental, opt-in JIT and AOT compilation. Both work through +the normal `SchemaParser` APIs for decoding, encoding, type guards, and construction; schemas +remain composable and do not acquire a separate compiled type. + +To enable JIT globally, import its side-effect entrypoint during startup: + +```ts +import "effect/unstable/schema/SchemaJITCompiler/enable" +import { Schema, SchemaParser } from "effect" + +const User = Schema.Struct({ + id: Schema.Number, + name: Schema.String +}) + +const decodeUser = SchemaParser.decodeUnknownSync(User) + +decodeUser({ id: 1, name: "Ada" }) +``` + +Compilation is lazy: the import enables it, but parsers initialize their +operations only when first used. If the environment forbids `new Function` or +JIT compilation fails, Schema falls back to interpreted parsing without retrying +the failed compilation. This also applies to lazy checkpoints, without repeating +earlier transformations or middleware. Exceptions during parsing keep their +normal behavior; they do not trigger a restart in the interpreter. + +#### One cache, interchangeable parsers + +`SchemaCompiler` uses one `WeakMap` for interpreted, JIT, AOT, and +manually installed decoders. Installed decoders are `CompiledDecoder` objects, +not just decoding functions. Each entry provides lazy `decodeEffect` and `makeEffect` +operations, either installed or interpreted. Decoders can also supply `validate` +and `is`, as described below. The cache never stores parsing results. + +The registry adds a `parseEffect` function to coordinate `validate` and `decodeEffect`, +and lazily prepares interpreted construction when the installed bundle omits `makeEffect`. +Both constructor and decoder functions are cached on that same entry. It also adds +an `origin` flag, either `"interpreted"` or `"installed"`. This flag controls +replacement during installation, not validation: selective JIT preserves already +installed descendants but can replace interpreted ones. Callers of `set` supply +only the decoder operations, not these internal fields. + +On first use, a parser reuses the cached entry or creates and caches a compiled +or interpreted decoder. Children use the same cache, so an interpreted parent +can have compiled children. Internal decoding resolves the entry's raw parser; +the public `parseEffect` boundary materializes its successful output. Construction +resolves `makeEffect` instead. Selective compilation remains active for children +even when their parent uses an interpreted constructor. All operations are lazy, +so recursive children resolve after their parent entry has been installed. No +separate constructor cache or recursive placeholder cache is needed. + +On first use of a Declaration, its declared type parameters are registered through +the entry lookup carried by the same resolver before its callback runs. Their +operations remain lazy. This lets callbacks using public parsers find selectively +compiled children; new ASTs created inside a callback follow the normal registry +policy. + +Choose how to populate it: + +| API | Behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Import `SchemaJITCompiler/enable` | Enables lazy JIT globally without replacing existing entries. | +| `SchemaJITCompiler.enable(ast)` | Installs one root immediately and compiles its parsing/construction dependencies as needed. Operations remain lazy; already installed descendants are preserved. | +| `SchemaCompiler.set(ast, decoder)` | Installs a trusted decoder, replacing any entry for that AST. AOT uses the same registry. | + +These modules live under `effect/unstable/schema`. Selective installation takes +an AST: use `schema.ast` for decoding, `SchemaAST.flip(schema.ast)` for encoding, +and `SchemaAST.toType(schema.ast)` for type guards and construction. The exact returned AST is +the cache key. Different AST objects have separate entries, even if structurally +equal; operations using the same AST object share an entry. + +Install before the **first execution** of parsers you want to accelerate. +Creating a parser earlier is fine. Late installation is safe, but a parser that +already captured an entry keeps it, even when later calls change parse options. +This includes `make`, `makeOption`, and `makeEffect`: constructing a value can +populate the entry before its first decode. A later global JIT import does not +upgrade it. Explicit `set` still replaces the whole entry for new consumers; +omitting `makeEffect` from a replacement restores interpreted construction for them. + +#### Parsing behavior and constraints + +Every entry provides the complete `decodeEffect` operation. Two optional fast paths +avoid work that is unnecessary for successful decoding or boolean validation. +All operations initialize independently when first needed: + +| Operation | Result | Purpose | +| ------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------- | +| `is`, optional | `boolean` | Validates without constructing output, when checks do not require reconstructed values. | +| `validate`, optional | Decoded value or `SchemaCompiler.invalid` | Validates and constructs output without generating diagnostic issues. | +| `decodeEffect`, required | `Effect` | Returns the actual output or detailed failure. | +| `makeEffect`, optional | `Effect` | Constructs the node without replay; omission selects interpreted construction. | + +`decodeEffect` is required so every entry can produce output and explain failures, +even without any fast paths. It also handles transformations, middleware, and +asynchronous work when the AST requires them. It can be compiled or interpreted; +calling `decodeEffect` does not necessarily mean returning to the interpreter. + +`validate` is optional because a synchronous, diagnostic-free first pass is not +always supported or safe to repeat. In particular, ASTs containing encodings +omit it so a later failure cannot repeat transformations or middleware. + +`is` is optional because preserving validation semantics can require constructing +output. For example, a check on a Struct must see the reconstructed object with +extra properties removed. Such a schema uses `validate`, or `decodeEffect` if +`validate` is unavailable, instead of an output-free `is`. Omitting either fast +path removes an optimization, not parsing capability. + +For decoding, `entry.parseEffect` tries `validate` when available. Success already +contains the output, so no detailed pass is needed. `invalid` contains no error +location or explanation, so failure requires one detailed `decodeEffect` pass. This +favors valid inputs at the cost of traversing invalid inputs again, only where +repetition is safe. Without `validate`, or for the `missing` sentinel, it calls +`decodeEffect` directly. Interpreter, JIT, and AOT implementations supply the operations +without implementing this dispatch. The synchronous decode and encode adapters +share a direct version of it, returning successful `validate` output without an +intermediate Effect. Encoding uses the flipped AST; when it equals the original, +both adapters use the same entry and execution path. Detailed traversal does not +restart fast validation at every child. + +Construction calls `entry.makeEffect` directly, without `is` or `validate`. +It has different semantics from decoding: defaults are applied to Struct fields, +tuple/array elements, and Record values before the child is constructed. They do +not become defaults for that AST used as a root, a Union member, or a Record key. +Union selection stays conservative for missing discriminants. Class construction +preserves recognized instances; otherwise it constructs the source and creates +the instance. Ordinary Declaration callbacks retain their own parsing choices. + +Using only construction does not initialize the decoder or validators. JIT +preparation failures select the interpreted implementation for the affected +operation, independently of decoding or construction that already works. If a +lazy child's compilation fails after a default has run, only that child falls +back. Neither default effects nor Class constructors are replayed. Normal Union +branch attempts remain unchanged, so more than one branch's defaults can run. + +A composed Struct decoder can compile its fields, including transformations, +then apply the Struct's checks to the decoded output. Both stages read the same +AST, and only the complete decoder is installed. Checks run after stripping and +successful field decoding, without replaying transformations. + +`SchemaParser.is(schema)` and `Schema.is(schema)` check `toType(schema.ast)` +with default parse options: excess properties are ignored and checks are enabled. +The guard uses `is`, then `validate` if `is` is unavailable, converting `invalid` +to `false` without a diagnostic pass. If neither exists, it uses ordinary +decoding and converts the outcome to a boolean; non-schema failures still throw. +For configurable type-side validation, use a decoding API with `Schema.toType(schema)`. + +The following constraints apply: + +- Runtime `ParseOptions` apply throughout parsing, without recompilation. + Annotations cannot override them. Children parse sequentially; use Effect + concurrency combinators for independent operations or inside transformations. +- A failed validation can read properties and run checks twice. Checks must + have no observable side effects; property getters must be deterministic and + safe to repeat. Declaration parsers have the same constraint and must be + synchronous. +- ASTs with encodings enter `decodeEffect` directly. Transformations and middleware + run once, with their validation checkpoints resolved through the shared cache. + Local checkpoints preserve the original AST for checks and issues. +- Unsupported nodes and code-size limits select composed or interpreted parsing; + supported descendants can still compile. Parsing exceptions follow normal + Effect defect behavior. + +For custom `set` implementations, every operation must honor the active +`ParseOptions`. Return `invalid` only for invalid input, never to decline an +optimization; `validate` must not call `decodeEffect` and discard its issues. +User checks may themselves allocate issues. An absent optional input reaches +`decodeEffect` or `makeEffect` as `SchemaCompiler.missing`, distinct from a present +`undefined`. When a field produces no value, propagate `missing` as an Effect +success: the parent omits optional fields or reports a missing required key. +Public root adapters reject a final `missing` rather than exposing it. + +Installation trusts the decoder to implement its AST and does not mutate the +supplied object. Operation accessors are evaluated once, on demand, with that +object as their receiver; missing optional operations are cached too. Installed +`decodeEffect` and `makeEffect` functions return ordinary Effects. + +#### Ahead-of-time compilation + +`SchemaAOTCompiler.compile(asts)` accepts a readonly array of ASTs and returns +a JavaScript ES module exporting `install(asts)`. Share the root array between +the build script and the application: + +```ts +export const roots = [Person.ast, Order.ast] as const +``` + +Generate the module at build time: + +```ts +import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" +import { writeFile } from "node:fs/promises" +import { roots } from "./schemas.js" + +await writeFile("./schemas.decoders.js", SchemaAOTCompiler.compile(roots)) +``` + +Install it before first parser execution: + +```ts +import * as SchemaParser from "effect/SchemaParser" +import { install } from "./schemas.decoders.js" +import { Person, roots } from "./schemas.js" + +install(roots) +const decodePerson = SchemaParser.decodeUnknownSync(Person) +``` + +Neither generation nor importing the generated module installs decoders. +`install` registers the roots and supported statically reachable dependencies. +Use `[ast]` for one schema; an empty array is a no-op. Repeated roots and shared +dependencies are emitted and installed once by identity. + +JIT and AOT share generation rules and runtime support. Generated modules import +`effect/unstable/schema/SchemaCompiler/runtime`, not the code generator, and +work without dynamic function construction. Validators and composed Struct +decoders and constructors are static. Detailed diagnostics, transformation +orchestration, and Array, Record, Union, leaf, and Class constructors specialize +lazily in shared runtime support. AOT does not precompute every operation. +Constructor default links and Class source schemas are included among reachable +dependencies and read from the runtime ASTs without executing defaults during generation. + +Keep these installation requirements in mind: + +- Regenerate after schema or Effect version changes. Installation trusts the + array length, root order, AST structure, and sharing to match the build-time + roots; it does not check compatibility. +- Checks, symbols, transformations, and middleware come from the runtime ASTs, + not serialization, preserving their identity. +- Suspend thunks are not forced during generation. Their contents and other + unsupported nodes use the interpreter, with no late JIT required. +- Include type-side ASTs for construction and flipped ASTs for encoding separately + when they differ from the supplied roots. As with `set`, + late installation does not update parser closures that captured older entries. + +#### Performance snapshot + +##### Construction + +Measured on 2026-09-08, Node 24.12.0, V8 13.6, Apple M3, macOS arm64. +Median ns/op through `SchemaParser.make`; nine isolated rounds, 500 ms measurement +and 150 ms warmup. Interpreted/JIT use calibrated batches; AOT uses batch 256 +with dynamic code generation disabled and a separate fixture call site. +Schema setup is outside measurement except in the last row, which also includes +installation for AOT. + +| Case | Interpreted | JIT | AOT | +| ------------------------------------ | ----------: | -----: | -----: | +| Struct, two fields | 59.7 | 33.7 | 30.9 | +| Struct, constructor default | 101.2 | 65.8 | 63.4 | +| Array of 32 Structs | 792.1 | 811.8 | 801.4 | +| Union, missing discriminant default | 119.4 | 95.6 | 92.4 | +| Class, plain input | 156.3 | 155.6 | 151.8 | +| Schema creation + first construction | 3092.5 | 6857.0 | 4286.7 | + +Retained parser heap, KiB/schema, for +`Struct({ a: String, b: Number.withConstructorDefault(succeed(1)) })`. +Five isolated processes per cell retain 1,000 distinct schemas and their public +parsers after first use with `{ a: "a", b: 1 }`. Imports, static AOT modules, +64 warmup schemas, and schema construction precede the parser heap reading. +Two forced GCs run at each reading. Costs include public parser closures and AOT +installation, but exclude schema construction, about 3 KiB/schema. +These are retained V8 heap values, not peak or all native executable-code memory. + +| Operations used | Interpreted | JIT | AOT | +| --------------- | ----------: | ---: | ----: | +| make | 2.78 | 5.24 | 8.69 | +| decode | 1.83 | 2.35 | 5.01 | +| make + decode | 4.33 | 5.60 | 10.55 | + +##### Decoding and type guards + +Measured on 2026-09-08 on the same source revision as the construction snapshot. +All measurements use public `SchemaParser` APIs on Node 24.12.0, V8 13.6, +Apple M3, macOS arm64. The current snapshot includes every scenario in the +`schema-compiler` suite, not just the Moltar objects. + +###### Moltar + +Median ns/op, batch 256. Interpreted/JIT: five processes per case, 300 ms +measurement and 100 ms warmup. AOT: nine processes, 500 ms measurement and +150 ms warmup, with dynamic code generation disabled. AOT uses the same data and +worker with a different fixture call site. Sub-10 ns differences between JIT and +AOT are not a general ranking. + +| Case | Interpreted | JIT | AOT | +| --------------------------- | ----------: | -----: | -----: | +| parseSafe, valid | 265.5 | 5.8 | 5.9 | +| parseSafe, extra property | 265.3 | 5.9 | 5.9 | +| parseSafe, invalid | 2770.0 | 2840.0 | 3082.8 | +| assertLoose, valid | 266.6 | 3.3 | 3.3 | +| assertLoose, extra property | 266.7 | 3.3 | 3.3 | +| assertLoose, invalid | 118.7 | 3.0 | 1.4 | + +Schema creation plus first use, median µs/op, five processes per case. AOT was +not measured in this fixture. Creation and first use are measured together. + +| Case | Interpreted | JIT | +| ----------- | ----------: | ----: | +| parseSafe | 7.52 | 11.31 | +| assertLoose | 10.02 | 11.54 | + +###### Other schema shapes + +All 31 scenarios in `schema-compiler`, median ns/op from five processes per case, +300 ms measurement, 100 ms warmup, and shared calibrated batches. + +| Scenario | Interpreted | JIT | +| ------------------------------------ | ----------: | --------: | +| `declaration-set-valid` | 2757.8 | 1393.7 | +| `checked-transformed-struct-valid` | 1712.1 | 1034.3 | +| `checked-transformed-struct-invalid` | 6067.8 | 5252.9 | +| `sync-decode-valid` | 79.1 | 6.8 | +| `sync-encode-valid` | 78.1 | 6.7 | +| `strict-record-1024-valid` | 203883.2 | 206146.6 | +| `strict-record-4096-valid` | 680157.5 | 758954.0 | +| `strict-record-4096-invalid` | 683783.0 | 1421426.9 | +| `array-100-valid` | 704.8 | 126.5 | +| `array-100-invalid-last` | 6181.0 | 4453.2 | +| `tuple-rest-valid` | 478.3 | 40.0 | +| `optional-struct-valid` | 1154.6 | 19.5 | +| `record-valid` | 3817.9 | 665.9 | +| `template-record-valid` | 9185.1 | 4052.9 | +| `struct-with-record-valid` | 1384.0 | 724.8 | +| `number-record-valid` | 4907.4 | 4547.7 | +| `transformed-key-record-valid` | 3575.2 | 3528.8 | +| `encoding-checked-struct-valid` | 779.2 | 30.9 | +| `literal-100-valid-last` | 33.2 | 10.0 | +| `literal-100-invalid` | 3247.7 | 3402.2 | +| `tagged-union-100-valid-last` | 113.0 | 27.1 | +| `tagged-union-100-invalid` | 3236.4 | 3456.3 | +| `checked-string-valid` | 20.0 | 8.9 | +| `template-literal-valid` | 161.8 | 48.2 | +| `transformation-struct-valid` | 1856.8 | 1271.6 | +| `transformation-root-valid` | 38.8 | 38.5 | +| `transformation-root-invalid` | 3531.6 | 3618.9 | +| `transformation-uppercase-valid` | 47.3 | 47.4 | +| `transformation-output-invalid` | 3542.5 | 3590.8 | +| `middleware-struct-valid` | 1961.3 | 381.6 | +| `recursive-node-valid` | 13543.7 | 8117.6 | + +###### Memory and first-use CPU + +Seven isolated processes per mode, each retaining 1,000 distinct schemas for +`Struct({ name: String, count: Number, active: Boolean, tags: Array(String) })`, +their inputs, and public `decodeUnknownSync` functions. The inputs contain +`{ name: "a", count: 1, active: true, tags: ["a", "b"] }`. +Two forced GCs run before construction, after construction, and after first use. + +Retained heap, B/schema. Construction includes schemas, inputs and public parser +closures. First use includes AOT installation and lazy parser initialization. +Imports and the static AOT module precede the first reading and are excluded. +These are retained V8 heap values, not peak memory, RSS, or all native code memory. + +| Mode | Construction | First use | Total | +| ----------- | -----------: | --------: | ----: | +| Interpreted | 3208 | 3018 | 6226 | +| JIT | 3208 | 2425 | 5634 | +| AOT | 3203 | 5991 | 9194 | + +First use, median µs/schema within the 1,000-schema batch, including AOT +installation. Schema construction, imports and GC are outside the timed region. +Process CPU includes background work and can exceed wall time. Warm CPU is not +measured separately. + +| Mode | Wall time | Process CPU | +| ----------- | --------: | ----------: | +| Interpreted | 4.66 | 9.92 | +| JIT | 8.56 | 15.15 | +| AOT | 48.86 | 51.54 | + +Coverage of the accompanying revision comparison: all 191 Effect cases in the +runtimeperf registry, including the 54 interpreter diagnostics and 31 Arbitrary +cases not tabulated here. AOT throughput coverage includes the six Moltar paths and six construction cases. +Peak memory and native executable-code memory are not covered. Current measurements are shown here without deltas; +revision-comparison reports retain their paired observations and confidence intervals. + +Raw reports are under `tmp/runtimeperf/results/`; the audit summary, complete +comparison matrix and supplementary probe source are documented in +`.tmp/schema-construction-optimization.md`. # Defining Elementary Schemas @@ -117,6 +511,10 @@ console.log(parser(null)) // => "null" A literal schema matches one exact value. Use it when a field must be a specific string, number, or other constant. +Matching uses strict equality (`===`). Like TypeScript, `Literal(0)` and +`Literal(-0)` accept both zero signs; decoding and encoding preserve the input's +sign instead of replacing it with the schema's stored literal. + ```ts import { Schema } from "effect" @@ -909,25 +1307,35 @@ Failure(Cause([Fail(SchemaError: Custom message */ ``` -### Preserve unexpected keys +### Extra keys and output order -You can preserve unexpected keys by setting `onExcessProperty` to `preserve`. +Runtime `onExcessProperty` supports `"ignore"`, the default, and `"error"`. +`"preserve"` is no longer supported. Model accepted extras with an explicit +`Record` or `StructWithRest` value schema, so their presence is visible in the +type and their values are validated. -**Example** (Preserving unexpected keys) +`"error"` also applies to Records and StructWithRest. A key is excess only if +no fixed field and no index signature selects it. Each applicable index +signature still validates its value. Invalid values are not excess keys. +`Struct({})` retains its special non-nullish contract, matching TypeScript `{}`. -```ts -import { Schema } from "effect" +Object property order is unspecified. Decoding and encoding do not guarantee +preservation of input key order, including in nested Structs, Records and +StructWithRest. There is no guarantee of schema declaration order either. +This also applies to the decoded values received by checks, including through +`SchemaParser.is`. -const schema = Schema.Struct({ - a: Schema.String -}) +The `propertyOrder` parse option has been removed. Remove it from parser +configuration. If presentation or serialization requires a specific order, +retain the required ordering information and arrange the parsed values +explicitly. Checks that previously relied on `propertyOrder: "original"` need +to be revised; reordering the final output does not restore the order those +checks observe. -console.log(String(Schema.decodeUnknownExit(schema)({ a: "a", b: "b" }, { onExcessProperty: "preserve" }))) -/* -Output: -Success({"b":"b","a":"a"}) -*/ -``` +`Union.options` is an optional immutable-by-contract object containing `mode`, +defaulting to `"anyOf"`. Normal AST projection and reconstruction copy it. +Structural representations and generated schema code retain it. Regenerate old persisted +representations; there is no compatibility reader for the old Union shape. ### Index Signatures @@ -1688,10 +2096,8 @@ console.log(Schema.decodeUnknownSync(schema)({ a_b: 1, c_d: 2 })) // { aB: 1, cD: 2 } ``` -When parsing sequentially, transformed keys are applied in selection order, so +Transformed keys are applied sequentially in selection order, so the later selected property wins if a transformation produces a duplicate key. -With concurrency greater than `1`, completion order determines which value is -retained. **Example** (Keeping the later selected value when parsing sequentially) @@ -2331,6 +2737,12 @@ While `Schema.declare` works for fixed types like `URL` or `File`, some types ar > **Important:** `declareConstructor` is for types where the **container shape is the same** on both sides: only the inner type parameter changes (e.g. `Box` to `Box`). If you need to convert a structurally different type into your declared type (e.g. `T` to `Box`), first declare `Box` with `declareConstructor`, then define a separate transformation schema to express the conversion. +The declaration parser must complete synchronously, have no observable side +effects, and be safe to evaluate again with the same input. It may reconstruct +an equivalent container (for example, a `ReadonlySet` whose elements were +decoded), but it must not perform a semantic encoded-to-type conversion; express +that conversion with a schema transformation. + ### How the two-step call works `declareConstructor` uses a curried (two-step) call pattern: @@ -3581,6 +3993,8 @@ This is not enforced at the type level, but it may be enforced through a linter - Instances compare structurally with `Equal.equals`, but they do not implement `Equal`. - Instances carry the class prototype at runtime, so `instanceof` checks succeed and methods are callable. +For schema classes, including class-based errors, `make`, `makeOption`, and `makeEffect` use the same constructor parser. An already recognized instance is returned unchanged, without rerunning its constructor. Use `new MyClass(input)` when you need a new instance. + **Example** (Creating an Opaque Struct) ```ts diff --git a/packages/effect/package.json b/packages/effect/package.json index b52e9264010..0fd99d44898 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -25,7 +25,10 @@ "concurrency", "observability" ], - "sideEffects": [], + "sideEffects": [ + "./src/unstable/schema/SchemaJITCompiler/enable.ts", + "./dist/unstable/schema/SchemaJITCompiler/enable.js" + ], "exports": { "./package.json": "./package.json", ".": "./src/index.ts", diff --git a/packages/effect/runtimeperf/README.md b/packages/effect/runtimeperf/README.md index 8265911a735..26ce3a26035 100644 --- a/packages/effect/runtimeperf/README.md +++ b/packages/effect/runtimeperf/README.md @@ -33,6 +33,18 @@ with valid/invalid inputs and first/all error modes, plus BigInt codec operations. The upstream bundle and stack reports are not throughput benchmarks, and the adapters do not define the optional string-format cases. +Run the Moltar `assertLoose` and `parseSafe` cases, including Zod's standard +object JIT, `jitless: true`, and full-schema compilation: + +```sh +pnpm runtimeperf moltar-assert-loose +pnpm runtimeperf moltar-parse-safe +``` + +These fixtures are adapted from +[`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks) +and exercise Effect only through public `SchemaParser` functions. + Select a suite, fixture, shared scenario, tier, family or implementation: ```sh @@ -50,6 +62,17 @@ Override measurement settings: pnpm runtimeperf object-32-valid --rounds 9 --time 500 --warmup-time 150 ``` +Use one batch size for every implementation in a cross-library scenario: + +```sh +pnpm runtimeperf moltar-parse-safe-valid --batch-size 256 +``` + +This is useful when generated functions take only a few nanoseconds and V8 +optimizes different loop sizes differently. The report records the fixed batch +as its calibration mode. The two Moltar suites use a shared batch of 256 by +default; the command-line option overrides suite configuration. + Compare Effect `HEAD` with the working tree: ```sh @@ -121,10 +144,11 @@ adapter family measures the overhead of public APIs that wrap parser issues. ## Measurement model -Each worker validates the fixture before and after measuring. Calibration finds -a batch large enough for the configured target duration. Each implementation -uses its own calibrated batch and executes in a separate process, with rotating -order within the scenario. +Each worker validates the fixture before and after measuring. By default, +calibration finds a batch large enough for the configured target duration. +Suites can pin one shared batch for a scenario, and `--batch-size` can override +it. Each implementation executes in a separate process, with rotating order +within the scenario. Tinybench measures one synchronous batched task. The primary process result is: diff --git a/packages/effect/runtimeperf/compare.mts b/packages/effect/runtimeperf/compare.mts index 7c2cf346ea6..0093e37346d 100644 --- a/packages/effect/runtimeperf/compare.mts +++ b/packages/effect/runtimeperf/compare.mts @@ -37,6 +37,7 @@ Options: --rounds --time --warmup-time + --batch-size Use the same fixed batch for base and head --tier <0-3> --family --fail-on-regression diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 261711dcdb1..1df9fd17c7b 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -11,6 +11,51 @@ "maxRegressionPercent": 5 }, "suites": [ + { + "name": "construction", + "fixtures": [ + { + "file": "suites/construction/fixtures/effect.ts", + "defaults": { + "tier": 1, + "implementation": "effect", + "family": "construction", + "astTags": ["Objects", "Arrays", "Union", "Declaration"], + "path": "valid", + "operation": "make-sync", + "size": 1 + }, + "cases": [ + { "name": "struct", "export": "struct", "scenario": "construction-struct" }, + { "name": "defaults", "export": "defaults", "scenario": "construction-defaults" }, + { "name": "array", "export": "array", "size": 32, "scenario": "construction-array" }, + { "name": "union", "export": "union", "scenario": "construction-union" }, + { "name": "class", "export": "classValue", "scenario": "construction-class" }, + { "name": "cold", "export": "cold", "path": "cold", "scenario": "construction-cold" } + ] + }, + { + "file": "suites/construction/fixtures/effect-compiled.ts", + "defaults": { + "tier": 1, + "implementation": "effect-compiled", + "family": "construction", + "astTags": ["Objects", "Arrays", "Union", "Declaration"], + "path": "valid", + "operation": "make-sync", + "size": 1 + }, + "cases": [ + { "name": "struct-compiled", "export": "struct", "scenario": "construction-struct" }, + { "name": "defaults-compiled", "export": "defaults", "scenario": "construction-defaults" }, + { "name": "array-compiled", "export": "array", "size": 32, "scenario": "construction-array" }, + { "name": "union-compiled", "export": "union", "scenario": "construction-union" }, + { "name": "class-compiled", "export": "classValue", "scenario": "construction-class" }, + { "name": "cold-compiled", "export": "cold", "path": "cold", "scenario": "construction-cold" } + ] + } + ] + }, { "name": "arbitrary", "fixtures": [ @@ -1104,17 +1149,6 @@ "path": "valid", "size": 2 }, - { - "name": "property-order-original", - "export": "propertyOrderOriginal", - "scenario": "property-order-original", - "family": "parse-options", - "astTags": [ - "Objects" - ], - "path": "valid", - "size": 3 - }, { "name": "recursive-tree-depth-16-valid", "export": "recursiveTreeDepth16Valid", @@ -1589,6 +1623,989 @@ ] } ] + }, + { + "name": "moltar-assert-loose", + "batchSize": 256, + "fixtures": [ + { + "file": "suites/moltar-assert-loose/fixtures/effect.ts", + "defaults": { + "tier": 3, + "implementation": "effect", + "family": "moltar-assert-loose", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "size": "moltar-object", + "operation": "is" + }, + "cases": [ + { + "name": "valid-effect", + "export": "assertLooseValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "extra-valid-effect", + "export": "assertLooseExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "invalid-effect", + "export": "assertLooseInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + }, + { + "name": "initialization-first-use-effect", + "export": "initializationSchema", + "scenario": "moltar-assert-loose-effect-initialization", + "operation": "schema-and-is", + "path": "cold" + } + ] + }, + { + "file": "suites/moltar-assert-loose/fixtures/effect-compiled.ts", + "defaults": { + "tier": 3, + "implementation": "effect-compiled", + "family": "moltar-assert-loose", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "size": "moltar-object", + "operation": "is-compiled" + }, + "cases": [ + { + "name": "valid-effect-compiled", + "export": "assertLooseValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "extra-valid-effect-compiled", + "export": "assertLooseExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "invalid-effect-compiled", + "export": "assertLooseInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + }, + { + "name": "initialization-first-use-effect-compiled", + "export": "initializationSchema", + "scenario": "moltar-assert-loose-effect-initialization", + "operation": "schema-compile-and-is", + "path": "cold" + } + ] + }, + { + "file": "suites/moltar-assert-loose/fixtures/zod.ts", + "defaults": { + "tier": 3, + "family": "moltar-assert-loose", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "valid-zod4-parse", + "export": "assertLooseParseValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4", + "operation": "parse-and-assert", + "path": "valid" + }, + { + "name": "extra-valid-zod4-parse", + "export": "assertLooseParseExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4", + "operation": "parse-and-assert", + "path": "extra-valid" + }, + { + "name": "valid-zod4-jitless-parse", + "export": "assertLooseParseJitlessValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-jitless", + "operation": "parse-and-assert-jitless", + "path": "valid" + }, + { + "name": "extra-valid-zod4-jitless-parse", + "export": "assertLooseParseJitlessExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-jitless", + "operation": "parse-and-assert-jitless", + "path": "extra-valid" + }, + { + "name": "valid-zod4-validate", + "export": "assertLooseValidateValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "valid" + }, + { + "name": "extra-valid-zod4-validate", + "export": "assertLooseValidateExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "extra-valid" + }, + { + "name": "invalid-zod4-validate", + "export": "assertLooseValidateInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "invalid" + }, + { + "name": "valid-zod4-compiled", + "export": "assertLooseCompiledValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "valid" + }, + { + "name": "extra-valid-zod4-compiled", + "export": "assertLooseCompiledExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "extra-valid" + }, + { + "name": "invalid-zod4-compiled", + "export": "assertLooseCompiledInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "invalid" + }, + { + "name": "initialization-schema-zod4", + "export": "initializationSchema", + "scenario": "moltar-assert-loose-zod-initialization", + "implementation": "zod4", + "operation": "schema", + "path": "cold" + }, + { + "name": "initialization-compiled-schema-zod4", + "export": "initializationCompiledSchema", + "scenario": "moltar-assert-loose-zod-initialization", + "implementation": "zod4-compiled", + "operation": "schema-and-compile", + "path": "cold" + } + ] + } + ] + }, + { + "name": "moltar-parse-safe", + "batchSize": 256, + "fixtures": [ + { + "file": "suites/moltar-parse-safe/fixtures/effect.ts", + "defaults": { + "tier": 3, + "implementation": "effect", + "family": "moltar-parse-safe", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "size": "moltar-object", + "operation": "decode-sync" + }, + "cases": [ + { + "name": "valid-effect", + "export": "parseSafeValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "extra-valid-effect", + "export": "parseSafeExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "invalid-effect", + "export": "parseSafeInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + }, + { + "name": "initialization-first-use-effect", + "export": "initializationSchema", + "scenario": "moltar-parse-safe-effect-initialization", + "operation": "schema-and-decode", + "path": "cold" + } + ] + }, + { + "file": "suites/moltar-parse-safe/fixtures/effect-compiled.ts", + "defaults": { + "tier": 3, + "implementation": "effect-compiled", + "family": "moltar-parse-safe", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "size": "moltar-object", + "operation": "decode-sync" + }, + "cases": [ + { + "name": "valid-effect-compiled", + "export": "parseSafeValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "extra-valid-effect-compiled", + "export": "parseSafeExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "invalid-effect-compiled", + "export": "parseSafeInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + }, + { + "name": "initialization-first-use-effect-compiled", + "export": "initializationSchema", + "scenario": "moltar-parse-safe-effect-initialization", + "operation": "schema-and-compile-and-decode", + "path": "cold" + } + ] + }, + { + "file": "suites/moltar-parse-safe/fixtures/zod.ts", + "defaults": { + "tier": 3, + "family": "moltar-parse-safe", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "valid-zod4-parse", + "export": "parseSafeValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4", + "operation": "parse", + "path": "valid" + }, + { + "name": "extra-valid-zod4-parse", + "export": "parseSafeExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4", + "operation": "parse", + "path": "extra-valid" + }, + { + "name": "invalid-zod4-parse", + "export": "parseSafeInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4", + "operation": "parse", + "path": "invalid" + }, + { + "name": "valid-zod4-jitless-parse", + "export": "parseSafeJitlessValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "valid" + }, + { + "name": "extra-valid-zod4-jitless-parse", + "export": "parseSafeJitlessExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "extra-valid" + }, + { + "name": "invalid-zod4-jitless-parse", + "export": "parseSafeJitlessInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "invalid" + }, + { + "name": "valid-zod4-compiled", + "export": "parseSafeCompiledValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "valid" + }, + { + "name": "extra-valid-zod4-compiled", + "export": "parseSafeCompiledExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "extra-valid" + }, + { + "name": "invalid-zod4-compiled", + "export": "parseSafeCompiledInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "invalid" + }, + { + "name": "initialization-schema-zod4", + "export": "initializationSchema", + "scenario": "moltar-parse-safe-zod-initialization", + "implementation": "zod4", + "operation": "schema", + "path": "cold" + }, + { + "name": "initialization-compiled-schema-zod4", + "export": "initializationCompiledSchema", + "scenario": "moltar-parse-safe-zod-initialization", + "implementation": "zod4-compiled", + "operation": "schema-and-compile", + "path": "cold" + } + ] + } + ] + }, + { + "name": "schema-compiler", + "fixtures": [ + { + "file": "suites/schema-compiler/fixtures/architecture.ts", + "defaults": { + "tier": 2, + "family": "compiler-architecture", + "operation": "decode-sync", + "astTags": ["Objects", "Declaration", "String", "Number", "Boolean"], + "implementation": "effect-compiled", + "path": "valid" + }, + "cases": [ + { + "name": "declaration-set-valid-effect", + "export": "declarationSet", + "scenario": "compiler-declaration-set-valid", + "implementation": "effect", + "size": 32 + }, + { + "name": "declaration-set-valid-effect-compiled", + "export": "declarationSetCompiled", + "scenario": "compiler-declaration-set-valid", + "size": 32 + }, + { + "name": "checked-transformed-struct-valid-effect", + "export": "checkedStructValidInterpreted", + "scenario": "compiler-checked-transformed-struct-valid", + "implementation": "effect", + "size": 32 + }, + { + "name": "checked-transformed-struct-invalid-effect", + "export": "checkedStructInvalidInterpreted", + "scenario": "compiler-checked-transformed-struct-invalid", + "implementation": "effect", + "path": "invalid", + "size": 32 + }, + { + "name": "sync-decode-valid-effect", + "export": "syncDecodeInterpreted", + "scenario": "compiler-sync-decode-valid", + "implementation": "effect", + "size": 3 + }, + { + "name": "sync-encode-valid-effect", + "export": "syncEncodeInterpreted", + "scenario": "compiler-sync-encode-valid", + "implementation": "effect", + "operation": "encode-sync", + "size": 3 + }, + { + "name": "checked-transformed-struct-valid-effect-compiled", + "export": "checkedStructValid", + "scenario": "compiler-checked-transformed-struct-valid", + "size": 32 + }, + { + "name": "checked-transformed-struct-invalid-effect-compiled", + "export": "checkedStructInvalid", + "scenario": "compiler-checked-transformed-struct-invalid", + "path": "invalid", + "size": 32 + }, + { + "name": "sync-decode-valid-effect-compiled", + "export": "syncDecode", + "scenario": "compiler-sync-decode-valid", + "size": 3 + }, + { + "name": "sync-encode-valid-effect-compiled", + "export": "syncEncode", + "scenario": "compiler-sync-encode-valid", + "operation": "encode-sync", + "size": 3 + } + ] + }, + { + "file": "suites/schema-compiler/fixtures/strict-record.ts", + "defaults": { + "tier": 2, + "family": "strict-record", + "operation": "decode-result", + "astTags": ["Objects", "String", "Number"] + }, + "cases": [ + { + "name": "strict-record-1024-valid-effect", + "export": "valid1024", + "scenario": "strict-record-1024-valid", + "implementation": "effect", + "path": "valid", + "size": 1024 + }, + { + "name": "strict-record-1024-valid-effect-compiled", + "export": "valid1024Compiled", + "scenario": "strict-record-1024-valid", + "implementation": "effect-compiled", + "path": "valid", + "size": 1024 + }, + { + "name": "strict-record-4096-valid-effect", + "export": "valid4096", + "scenario": "strict-record-4096-valid", + "implementation": "effect", + "path": "valid", + "size": 4096 + }, + { + "name": "strict-record-4096-valid-effect-compiled", + "export": "valid4096Compiled", + "scenario": "strict-record-4096-valid", + "implementation": "effect-compiled", + "path": "valid", + "size": 4096 + }, + { + "name": "strict-record-4096-invalid-effect", + "export": "invalid4096", + "scenario": "strict-record-4096-invalid", + "implementation": "effect", + "path": "invalid", + "size": 4096 + }, + { + "name": "strict-record-4096-invalid-effect-compiled", + "export": "invalid4096Compiled", + "scenario": "strict-record-4096-invalid", + "implementation": "effect-compiled", + "path": "invalid", + "size": 4096 + } + ] + }, + { + "file": "suites/schema-compiler/fixtures/coverage.ts", + "defaults": { + "tier": 2, + "operation": "decode-sync", + "astTags": [] + }, + "cases": [ + { + "name": "array-100-valid-effect", + "export": "array100Valid", + "scenario": "schema-compiler-array-100-valid", + "implementation": "effect", + "family": "arrays", + "path": "valid", + "size": 100 + }, + { + "name": "array-100-valid-effect-compiled", + "export": "array100ValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-array-100-valid", + "implementation": "effect-compiled", + "family": "arrays", + "path": "valid", + "size": 100 + }, + { + "name": "array-100-invalid-last-effect", + "export": "array100InvalidLast", + "scenario": "schema-compiler-array-100-invalid-last", + "implementation": "effect", + "family": "arrays", + "path": "invalid", + "size": 100 + }, + { + "name": "array-100-invalid-last-effect-compiled", + "export": "array100InvalidLastCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-array-100-invalid-last", + "implementation": "effect-compiled", + "family": "arrays", + "path": "invalid", + "size": 100 + }, + { + "name": "tuple-rest-valid-effect", + "export": "tupleRestValid", + "scenario": "schema-compiler-tuple-rest-valid", + "implementation": "effect", + "family": "arrays", + "path": "valid", + "size": 34 + }, + { + "name": "tuple-rest-valid-effect-compiled", + "export": "tupleRestValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-tuple-rest-valid", + "implementation": "effect-compiled", + "family": "arrays", + "path": "valid", + "size": 34 + }, + { + "name": "optional-struct-valid-effect", + "export": "optionalStructValid", + "scenario": "schema-compiler-optional-struct-valid", + "implementation": "effect", + "family": "objects", + "path": "valid", + "size": 32 + }, + { + "name": "optional-struct-valid-effect-compiled", + "export": "optionalStructValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-optional-struct-valid", + "implementation": "effect-compiled", + "family": "objects", + "path": "valid", + "size": 32 + }, + { + "name": "record-valid-effect", + "export": "recordValid", + "scenario": "schema-compiler-record-valid", + "implementation": "effect", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "record-valid-effect-compiled", + "export": "recordValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-record-valid", + "implementation": "effect-compiled", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "template-record-valid-effect", + "export": "templateRecordValid", + "scenario": "schema-compiler-template-record-valid", + "implementation": "effect", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "template-record-valid-effect-compiled", + "export": "templateRecordValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-template-record-valid", + "implementation": "effect-compiled", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "struct-with-record-valid-effect", + "export": "structWithRecordValid", + "scenario": "schema-compiler-struct-with-record-valid", + "implementation": "effect", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "struct-with-record-valid-effect-compiled", + "export": "structWithRecordValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-struct-with-record-valid", + "implementation": "effect-compiled", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "number-record-valid-effect", + "export": "numberRecordValid", + "scenario": "schema-compiler-number-record-valid", + "implementation": "effect", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "number-record-valid-effect-compiled", + "export": "numberRecordValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-number-record-valid", + "implementation": "effect-compiled", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "transformed-key-record-valid-effect", + "export": "transformedKeyRecordValid", + "scenario": "schema-compiler-transformed-key-record-valid", + "implementation": "effect", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "transformed-key-record-valid-effect-compiled", + "export": "transformedKeyRecordValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-transformed-key-record-valid", + "implementation": "effect-compiled", + "family": "records", + "path": "valid", + "size": 32 + }, + { + "name": "encoding-checked-struct-valid-effect", + "export": "encodingCheckedStructValid", + "scenario": "schema-compiler-encoding-checked-struct-valid", + "implementation": "effect", + "family": "checks", + "path": "valid", + "size": 32 + }, + { + "name": "encoding-checked-struct-valid-effect-compiled", + "export": "encodingCheckedStructValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-encoding-checked-struct-valid", + "implementation": "effect-compiled", + "family": "checks", + "path": "valid", + "size": 32 + }, + { + "name": "literal-100-valid-last-effect", + "export": "literal100ValidLast", + "scenario": "schema-compiler-literal-100-valid-last", + "implementation": "effect", + "family": "unions", + "path": "valid", + "size": 100 + }, + { + "name": "literal-100-valid-last-effect-compiled", + "export": "literal100ValidLastCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-literal-100-valid-last", + "implementation": "effect-compiled", + "family": "unions", + "path": "valid", + "size": 100 + }, + { + "name": "literal-100-invalid-effect", + "export": "literal100Invalid", + "scenario": "schema-compiler-literal-100-invalid", + "implementation": "effect", + "family": "unions", + "path": "invalid", + "size": 100 + }, + { + "name": "literal-100-invalid-effect-compiled", + "export": "literal100InvalidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-literal-100-invalid", + "implementation": "effect-compiled", + "family": "unions", + "path": "invalid", + "size": 100 + }, + { + "name": "tagged-union-100-valid-last-effect", + "export": "taggedUnion100ValidLast", + "scenario": "schema-compiler-tagged-union-100-valid-last", + "implementation": "effect", + "family": "unions", + "path": "valid", + "size": 100 + }, + { + "name": "tagged-union-100-valid-last-effect-compiled", + "export": "taggedUnion100ValidLastCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-tagged-union-100-valid-last", + "implementation": "effect-compiled", + "family": "unions", + "path": "valid", + "size": 100 + }, + { + "name": "tagged-union-100-invalid-effect", + "export": "taggedUnion100Invalid", + "scenario": "schema-compiler-tagged-union-100-invalid", + "implementation": "effect", + "family": "unions", + "path": "invalid", + "size": 100 + }, + { + "name": "tagged-union-100-invalid-effect-compiled", + "export": "taggedUnion100InvalidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-tagged-union-100-invalid", + "implementation": "effect-compiled", + "family": "unions", + "path": "invalid", + "size": 100 + }, + { + "name": "checked-string-valid-effect", + "export": "checkedStringValid", + "scenario": "schema-compiler-checked-string-valid", + "implementation": "effect", + "family": "checks", + "path": "valid", + "size": 1 + }, + { + "name": "checked-string-valid-effect-compiled", + "export": "checkedStringValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-checked-string-valid", + "implementation": "effect-compiled", + "family": "checks", + "path": "valid", + "size": 1 + }, + { + "name": "template-literal-valid-effect", + "export": "templateLiteralValid", + "scenario": "schema-compiler-template-literal-valid", + "implementation": "effect", + "family": "template-literals", + "path": "valid", + "size": 1 + }, + { + "name": "template-literal-valid-effect-compiled", + "export": "templateLiteralValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-template-literal-valid", + "implementation": "effect-compiled", + "family": "template-literals", + "path": "valid", + "size": 1 + }, + { + "name": "transformation-struct-valid-effect", + "export": "transformationStructValid", + "scenario": "schema-compiler-transformation-struct-valid", + "implementation": "effect", + "family": "transformations", + "path": "valid", + "size": 32 + }, + { + "name": "transformation-struct-valid-effect-compiled", + "export": "transformationStructValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-transformation-struct-valid", + "implementation": "effect-compiled", + "family": "transformations", + "path": "valid", + "size": 32 + }, + { + "name": "transformation-root-valid-effect", + "export": "transformationRootValid", + "scenario": "schema-compiler-transformation-root-valid", + "implementation": "effect", + "family": "transformations", + "path": "valid", + "size": 1 + }, + { + "name": "transformation-root-valid-effect-compiled", + "export": "transformationRootValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-transformation-root-valid", + "implementation": "effect-compiled", + "family": "transformations", + "path": "valid", + "size": 1 + }, + { + "name": "transformation-root-invalid-effect", + "export": "transformationRootInvalid", + "scenario": "schema-compiler-transformation-root-invalid", + "implementation": "effect", + "family": "transformations", + "path": "invalid", + "size": 1 + }, + { + "name": "transformation-uppercase-valid-effect", + "export": "transformationUpperCaseValid", + "scenario": "schema-compiler-transformation-uppercase-valid", + "implementation": "effect", + "family": "transformations", + "path": "valid", + "size": 1 + }, + { + "name": "transformation-uppercase-valid-effect-compiled", + "export": "transformationUpperCaseValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-transformation-uppercase-valid", + "implementation": "effect-compiled", + "family": "transformations", + "path": "valid", + "size": 1 + }, + { + "name": "transformation-root-invalid-effect-compiled", + "export": "transformationRootInvalidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-transformation-root-invalid", + "implementation": "effect-compiled", + "family": "transformations", + "path": "invalid", + "size": 1 + }, + { + "name": "transformation-output-invalid-effect", + "export": "transformationOutputInvalid", + "scenario": "schema-compiler-transformation-output-invalid", + "implementation": "effect", + "family": "transformations", + "path": "invalid", + "size": 1 + }, + { + "name": "transformation-output-invalid-effect-compiled", + "export": "transformationOutputInvalidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-transformation-output-invalid", + "implementation": "effect-compiled", + "family": "transformations", + "path": "invalid", + "size": 1 + }, + { + "name": "middleware-struct-valid-effect", + "export": "middlewareStructValid", + "scenario": "schema-compiler-middleware-struct-valid", + "implementation": "effect", + "family": "middleware", + "path": "valid", + "size": 32 + }, + { + "name": "middleware-struct-valid-effect-compiled", + "export": "middlewareStructValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-middleware-struct-valid", + "implementation": "effect-compiled", + "family": "middleware", + "path": "valid", + "size": 32 + }, + { + "name": "recursive-node-valid-effect", + "export": "recursiveNodeValid", + "scenario": "schema-compiler-recursive-node-valid", + "implementation": "effect", + "family": "recursion", + "path": "valid", + "size": 121 + }, + { + "name": "recursive-node-valid-effect-compiled", + "export": "recursiveNodeValidCompiled", + "file": "suites/schema-compiler/fixtures/coverage-compiled.ts", + "scenario": "schema-compiler-recursive-node-valid", + "implementation": "effect-compiled", + "family": "recursion", + "path": "valid", + "size": 121 + } + ] + } + ] } ] } diff --git a/packages/effect/runtimeperf/run.mts b/packages/effect/runtimeperf/run.mts index b97665f60bb..efcebc9cdfc 100644 --- a/packages/effect/runtimeperf/run.mts +++ b/packages/effect/runtimeperf/run.mts @@ -29,9 +29,10 @@ Options: --rounds --time --warmup-time + --batch-size Use the same fixed batch for every selected fixture --tier <0-3> --family - --implementation + --implementation ` const rotate = (items, offset) => items.map((_, index) => items[(index + offset) % items.length]) diff --git a/packages/effect/runtimeperf/suites/construction/fixtures/effect-compiled.ts b/packages/effect/runtimeperf/suites/construction/fixtures/effect-compiled.ts new file mode 100644 index 00000000000..24912bded07 --- /dev/null +++ b/packages/effect/runtimeperf/suites/construction/fixtures/effect-compiled.ts @@ -0,0 +1,3 @@ +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +export { array, classValue, cold, defaults, struct, union } from "./effect.ts" diff --git a/packages/effect/runtimeperf/suites/construction/fixtures/effect.ts b/packages/effect/runtimeperf/suites/construction/fixtures/effect.ts new file mode 100644 index 00000000000..262a6f9622c --- /dev/null +++ b/packages/effect/runtimeperf/suites/construction/fixtures/effect.ts @@ -0,0 +1,56 @@ +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +import assert from "node:assert/strict" + +export const struct = () => { + const schema = Schema.Struct({ a: Schema.String, b: Schema.Number }) + const make = SchemaParser.make(schema) + const input = { a: "a", b: 1 } + return { run: () => make(input), validate: (out) => assert.deepEqual(out, input) } +} + +export const defaults = () => { + const schema = Schema.Struct({ + a: Schema.String, + b: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + }) + const make = SchemaParser.make(schema) + const input = { a: "a" } + return { run: () => make(input), validate: (out) => assert.deepEqual(out, { a: "a", b: 1 }) } +} + +export const array = () => { + const schema = Schema.Array(Schema.Struct({ a: Schema.String })) + const make = SchemaParser.make(schema) + const input = Array.from({ length: 32 }, () => ({ a: "a" })) + return { run: () => make(input), validate: (out) => assert.deepEqual(out, input) } +} + +export const union = () => { + const schema = Schema.Union([ + Schema.Struct({ _tag: Schema.tag("A"), a: Schema.String }), + Schema.Struct({ _tag: Schema.tag("B"), b: Schema.Number }) + ]) + const make = SchemaParser.make(schema) + const input = { a: "a" } + return { run: () => make(input), validate: (out) => assert.deepEqual(out, { _tag: "A", a: "a" }) } +} + +export const classValue = () => { + class A extends Schema.Class("A")({ a: Schema.String }) {} + const make = SchemaParser.make(A) + const input = { a: "a" } + return { + run: () => make(input), + validate: (out) => { + assert(out instanceof A) + assert.equal(out.a, "a") + } + } +} + +export const cold = () => ({ + run: () => SchemaParser.make(Schema.Struct({ a: Schema.String }))({ a: "a" }), + validate: (out) => assert.deepEqual(out, { a: "a" }) +}) diff --git a/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/data.ts b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/data.ts new file mode 100644 index 00000000000..d247f4eb016 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/data.ts @@ -0,0 +1,31 @@ +// Extracted from moltar/typescript-runtime-type-benchmarks at +// d1791e68fc1108ef47da50547e80900e177a9d10. +// Upstream license: MIT, declared in package.json at that commit. +export const validData = Object.freeze({ + number: 1, + negNumber: -1, + maxNumber: Number.MAX_VALUE, + string: "string", + longString: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Vivendum intellegat et qui, ei denique consequuntur vix. Semper aeterno percipit ut his, sea ex utinam referrentur repudiandae. No epicuri hendrerit consetetur sit, sit dicta adipiscing ex, in facete detracto deterruisset duo. Quot populo ad qui. Sit fugit nostrum et. Ad per diam dicant interesset, lorem iusto sensibus ut sed. No dicam aperiam vis. Pri posse graeco definitiones cu, id eam populo quaestio adipiscing, usu quod malorum te. Ex nam agam veri, dicunt efficiantur ad qui, ad legere adversarium sit. Commune platonem mel id, brute adipiscing duo an. Vivendum intellegat et qui, ei denique consequuntur vix. Offendit eleifend moderatius ex vix, quem odio mazim et qui, purto expetendis cotidieque quo cu, veri persius vituperata ei nec. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + boolean: true, + deeplyNested: { + foo: "bar", + num: 1, + bool: false + } +}) + +export const validDataWithExtras = Object.freeze({ + ...validData, + extraAttribute: "foo", + deeplyNested: { + ...validData.deeplyNested, + extraNestedAttribute: "bar" + } +}) + +export const invalidData = Object.freeze({ + ...validData, + number: "invalid" +}) diff --git a/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/effect-compiled.ts b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/effect-compiled.ts new file mode 100644 index 00000000000..7ceed1b7bf5 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/effect-compiled.ts @@ -0,0 +1,48 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import assert from "node:assert/strict" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +const makeSchema = () => + Schema.Struct({ + number: Schema.Number, + negNumber: Schema.Number, + maxNumber: Schema.Number, + string: Schema.String, + longString: Schema.String, + boolean: Schema.Boolean, + deeplyNested: Schema.Struct({ + foo: Schema.String, + num: Schema.Number, + bool: Schema.Boolean + }) + }) + +const assertionCase = (input: unknown) => () => { + const run = SchemaParser.is(makeSchema()) + return { + run: () => { + if (!run(input)) throw new Error("Invalid") + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +export const assertLooseValid = assertionCase(validData) +export const assertLooseExtraValid = assertionCase(validDataWithExtras) + +export const assertLooseInvalid = () => { + const run = SchemaParser.is(makeSchema()) + return { + run: () => run(invalidData), + validate: (result: unknown) => assert.equal(result, false) + } +} + +export const initializationSchema = () => ({ + run: () => SchemaParser.is(makeSchema())(validData), + validate: (result: unknown) => assert.equal(result, true) +}) diff --git a/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/effect.ts b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/effect.ts new file mode 100644 index 00000000000..a3ecc3e6452 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/effect.ts @@ -0,0 +1,49 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +import assert from "node:assert/strict" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +// Adapted to the Effect 4 boolean guard API from the @effect/schema adapter in +// moltar/typescript-runtime-type-benchmarks at d1791e68fc1108ef47da50547e80900e177a9d10. +// Upstream license: MIT, declared in package.json at that commit. +const makeSchema = () => + Schema.Struct({ + number: Schema.Number, + negNumber: Schema.Number, + maxNumber: Schema.Number, + string: Schema.String, + longString: Schema.String, + boolean: Schema.Boolean, + deeplyNested: Schema.Struct({ + foo: Schema.String, + num: Schema.Number, + bool: Schema.Boolean + }) + }) + +const assertionCase = (input: unknown) => () => { + const run = SchemaParser.is(makeSchema()) + return { + run: () => { + if (!run(input)) throw new Error("Invalid") + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +export const assertLooseValid = assertionCase(validData) +export const assertLooseExtraValid = assertionCase(validDataWithExtras) + +export const assertLooseInvalid = () => { + const run = SchemaParser.is(makeSchema()) + return { + run: () => run(invalidData), + validate: (result: unknown) => assert.equal(result, false) + } +} + +export const initializationSchema = () => ({ + run: () => SchemaParser.is(makeSchema())(validData), + validate: (result: unknown) => assert.equal(result, true) +}) diff --git a/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/zod.ts b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/zod.ts new file mode 100644 index 00000000000..b019c478a25 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-assert-loose/fixtures/zod.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +// The parse and compiled cases reproduce the adapters proposed in +// moltar/typescript-runtime-type-benchmarks#2329 at +// 34ebbad559318a8b5cc60fb92204db4c162fce50. The uncompiled validate cases +// isolate z.validate from z.compile, while the jitless parse cases isolate the +// default object JIT, so the three costs are not conflated. +// Upstream license: MIT, declared in package.json at the pinned base commit. +const makeSchema = () => + z + .object({ + number: z.number(), + negNumber: z.number(), + maxNumber: z.number(), + string: z.string(), + longString: z.string(), + boolean: z.boolean(), + deeplyNested: z + .object({ + foo: z.string(), + num: z.number(), + bool: z.boolean() + }) + .passthrough() + }) + .passthrough() + +const parseCase = (input: unknown) => () => { + const schema = makeSchema() + return { + run: () => { + schema.parse(input) + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +const parseJitlessCase = (input: unknown) => () => { + const schema = makeSchema() + return { + run: () => { + schema.parse(input, { jitless: true }) + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +const assertValidateCase = (input: unknown, compile: boolean) => () => { + const schema = compile ? z.compile(makeSchema()) : makeSchema() + return { + run: () => { + if (!z.validate(schema, input)) throw new Error("Invalid") + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +export const assertLooseParseValid = parseCase(validData) +export const assertLooseParseExtraValid = parseCase(validDataWithExtras) + +export const assertLooseParseJitlessValid = parseJitlessCase(validData) +export const assertLooseParseJitlessExtraValid = parseJitlessCase(validDataWithExtras) + +export const assertLooseValidateValid = assertValidateCase(validData, false) +export const assertLooseValidateExtraValid = assertValidateCase(validDataWithExtras, false) + +export const assertLooseCompiledValid = assertValidateCase(validData, true) +export const assertLooseCompiledExtraValid = assertValidateCase(validDataWithExtras, true) + +const invalidCase = (compile: boolean) => () => { + const schema = compile ? z.compile(makeSchema()) : makeSchema() + return { + run: () => z.validate(schema, invalidData), + validate: (result: unknown) => assert.equal(result, false) + } +} + +export const assertLooseValidateInvalid = invalidCase(false) +export const assertLooseCompiledInvalid = invalidCase(true) + +export const initializationSchema = () => ({ + run: makeSchema, + validate: (schema: z.ZodObject) => assert.equal(schema.type, "object") +}) + +export const initializationCompiledSchema = () => ({ + run: () => z.compile(makeSchema()), + validate: (schema: z.ZodObject) => assert.equal(schema.type, "object") +}) diff --git a/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/data.ts b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/data.ts new file mode 100644 index 00000000000..d247f4eb016 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/data.ts @@ -0,0 +1,31 @@ +// Extracted from moltar/typescript-runtime-type-benchmarks at +// d1791e68fc1108ef47da50547e80900e177a9d10. +// Upstream license: MIT, declared in package.json at that commit. +export const validData = Object.freeze({ + number: 1, + negNumber: -1, + maxNumber: Number.MAX_VALUE, + string: "string", + longString: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Vivendum intellegat et qui, ei denique consequuntur vix. Semper aeterno percipit ut his, sea ex utinam referrentur repudiandae. No epicuri hendrerit consetetur sit, sit dicta adipiscing ex, in facete detracto deterruisset duo. Quot populo ad qui. Sit fugit nostrum et. Ad per diam dicant interesset, lorem iusto sensibus ut sed. No dicam aperiam vis. Pri posse graeco definitiones cu, id eam populo quaestio adipiscing, usu quod malorum te. Ex nam agam veri, dicunt efficiantur ad qui, ad legere adversarium sit. Commune platonem mel id, brute adipiscing duo an. Vivendum intellegat et qui, ei denique consequuntur vix. Offendit eleifend moderatius ex vix, quem odio mazim et qui, purto expetendis cotidieque quo cu, veri persius vituperata ei nec. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + boolean: true, + deeplyNested: { + foo: "bar", + num: 1, + bool: false + } +}) + +export const validDataWithExtras = Object.freeze({ + ...validData, + extraAttribute: "foo", + deeplyNested: { + ...validData.deeplyNested, + extraNestedAttribute: "bar" + } +}) + +export const invalidData = Object.freeze({ + ...validData, + number: "invalid" +}) diff --git a/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/effect-compiled.ts b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/effect-compiled.ts new file mode 100644 index 00000000000..0d6ac77d83c --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/effect-compiled.ts @@ -0,0 +1,52 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import assert from "node:assert/strict" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +const makeSchema = () => + Schema.Struct({ + number: Schema.Number, + negNumber: Schema.Number, + maxNumber: Schema.Number, + string: Schema.String, + longString: Schema.String, + boolean: Schema.Boolean, + deeplyNested: Schema.Struct({ + foo: Schema.String, + num: Schema.Number, + bool: Schema.Boolean + }) + }) + +const parseCase = (input: unknown) => () => { + const parse = SchemaParser.decodeUnknownSync(makeSchema()) + return { + run: () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, validData) + } +} + +export const parseSafeValid = parseCase(validData) +export const parseSafeExtraValid = parseCase(validDataWithExtras) + +export const parseSafeInvalid = () => { + const parse = SchemaParser.decodeUnknownSync(makeSchema()) + return { + run: () => { + try { + parse(invalidData) + return false + } catch { + return true + } + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +export const initializationSchema = () => ({ + run: () => SchemaParser.decodeUnknownSync(makeSchema())(validData), + validate: (result: unknown) => assert.deepEqual(result, validData) +}) diff --git a/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/effect.ts b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/effect.ts new file mode 100644 index 00000000000..da6879f515d --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/effect.ts @@ -0,0 +1,53 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +import assert from "node:assert/strict" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +// Adapted to Effect 4 from the @effect/schema adapter in +// moltar/typescript-runtime-type-benchmarks at d1791e68fc1108ef47da50547e80900e177a9d10. +// Upstream license: MIT, declared in package.json at that commit. +const makeSchema = () => + Schema.Struct({ + number: Schema.Number, + negNumber: Schema.Number, + maxNumber: Schema.Number, + string: Schema.String, + longString: Schema.String, + boolean: Schema.Boolean, + deeplyNested: Schema.Struct({ + foo: Schema.String, + num: Schema.Number, + bool: Schema.Boolean + }) + }) + +const parseCase = (input: unknown) => () => { + const parse = SchemaParser.decodeUnknownSync(makeSchema()) + return { + run: () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, validData) + } +} + +export const parseSafeValid = parseCase(validData) +export const parseSafeExtraValid = parseCase(validDataWithExtras) + +export const parseSafeInvalid = () => { + const parse = SchemaParser.decodeUnknownSync(makeSchema()) + return { + run: () => { + try { + parse(invalidData) + return false + } catch { + return true + } + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +export const initializationSchema = () => ({ + run: () => SchemaParser.decodeUnknownSync(makeSchema())(validData), + validate: (result: unknown) => assert.deepEqual(result, validData) +}) diff --git a/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/zod.ts b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/zod.ts new file mode 100644 index 00000000000..5e740212c0c --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar-parse-safe/fixtures/zod.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +// The standard and compiled cases reproduce the adapters proposed in +// moltar/typescript-runtime-type-benchmarks#2329 at +// 34ebbad559318a8b5cc60fb92204db4c162fce50. The jitless cases isolate the +// default object JIT from full-schema compilation. +// Upstream license: MIT, declared in package.json at the pinned base commit. +const makeSchema = () => + z.object({ + number: z.number(), + negNumber: z.number(), + maxNumber: z.number(), + string: z.string(), + longString: z.string(), + boolean: z.boolean(), + deeplyNested: z.object({ + foo: z.string(), + num: z.number(), + bool: z.boolean() + }) + }) + +const parseCase = (input: unknown) => () => { + const schema = makeSchema() + return { + run: () => schema.parse(input), + validate: (result: unknown) => assert.deepEqual(result, validData) + } +} + +const parseJitlessCase = (input: unknown) => () => { + const schema = makeSchema() + return { + run: () => schema.parse(input, { jitless: true }), + validate: (result: unknown) => assert.deepEqual(result, validData) + } +} + +const parseCompiledCase = (input: unknown) => () => { + const schema = z.compile(makeSchema()) + return { + run: () => schema.parse(input), + validate: (result: unknown) => assert.deepEqual(result, validData) + } +} + +export const parseSafeValid = parseCase(validData) +export const parseSafeExtraValid = parseCase(validDataWithExtras) + +export const parseSafeJitlessValid = parseJitlessCase(validData) +export const parseSafeJitlessExtraValid = parseJitlessCase(validDataWithExtras) + +export const parseSafeCompiledValid = parseCompiledCase(validData) +export const parseSafeCompiledExtraValid = parseCompiledCase(validDataWithExtras) + +const invalidCase = (parse: () => unknown) => ({ + run: () => { + try { + parse() + return false + } catch { + return true + } + }, + validate: (result: unknown) => assert.equal(result, true) +}) + +export const parseSafeInvalid = () => { + const schema = makeSchema() + return invalidCase(() => schema.parse(invalidData)) +} + +export const parseSafeJitlessInvalid = () => { + const schema = makeSchema() + return invalidCase(() => schema.parse(invalidData, { jitless: true })) +} + +export const parseSafeCompiledInvalid = () => { + const schema = z.compile(makeSchema()) + return invalidCase(() => schema.parse(invalidData)) +} + +export const initializationSchema = () => ({ + run: makeSchema, + validate: (schema: z.ZodObject) => assert.equal(schema.type, "object") +}) + +export const initializationCompiledSchema = () => ({ + run: () => z.compile(makeSchema()), + validate: (schema: z.ZodObject) => assert.equal(schema.type, "object") +}) diff --git a/packages/effect/runtimeperf/suites/schema-compiler/fixtures/architecture.ts b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/architecture.ts new file mode 100644 index 00000000000..b39eee399d5 --- /dev/null +++ b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/architecture.ts @@ -0,0 +1,57 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +import * as SchemaJITCompiler from "effect/unstable/schema/SchemaJITCompiler" +import assert from "node:assert/strict" + +const setCase = (compiled: boolean) => { + const child = Schema.Struct({ name: Schema.String, count: Schema.Number }) + const schema = Schema.ReadonlySet(child) + const input = new Set(Array.from({ length: 32 }, (_, count) => ({ name: "value", count }))) + if (compiled) SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + return { run: () => decode(input), validate: (result) => assert.deepEqual(result, input) } +} + +export const declarationSet = () => setCase(false) +export const declarationSetCompiled = () => setCase(true) + +const checkedStructCase = (invalid: boolean, compiled: boolean) => { + const schema = Schema.Struct(Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`field${i}`, Schema.NumberFromString]) + )).check(Schema.makeFilter((output) => output.field31 > 0)) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`field${i}`, String(invalid ? -i : i)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`field${i}`, i])) + if (compiled) SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + return { + run: invalid ? + () => { + try { + decode(input) + return false + } catch { + return true + } + } : + () => decode(input), + validate: (result) => assert.deepEqual(result, invalid ? true : expected) + } +} + +export const checkedStructValid = () => checkedStructCase(false, true) +export const checkedStructInvalid = () => checkedStructCase(true, true) +export const checkedStructValidInterpreted = () => checkedStructCase(false, false) +export const checkedStructInvalidInterpreted = () => checkedStructCase(true, false) + +const syncCase = (encode: boolean, compiled: boolean) => { + const schema = Schema.Struct({ name: Schema.String, count: Schema.Number, active: Schema.Boolean }) + const input = { name: "value", count: 1, active: true } + if (compiled) SchemaJITCompiler.enable(schema.ast) + const parse = encode ? SchemaParser.encodeUnknownSync(schema) : SchemaParser.decodeUnknownSync(schema) + return { run: () => parse(input), validate: (result) => assert.deepEqual(result, input) } +} + +export const syncDecode = () => syncCase(false, true) +export const syncEncode = () => syncCase(true, true) +export const syncDecodeInterpreted = () => syncCase(false, false) +export const syncEncodeInterpreted = () => syncCase(true, false) diff --git a/packages/effect/runtimeperf/suites/schema-compiler/fixtures/coverage-compiled.ts b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/coverage-compiled.ts new file mode 100644 index 00000000000..6b2839ccfcc --- /dev/null +++ b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/coverage-compiled.ts @@ -0,0 +1,28 @@ +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" + +export { + array100InvalidLast as array100InvalidLastCompiled, + array100Valid as array100ValidCompiled, + checkedStringValid as checkedStringValidCompiled, + encodingCheckedStructValid as encodingCheckedStructValidCompiled, + literal100Invalid as literal100InvalidCompiled, + literal100ValidLast as literal100ValidLastCompiled, + middlewareStructValid as middlewareStructValidCompiled, + numberRecordValid as numberRecordValidCompiled, + optionalStructValid as optionalStructValidCompiled, + recordValid as recordValidCompiled, + recursiveNodeValid as recursiveNodeValidCompiled, + structWithRecordValid as structWithRecordValidCompiled, + taggedUnion100Invalid as taggedUnion100InvalidCompiled, + taggedUnion100ValidLast as taggedUnion100ValidLastCompiled, + templateLiteralValid as templateLiteralValidCompiled, + templateRecordValid as templateRecordValidCompiled, + transformationOutputInvalid as transformationOutputInvalidCompiled, + transformationRootInvalid as transformationRootInvalidCompiled, + transformationRootValid as transformationRootValidCompiled, + transformationStructValid as transformationStructValidCompiled, + transformationUpperCaseValid as transformationUpperCaseValidCompiled, + transformedKeyRecordValid as transformedKeyRecordValidCompiled, + tupleRestValid as tupleRestValidCompiled +} from "./coverage.ts" diff --git a/packages/effect/runtimeperf/suites/schema-compiler/fixtures/coverage.ts b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/coverage.ts new file mode 100644 index 00000000000..560be88240b --- /dev/null +++ b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/coverage.ts @@ -0,0 +1,226 @@ +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +import * as SchemaTransformation from "effect/SchemaTransformation" +import assert from "node:assert/strict" + +const validCase = (schema, input, expected = input) => () => { + const decode = SchemaParser.decodeUnknownSync(schema) + return { + run: () => decode(input), + validate: (result) => assert.deepEqual(result, expected) + } +} + +const invalidCase = (schema, input) => () => { + const decode = SchemaParser.decodeUnknownSync(schema) + return { + run: () => { + try { + decode(input) + return false + } catch { + return true + } + }, + validate: (result) => assert.equal(result, true) + } +} + +const array100 = Schema.Array(Schema.String) +const array100Input = Array.from({ length: 100 }, (_, index) => `value${index}`) +const array100Invalid = [...array100Input.slice(0, -1), 99] + +export const array100Valid = validCase(array100, array100Input) +export const array100InvalidLast = invalidCase(array100, array100Invalid) + +const tupleRest = Schema.TupleWithRest( + Schema.Tuple([Schema.String]), + [Schema.Number, Schema.Boolean] +) +const tupleRestInput = ["head", ...Array.from({ length: 32 }, (_, index) => index), true] + +export const tupleRestValid = validCase(tupleRest, tupleRestInput) + +const optionalFields = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, Schema.optionalKey(Schema.String)]) +) +const optionalStruct = Schema.Struct(optionalFields) +const optionalStructInput = Object.fromEntries( + Array.from({ length: 16 }, (_, index) => [`field${index * 2}`, `value${index}`]) +) + +export const optionalStructValid = validCase(optionalStruct, optionalStructInput) + +const record = Schema.Record( + Schema.String, + Schema.Struct({ text: Schema.String, count: Schema.Number, active: Schema.Boolean }) +) +const recordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [ + `entry${index}`, + { text: `value${index}`, count: index, active: index % 2 === 0 } + ]) +) + +export const recordValid = validCase(record, recordInput) + +const templateRecord = Schema.Record( + Schema.TemplateLiteral(["data-", Schema.String]), + Schema.Number +) +const templateRecordInput = Object.fromEntries( + Array.from({ length: 64 }, (_, index) => [ + index % 2 === 0 ? `data-${index}` : `ignored-${index}`, + index + ]) +) +const templateRecordOutput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`data-${index * 2}`, index * 2]) +) + +export const templateRecordValid = validCase(templateRecord, templateRecordInput, templateRecordOutput) + +const structWithRecord = Schema.StructWithRest( + Schema.Struct( + Object.fromEntries(Array.from({ length: 16 }, (_, index) => [`field${index}`, Schema.Number])) + ), + [Schema.Record(Schema.String, Schema.Number)] +) +const structWithRecordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, index]) +) + +export const structWithRecordValid = validCase(structWithRecord, structWithRecordInput) + +const numberRecord = Schema.Record(Schema.Number, Schema.Number) +const numberRecordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [String(index), index]) +) + +export const numberRecordValid = validCase(numberRecord, numberRecordInput) + +const transformedKeyRecord = Schema.Record( + Schema.String.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.toUpperCase())), + Schema.Number +) +const transformedKeyRecordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, index]) +) +const transformedKeyRecordOutput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`FIELD${index}`, index]) +) + +export const transformedKeyRecordValid = validCase( + transformedKeyRecord, + transformedKeyRecordInput, + transformedKeyRecordOutput +) + +const encodingCheckedStruct = Schema.Struct( + Object.fromEntries(Array.from({ length: 32 }, (_, index) => [`field${index}`, Schema.String])) +).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => input.field0.length > 0)), + Schema.flip +) +const encodingCheckedStructInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, `value${index}`]) +) + +export const encodingCheckedStructValid = validCase(encodingCheckedStruct, encodingCheckedStructInput) + +const literal100 = Schema.Literals(Array.from({ length: 100 }, (_, index) => `value${index}`)) + +export const literal100ValidLast = validCase(literal100, "value99") +export const literal100Invalid = invalidCase(literal100, "missing") + +const taggedUnion100 = Schema.Union( + Array.from({ length: 100 }, (_, index) => + Schema.Struct({ + kind: Schema.Literal(`value${index}`), + text: Schema.String, + count: Schema.Number + })) +) +const taggedUnion100Input = { kind: "value99", text: "value", count: 99, extra: true } +const taggedUnion100Output = { kind: "value99", text: "value", count: 99 } + +export const taggedUnion100ValidLast = validCase(taggedUnion100, taggedUnion100Input, taggedUnion100Output) +export const taggedUnion100Invalid = invalidCase( + taggedUnion100, + { kind: "missing", text: "value", count: 99 } +) + +const checkedString = Schema.String.check(Schema.isMinLength(2)) + +export const checkedStringValid = validCase(checkedString, "value") + +const templateLiteral = Schema.TemplateLiteral(["prefix-", Schema.String]) + +export const templateLiteralValid = validCase(templateLiteral, "prefix-value") + +const transformationFields = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, Schema.FiniteFromString]) +) +const transformationStruct = Schema.Struct(transformationFields) +const transformationStructInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, String(index)]) +) +const transformationStructOutput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, index]) +) + +export const transformationStructValid = validCase( + transformationStruct, + transformationStructInput, + transformationStructOutput +) + +export const transformationRootValid = validCase(Schema.FiniteFromString, "123", 123) +export const transformationRootInvalid = invalidCase(Schema.FiniteFromString, "invalid") +export const transformationUpperCaseValid = validCase( + Schema.String.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.toUpperCase())), + "value", + "VALUE" +) + +const transformationOutputInvalidSchema = Schema.String.pipe( + Schema.decodeTo( + Schema.String.check(Schema.isMinLength(2)), + SchemaTransformation.transform({ + decode: () => "", + encode: (value) => value + }) + ) +) + +export const transformationOutputInvalid = invalidCase(transformationOutputInvalidSchema, "valid input") + +const middlewareStruct = Schema.Struct( + Object.fromEntries(Array.from({ length: 32 }, (_, index) => [`field${index}`, Schema.String])) +).pipe( + Schema.middlewareDecoding((effect) => Effect.map(effect, (value) => value)) +) +const middlewareStructInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}`, `value${index}`]) +) + +export const middlewareStructValid = validCase(middlewareStruct, middlewareStructInput) + +interface RecursiveNode { + readonly value: string + readonly children: ReadonlyArray +} + +const recursiveNode: Schema.Codec = Schema.Struct({ + value: Schema.String, + children: Schema.Array(Schema.suspend((): Schema.Codec => recursiveNode)) +}) +const makeRecursiveNode = (depth: number): RecursiveNode => ({ + value: `depth${depth}`, + children: depth === 0 ? [] : Array.from({ length: 3 }, () => makeRecursiveNode(depth - 1)) +}) +const recursiveNodeInput = makeRecursiveNode(4) + +export const recursiveNodeValid = validCase(recursiveNode, recursiveNodeInput) diff --git a/packages/effect/runtimeperf/suites/schema-compiler/fixtures/strict-record.ts b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/strict-record.ts new file mode 100644 index 00000000000..1116acff633 --- /dev/null +++ b/packages/effect/runtimeperf/suites/schema-compiler/fixtures/strict-record.ts @@ -0,0 +1,35 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" +import * as SchemaJITCompiler from "effect/unstable/schema/SchemaJITCompiler" +import assert from "node:assert/strict" + +const recordCase = (size, valid, compiled) => () => { + const schema = Schema.Record(Schema.String, Schema.Number) + if (compiled) SchemaJITCompiler.enable(schema.ast) + const input = Object.fromEntries(Array.from({ length: size }, (_, i) => [`key${i}`, i])) + if (!valid) input[`key${size - 1}`] = "invalid" + const decode = SchemaParser.decodeUnknownResult(schema, { onExcessProperty: "error" }) + return { + run: () => decode(input), + validate: (result) => { + assert.equal(result._tag, valid ? "Success" : "Failure") + if (valid) { + assert.deepEqual(result.success, input) + } else { + assert.equal(result.failure._tag, "Composite") + assert.equal(result.failure.issues.length, 1) + const issue = result.failure.issues[0] + assert.equal(issue._tag, "Pointer") + assert.deepEqual(issue.path, [`key${size - 1}`]) + assert.equal(issue.issue._tag, "InvalidType") + } + } + } +} + +export const valid1024 = recordCase(1024, true, false) +export const valid1024Compiled = recordCase(1024, true, true) +export const valid4096 = recordCase(4096, true, false) +export const valid4096Compiled = recordCase(4096, true, true) +export const invalid4096 = recordCase(4096, false, false) +export const invalid4096Compiled = recordCase(4096, false, true) diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts index 8ed02deaf1e..0a0333f6fdc 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts @@ -189,19 +189,6 @@ export const taggedWithFallbackValid = decodeParserCase( true ) -const propertyOrderSchema = Schema.Struct({ - a: Schema.String, - b: Schema.String -}) -const propertyOrderInput = { extra: "extra", b: "b", a: "a" } - -export const propertyOrderOriginal = decodeCase( - propertyOrderSchema, - propertyOrderInput, - true, - { onExcessProperty: "preserve", propertyOrder: "original" } -) - const recursiveTree = Schema.Struct({ value: Schema.String, children: Schema.Array(Schema.suspend(() => recursiveTree)) diff --git a/packages/effect/runtimeperf/test/registry.test.mts b/packages/effect/runtimeperf/test/registry.test.mts index 1598b63440c..b9e6f910c3d 100644 --- a/packages/effect/runtimeperf/test/registry.test.mts +++ b/packages/effect/runtimeperf/test/registry.test.mts @@ -2,17 +2,40 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import { describe, it } from "node:test" import { pathToFileURL } from "node:url" -import { loadRegistry } from "../utils.mts" +import { loadRegistry, selectFixtures } from "../utils.mts" describe("runtimeperf registry", () => { it("uses unique fixture targets and valid implementations", () => { const { fixtures } = loadRegistry() assert.equal(new Set(fixtures.map((fixture) => fixture.target)).size, fixtures.length) for (const fixture of fixtures) { - assert.ok(["effect", "fast-check-v4", "valibot", "zod4"].includes(fixture.implementation)) + assert.ok( + [ + "effect", + "effect-compiled", + "fast-check-v4", + "valibot", + "zod4", + "zod4-jitless", + "zod4-validate", + "zod4-compiled" + ].includes(fixture.implementation) + ) } }) + it("selects Effect implementation variants for comparisons", () => { + const { fixtures } = loadRegistry() + const selected = selectFixtures( + fixtures, + { target: "moltar-parse-safe/valid-effect-compiled" }, + { effectOnly: true } + ) + + assert.equal(selected.length, 1) + assert.equal(selected[0].implementation, "effect-compiled") + }) + it("pairs every Arbitrary scenario across the native and fast-check implementations", () => { const { fixtures } = loadRegistry() const scenarios = Map.groupBy( @@ -115,6 +138,98 @@ describe("runtimeperf registry", () => { } }) + it("includes the Moltar assertLoose matrix and isolates the Zod optimization layers", async () => { + const { fixtures } = loadRegistry() + const moltar = fixtures.filter((fixture) => fixture.suite === "moltar-assert-loose") + assert.equal(moltar.every((fixture) => fixture.batchSize === 256), true) + const implementations = (scenario) => + moltar + .filter((fixture) => fixture.scenario === scenario) + .map((fixture) => fixture.implementation) + .sort() + + assert.deepEqual(implementations("moltar-assert-loose-valid"), [ + "effect", + "effect-compiled", + "zod4", + "zod4-compiled", + "zod4-jitless", + "zod4-validate" + ]) + assert.deepEqual(implementations("moltar-assert-loose-extra-valid"), [ + "effect", + "effect-compiled", + "zod4", + "zod4-compiled", + "zod4-jitless", + "zod4-validate" + ]) + assert.deepEqual(implementations("moltar-assert-loose-invalid"), [ + "effect", + "effect-compiled", + "zod4-compiled", + "zod4-validate" + ]) + + const source = await readFile(moltar.find((fixture) => fixture.implementation === "zod4-compiled").fixturePath, "utf8") + assert.match(source, /z\.compile\(/) + assert.match(source, /z\.validate\(/) + assert.match(source, /schema\.parse\(input, \{ jitless: true \}\)/) + }) + + it("includes the Moltar parseSafe matrix and isolates the Zod compilation layers", async () => { + const { fixtures } = loadRegistry() + const moltar = fixtures.filter((fixture) => fixture.suite === "moltar-parse-safe") + assert.equal(moltar.every((fixture) => fixture.batchSize === 256), true) + const implementations = (scenario) => + moltar + .filter((fixture) => fixture.scenario === scenario) + .map((fixture) => fixture.implementation) + .sort() + const parsers = ["effect", "effect-compiled", "zod4", "zod4-compiled", "zod4-jitless"] + + assert.deepEqual(implementations("moltar-parse-safe-valid"), parsers) + assert.deepEqual(implementations("moltar-parse-safe-extra-valid"), parsers) + assert.deepEqual(implementations("moltar-parse-safe-invalid"), parsers) + + const source = await readFile(moltar.find((fixture) => fixture.implementation === "zod4-compiled").fixturePath, "utf8") + assert.match(source, /z\.compile\(/) + assert.match(source, /schema\.parse\(input, \{ jitless: true \}\)/) + assert.doesNotMatch(source, /passthrough\(/) + }) + + it("pairs Schema compiler cases through public SchemaParser APIs", async () => { + const { fixtures } = loadRegistry() + const compiler = fixtures.filter((fixture) => fixture.suite === "schema-compiler") + const scenarios = new Map() + for (const fixture of compiler) { + const implementations = scenarios.get(fixture.scenario) ?? [] + implementations.push(fixture.implementation) + scenarios.set(fixture.scenario, implementations) + } + for (const implementations of scenarios.values()) { + assert.deepEqual(implementations.sort(), ["effect", "effect-compiled"]) + } + + const interpreted = compiler.find((fixture) => fixture.name === "record-valid-effect") + const compiled = compiler.find((fixture) => fixture.name === "record-valid-effect-compiled") + const interpretedSource = await readFile(interpreted.fixturePath, "utf8") + const compiledSource = await readFile(compiled.fixturePath, "utf8") + assert.match(interpretedSource, /SchemaParser\.decodeUnknownSync\(/) + assert.doesNotMatch(interpretedSource, /SchemaCompiler/) + assert.match(compiledSource, /import "effect\/unstable\/schema\/SchemaJITCompiler\/enable"/) + assert.doesNotMatch(compiledSource, /internal\/schema/) + + const strictRecords = compiler.filter((fixture) => fixture.family === "strict-record") + assert.equal(strictRecords.length, 6) + assert.deepEqual([...new Set(strictRecords.map((fixture) => fixture.size))], [1024, 4096]) + const strictRecordSource = await readFile(strictRecords[0].fixturePath, "utf8") + assert.match(strictRecordSource, /SchemaParser\.decodeUnknownResult\(/) + assert.match(strictRecordSource, /onExcessProperty: "error"/) + assert.match(strictRecordSource, /SchemaJITCompiler\.enable\(schema\.ast\)/) + assert.doesNotMatch(strictRecordSource, /internal\/schema/) + }) + it("loads, runs and validates every fixture export", async () => { const { fixtures } = loadRegistry() const modules = new Map() diff --git a/packages/effect/runtimeperf/test/utils.test.mts b/packages/effect/runtimeperf/test/utils.test.mts new file mode 100644 index 00000000000..b67107964fd --- /dev/null +++ b/packages/effect/runtimeperf/test/utils.test.mts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { parseArgs, resolveDefaults } from "../utils.mts" + +describe("runtimeperf options", () => { + it("accepts a fixed batch size", () => { + const options = parseArgs(["moltar-parse-safe-valid", "--batch-size", "256"]) + assert.equal(options.batchSize, 256) + assert.equal(resolveDefaults({ defaults: {} }, options).batchSize, 256) + }) + + it("rejects an invalid fixed batch size", () => { + assert.throws(() => parseArgs(["--batch-size", "0"]), /--batch-size must be a positive integer/) + }) +}) diff --git a/packages/effect/runtimeperf/utils.mts b/packages/effect/runtimeperf/utils.mts index 2dc6210b022..d3cbd944c3a 100644 --- a/packages/effect/runtimeperf/utils.mts +++ b/packages/effect/runtimeperf/utils.mts @@ -62,6 +62,7 @@ export const parseArgs = (args, { compare = false } = {}) => { rounds: undefined, timeMs: undefined, warmupTimeMs: undefined, + batchSize: undefined, tier: undefined, family: undefined, implementation: undefined, @@ -74,6 +75,7 @@ export const parseArgs = (args, { compare = false } = {}) => { ["--rounds", "rounds"], ["--time", "timeMs"], ["--warmup-time", "warmupTimeMs"], + ["--batch-size", "batchSize"], ["--tier", "tier"], ["--family", "family"], ["--implementation", "implementation"], @@ -100,11 +102,18 @@ export const parseArgs = (args, { compare = false } = {}) => { throw new Error(`Expected at most one target, got ${options.target} and ${arg}`) } } - for (const key of ["rounds", "timeMs", "warmupTimeMs", "tier"]) { + const numericOptions = new Map([ + ["rounds", "--rounds"], + ["timeMs", "--time"], + ["warmupTimeMs", "--warmup-time"], + ["batchSize", "--batch-size"], + ["tier", "--tier"] + ]) + for (const [key, option] of numericOptions) { if (options[key] !== undefined) { const value = Number(options[key]) if (!Number.isInteger(value) || value < 0 || (key !== "tier" && value === 0)) { - throw new Error(`--${key} must be ${key === "tier" ? "a non-negative" : "a positive"} integer`) + throw new Error(`${option} must be ${key === "tier" ? "a non-negative" : "a positive"} integer`) } options[key] = value } @@ -119,9 +128,10 @@ export const loadRegistry = () => { fixtureGroup.cases.map((runtimeCase) => ({ ...fixtureGroup.defaults, ...runtimeCase, + batchSize: suite.batchSize, suite: suite.name, target: `${suite.name}/${runtimeCase.name}`, - fixturePath: resolve(runtimeperfDir, fixtureGroup.file) + fixturePath: resolve(runtimeperfDir, runtimeCase.file ?? fixtureGroup.file) })) ) ) @@ -147,7 +157,9 @@ export const selectFixtures = (fixtures, options, { effectOnly = false } = {}) = selected = selected.filter((fixture) => fixture.implementation === options.implementation) } if (effectOnly) { - selected = selected.filter((fixture) => fixture.implementation === "effect") + selected = selected.filter((fixture) => + fixture.implementation === "effect" || fixture.implementation.startsWith("effect-") + ) } if (selected.length === 0) { throw new Error("No runtimeperf fixtures matched the selection") @@ -159,6 +171,7 @@ export const resolveDefaults = (config, options) => ({ rounds: options.rounds ?? config.defaults.rounds, timeMs: options.timeMs ?? config.defaults.timeMs, warmupTimeMs: options.warmupTimeMs ?? config.defaults.warmupTimeMs, + batchSize: options.batchSize ?? null, targetBatchTimeNs: config.defaults.targetBatchTimeNs, maxBatchSize: config.defaults.maxBatchSize, bootstrapIterations: config.defaults.bootstrapIterations, @@ -184,19 +197,23 @@ export const runWorker = (workerArgs) => { } } -export const calibrateFixture = (fixture, defaults, fixturePath = fixture.fixturePath) => - runWorker([ - "--mode", - "calibrate", - "--fixture", - fixturePath, - "--export", - fixture.export, - "--target-batch-time-ns", - String(defaults.targetBatchTimeNs), - "--max-batch-size", - String(defaults.maxBatchSize) - ]) +export const calibrateFixture = (fixture, defaults, fixturePath = fixture.fixturePath) => { + const batchSize = defaults.batchSize ?? fixture.batchSize ?? null + return batchSize === null + ? runWorker([ + "--mode", + "calibrate", + "--fixture", + fixturePath, + "--export", + fixture.export, + "--target-batch-time-ns", + String(defaults.targetBatchTimeNs), + "--max-batch-size", + String(defaults.maxBatchSize) + ]) + : { ok: true, mode: "fixed", batchSize, warnings: [] } +} export const measureFixture = (fixture, defaults, batchSize, fixturePath = fixture.fixturePath) => runWorker([ diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index ab871a45e24..da773035956 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -449,6 +449,11 @@ export interface declareConstructor Effect` * - `annotations` — optional metadata * + * The parser returned by `run` must complete synchronously, have no observable + * side effects, and be safe to evaluate again with the same input. It may + * materialize an equivalent container representation, but encoded-to-type + * conversions belong in a separate schema transformation. + * * @see {@link declare} for creating schemas for non-parametric types. * * **Example** (Schema for a parametric `Box` type) @@ -1359,7 +1364,9 @@ export function toStandardJSONSchemaV1( * * This function returns a predicate that performs a type-safe check, narrowing * the type of the input value if the check passes. The predicate returns `false` - * for schema mismatches. + * for schema mismatches. It checks the decoded type side with the default parse + * options: excess properties are ignored and refinement checks are enabled. + * For configurable validation, use a decoding API with `Schema.toType(schema)`. * * **Gotchas** * @@ -1388,7 +1395,9 @@ export function toStandardJSONSchemaV1( * @category guards * @since 3.10.0 */ -export const is: typeof SchemaParser.is = SchemaParser.is +export const is: ( + schema: S +) => (input: I) => input is I & S["Type"] = SchemaParser.is /** * Creates an assertion function that throws an error if the input does not match * the schema. @@ -2610,6 +2619,12 @@ export interface Literal /** * Creates a schema for a single literal value (string, number, bigint, boolean, or null). * + * **Details** + * + * Matching uses strict equality (`===`) and preserves the input value. Like + * TypeScript's zero literal type, `Literal(0)` and `Literal(-0)` accept both + * zero signs. Decoding and encoding preserve the input's sign. + * * **Example** (Defining a string literal) * * ```ts import.meta.vitest @@ -3313,7 +3328,7 @@ export interface Struct extends BottomLazy(ast: SchemaAST.Objects, * * The resulting schema's `Type` is a readonly object type with the fields' * decoded types. The `Encoded` form mirrors the field schemas' encoded types. + * A declared field is present when its key exists anywhere on the input's + * prototype chain. The special `__proto__` field must be an own property. + * Parsing copies inherited field values to own properties on the output. + * Dynamic {@link Record} index signatures continue to select own properties + * only. + * + * Output property order is unspecified, including in values passed to checks. + * Decoding and encoding do not guarantee preservation of input key order. + * Unknown properties are stripped, or rejected when the parser + * receives `onExcessProperty: "error"`. `Struct({})` instead accepts every + * non-nullish value unchanged, matching TypeScript's `{}` type. * * **Example** (Defining a basic struct) * @@ -3756,6 +3782,11 @@ export interface $Record exten * * **Details** * + * Output property order is unspecified, including in values passed to checks. + * Decoding and encoding do not guarantee preservation of input key order. + * Runtime `onExcessProperty: "error"` rejects own keys not selected by the key + * schema. The default `"ignore"` strips them. + * * For dynamic keys, the key schema selects matching own properties and the * value schema decodes or encodes only those selected properties. Checks on * string, number, symbol, and template literal key schemas narrow which @@ -3767,9 +3798,8 @@ export interface $Record exten * **Gotchas** * * When decoded or encoded key transformations produce the same property key, - * sequential parsing applies selected own properties in selection order, so - * the later selected property overwrites the earlier value. With concurrency - * greater than `1`, completion order determines which value is retained. + * parsing applies selected own properties sequentially in selection order, so + * the later selected property overwrites the earlier value. * * **Example** (Defining a string-keyed record of numbers) * @@ -3979,6 +4009,11 @@ export interface StructWithRest< * Extends a struct schema with one or more record (index-signature) schemas, * producing a schema whose decoded type intersects the struct and all records. * + * **Details** + * + * A key is excess only when neither a fixed field nor any index signature covers it. + * Every applicable index signature validates its value, including fixed fields. + * * **Gotchas** * * TypeScript index signatures also apply to fixed keys. `StructWithRest` does @@ -4698,7 +4733,7 @@ function makeUnion>( ): Union>> { const members = f(this.members) return makeUnion( - SchemaAST.union(members, this.ast.mode, options?.unsafePreserveChecks ? this.ast.checks : undefined), + SchemaAST.union(members, this.ast.options, options?.unsafePreserveChecks ? this.ast.checks : undefined), members ) } @@ -4730,9 +4765,9 @@ function makeUnion>( */ export function Union>( members: Members, - options?: { mode?: "anyOf" | "oneOf" } + options?: SchemaAST.UnionOptions ): Union { - return makeUnion(SchemaAST.union(members, options?.mode ?? "anyOf", undefined), members) + return makeUnion(SchemaAST.union(members, options, undefined), members) } /** * Type-level representation returned by {@link Literals}. @@ -4774,7 +4809,7 @@ export interface Literals> */ export function Literals>(literals: L): Literals { const members = literals.map(Literal) as { readonly [K in keyof L]: Literal } - return make(SchemaAST.union(members, "anyOf", undefined), { + return make(SchemaAST.union(members, undefined, undefined), { literals, members, mapMembers>( @@ -6383,6 +6418,10 @@ export function link() { * When `abort` is `true`, parsing stops after this filter fails instead of * collecting later check failures. * + * Filter predicates must have no observable side effects. A runtime compiler + * may evaluate them once during fast validation and again to construct detailed + * issues after validation fails. + * * **Example** (Reporting failure at a nested path) * * ```ts import.meta.vitest @@ -13774,7 +13813,7 @@ function makeClass< return getClassSchema(this).rebuild(ast) } static make(input: S["~type.make.in"], options?: MakeOptions): Self { - return new this(input, options) + return SchemaParser.make(getClassSchema(this) as any)(input ?? {}, options) as Self } static makeOption(input: S["~type.make.in"], options?: MakeOptions): Option_.Option { return SchemaParser.makeOption(getClassSchema(this) as any)(input ?? {}, options) as any @@ -15027,7 +15066,7 @@ export declare namespace Annotations { } /** * Base annotations shared by all composite schema nodes. Extends - * {@link Documentation} with error messages, branding, parse options, and + * {@link Documentation} with error messages, branding, and * arbitrary generation hooks. {@link Declaration} and other annotation * interfaces build on top of this. * @@ -15066,7 +15105,6 @@ export declare namespace Annotations { * filter/refinement instead. */ readonly identifier?: string | undefined - readonly parseOptions?: SchemaAST.ParseOptions | undefined /** * Accumulated brands when multiple brands are added with `Schema.brand`. */ diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 783015b5f44..4b35aefefdc 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -12,16 +12,26 @@ */ import * as Arr from "./Array.ts" -import * as Cause from "./Cause.ts" import * as Effect from "./Effect.ts" import * as Exit from "./Exit.ts" import { format, formatPropertyKey } from "./Formatter.ts" import { identity, memoize, memoizeIdempotent } from "./Function.ts" -import { effectIsExit, iterateEager } from "./internal/effect.ts" +import { effectIsExit } from "./internal/effect.ts" import * as InternalRecord from "./internal/record.ts" import * as InternalAnnotations from "./internal/schema/annotations.ts" -import * as InternalSchemaCause from "./internal/schema/cause.ts" +import { makeArrayParser } from "./internal/schema/arrays.ts" +import { wrapPropertyKeyIssue } from "./internal/schema/cause.ts" +import * as Diagnostics from "./internal/schema/diagnostics.ts" +import { + hasDefaultObjectOptions, + type ObjectParserState, + type ParsedProperty, + parseProperties, + resumeProperties, + stepProperty +} from "./internal/schema/objects.ts" import * as InternalParser from "./internal/schema/parser.ts" +import { makeUnionParser } from "./internal/schema/unions.ts" import * as Pipeable from "./Pipeable.ts" import * as Predicate from "./Predicate.ts" import * as Result from "./Result.ts" @@ -453,22 +463,23 @@ export type Encoding = readonly [Link, ...Array] * **Details** * * Pass to `Schema.decodeUnknown`, `Schema.encode`, and related APIs to customize - * error reporting, excess property handling, output key ordering, check - * execution, and asynchronous parser concurrency. + * error reporting, excess property handling, and check + * execution. Options apply throughout the parse; schema annotations do not + * override them. Composite schemas parse their children sequentially, including + * asynchronous transformations and middleware. * * - `errors` — `"first"` (default) stops at the first error; `"all"` collects * every error. * - `onExcessProperty` — `"ignore"` (default) strips unknown object keys; - * `"error"` fails; `"preserve"` keeps them. - * - `propertyOrder` — `"none"` (default) lets the system choose key order; - * `"original"` preserves input key order. + * `"error"` fails. * - `disableChecks` — skips validation checks while still applying defaults and * transformations. - * - `concurrency` — maximum number of async parse effects to run concurrently; - * defaults to `1`, or use `"unbounded"`. * - `reportInput` — includes rejected input values in value-bearing schema * issues. * + * Object property order is unspecified, including in values passed to checks. + * Decoding and encoding do not guarantee preservation of input key order. + * * @category options * @since 3.10.0 */ @@ -492,35 +503,14 @@ export interface ParseOptions { * **Details** * * The default, `"ignore"`, strips unspecified properties from the output. Use - * `"error"` to fail when an excess property is present, or `"preserve"` to - * keep excess properties in the output. + * `"error"` to fail when an excess property is present. A key is covered by a + * declared property or any index signature selecting that key. This applies + * to structs, records, and structs with rest. Values must satisfy every + * applicable index signature. Empty structs keep their non-nullish behavior. * * @default "ignore" */ - readonly onExcessProperty?: "ignore" | "error" | "preserve" | undefined - - /** - * The `propertyOrder` option provides control over the order of object fields - * in the output. This feature is useful when the sequence of keys is - * important for the consuming processes or when maintaining the input order - * enhances readability and usability. - * - * **Details** - * - * By default, the `propertyOrder` option is set to `"none"`. This means that - * the internal system decides the order of keys to optimize parsing speed. - * - * Setting `propertyOrder` to `"original"` ensures that the keys are ordered - * as they appear in the input during the decoding/encoding process. - * - * **Gotchas** - * - * The key order for `"none"` should not be considered stable and may change - * in future updates without notice. - * - * @default "none" - */ - readonly propertyOrder?: "none" | "original" | undefined + readonly onExcessProperty?: "ignore" | "error" | undefined /** * Whether to disable checks while still applying defaults and @@ -528,13 +518,6 @@ export interface ParseOptions { */ readonly disableChecks?: boolean | undefined - /** - * The maximum number of async effects to run concurrently. - * - * @default 1 - */ - readonly concurrency?: number | "unbounded" | undefined - /** * Whether schema issues should retain and report rejected input values. * @@ -549,13 +532,12 @@ export interface ParseOptions { * Enabling this option can retain or disclose secrets, personally * identifiable information, and large object graphs. The `input` field is * enumerable and may be included by object enumeration, spread, or - * serialization. Disabling it on a nested schema does not redact that value - * from an ancestor issue whose input reporting remains enabled. Issues - * returned directly by user-defined declarations, checks, transformations, - * and middleware are not modified; their authors decide whether to retain an - * input. To respect this option, pass the callback's input and parse options - * directly to a value-bearing issue constructor. Custom messages and - * annotations remain the caller's responsibility regardless of this option. + * serialization. Issues returned directly by user-defined declarations, + * checks, transformations, and middleware are not modified; their authors + * decide whether to retain an input. To respect this option, pass the + * callback's input and parse options directly to a value-bearing issue + * constructor. Custom messages and annotations remain the caller's + * responsibility regardless of this option. * Formatting an issue with `SchemaIssue.makeFormatterDefault()`, reading * `SchemaError.message`, or formatting a Standard Schema failure can disclose * retained input. @@ -698,8 +680,10 @@ type DeclarationRun = ( * - `typeParameters` — inner schemas this declaration is parameterized over * (e.g. the element type for a custom collection). * - `run` — factory that receives `typeParameters` and returns a parser that - * validates or transforms raw input. The `Effect` returned by the parser must - * complete synchronously. + * recognizes the declared representation. It may materialize an equivalent + * container, but semantic encoded-to-type conversions belong in an AST link. + * The parser must complete synchronously, have no observable side effects, + * and be safe to evaluate again with the same input. * * @see {@link isDeclaration} * @category models @@ -717,7 +701,7 @@ export interface Declaration extends ASTNode { readonly encodingRun: DeclarationRun | undefined /** @internal */ - getParser(): SchemaParser.Parser + getParser(compile: SchemaParser.Compiler): SchemaParser.Parser /** @internal */ recur(recur: (ast: AST) => AST): Declaration @@ -772,11 +756,16 @@ export const Declaration: new( this.encodingRun = encodingRun } /** @internal */ - getParser(): SchemaParser.Parser { + getParser(compile: SchemaParser.Compiler): SchemaParser.Parser { let run: ReturnType return (input, options) => { if (input === InternalParser.missing) return InternalParser.missingExit - return (run ??= this.run(this.typeParameters))(input, this, options) + if (run === undefined) { + // The callback can use public parsers, which must see these scoped entries. + for (const typeParameter of this.typeParameters) compile.resolve(typeParameter) + run = this.run(this.typeParameters) + } + return run(input, this, options) } } private _rebuild( @@ -1348,7 +1337,7 @@ export const Enum: new( const coercions = Object.fromEntries(this.enums.map(([_, v]) => [globalThis.String(v), v])) return replaceEncoding(this, [ new Link( - new Union(Object.keys(coercions).map((k) => new Literal(k)), "anyOf"), + new Union(Object.keys(coercions).map((k) => new Literal(k))), new SchemaTransformation.Transformation( SchemaGetter.transform((s) => coercions[s]), SchemaGetter.String() @@ -1433,6 +1422,9 @@ export interface TemplateLiteral extends ASTNode { /** @internal */ matchPart(s: string, options: ParseOptions): string | undefined + /** @internal */ + + asTemplateLiteralParser(): Arrays } /** @@ -1493,13 +1485,12 @@ export const TemplateLiteral: new( } /** @internal */ getParser(compile: SchemaParser.Compiler): SchemaParser.Parser { - const tuple = new Arrays(false, this.parts.map(partFromString), []) - const parser = compile(decodeTo(string, tuple, templateLiteralTransformation(this))) + const parser = compile(this.asTemplateLiteralParser()) return (input, options) => { if (input === InternalParser.missing) return InternalParser.missingExit const result = parser(input, options) if ((result as Exit.Exit)._tag === "Success") { - return InternalParser.sameExit + return InternalParser.unchangedExit } return Effect.mapBothEager(result, { onSuccess: () => input, @@ -1515,6 +1506,11 @@ export const TemplateLiteral: new( matchPart(s: string, options: ParseOptions): string | undefined { return segmentTemplateLiteralParts(this, s, options) === undefined ? undefined : s } + /** @internal */ + asTemplateLiteralParser(): Arrays { + const tuple = new Arrays(false, this.parts.map(partFromString), []) + return decodeTo(string, tuple, templateLiteralTransformation(this)) + } } /** @internal */ @@ -1524,9 +1520,17 @@ export function templateLiteralParser(parts: ReadonlyArray): Arrays { const normalize = memoize((encoded: AST): AST => { if (encoded._tag !== "Union") return encoded const types = mapOrSame(encoded.types, normalize) - return encoded.mode === "anyOf" && types === encoded.types + return (encoded.options?.mode ?? "anyOf") === "anyOf" && types === encoded.types ? encoded - : new Union(types, "anyOf", encoded.annotations, encoded.checks, undefined, encoded.context) + : new Union( + types, + { ...encoded.options, mode: "anyOf" }, + encoded.annotations, + encoded.checks, + undefined, + encoded.context, + encoded.encodingChecks + ) }) const template = new TemplateLiteral(parts.map((part) => normalize(toEncoded(part)))) const tuple = new Arrays(false, parts.map(partFromString), []) @@ -1633,8 +1637,10 @@ export type LiteralValue = string | number | boolean | bigint * **Details** * * Parsing succeeds only when the input is strictly equal (`===`) to the - * stored `literal`. Numeric literals must be finite — `Infinity`, `-Infinity`, - * and `NaN` are rejected at construction time. + * stored `literal`, preserving the input value. Both `0` and `-0` are accepted + * by either zero literal, and parsing preserves the input's sign. Numeric + * literals must be finite — `Infinity`, `-Infinity`, and `NaN` are rejected at + * construction time. * * **Example** (Creating a literal AST) * @@ -2264,82 +2270,7 @@ export const Arrays: new( compile: SchemaParser.Compiler, compileConstructorDefault: SchemaParser.Compiler = compile ): SchemaParser.Parser { - // oxlint-disable-next-line @typescript-eslint/no-this-alias - const ast = this - type ElementParser = { readonly ast: AST; readonly parser: SchemaParser.Parser } - let elements: Array | undefined - let rest: Array | undefined - const elementLen = ast.elements.length - const tailLen = Math.max(0, ast.rest.length - 1) - - function getParser( - tailThreshold: number, - index: number - ): { readonly ast: AST; readonly parser: SchemaParser.Parser } { - if (index < elementLen) { - return elements![index] - } else if (index >= tailThreshold) { - return rest![index - tailThreshold + 1] - } - return rest![0] - } - - return Effect.fnUntracedEager(function*(input, options) { - if (input === InternalParser.missing) { - return InternalParser.missing - } - - // If the input is not an array, return early with an error - if (!Array.isArray(input)) { - return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) - } - if (!elements) { - elements = ast.elements.map((ast) => ({ ast, parser: compileConstructorDefault(ast) })) - rest = ast.rest.map((ast) => ({ ast, parser: compileConstructorDefault(ast) })) - } - - const len = input.length - const state = { - ast, - getParser, - input, - len, - tailThreshold: Math.max(elementLen, len - tailLen), - output: new globalThis.Array(len), - issues: undefined as Arr.NonEmptyArray | undefined, - options - } - const concurrency = resolveConcurrency(options?.concurrency) - const eff = parseArray(state, input, { - concurrency: concurrency?.concurrency, - end: ast.rest.length === 0 ? elementLen : Math.max(len, elementLen + tailLen) - }) - if (eff) yield* eff - - // --------------------------------------------- - // handle excess indexes - // --------------------------------------------- - if (ast.rest.length === 0 && len > elementLen) { - for (let i = elementLen; i <= len - 1; i++) { - const unexpected = new SchemaIssue.UnexpectedKey(ast, input[i], options) - const issue = new SchemaIssue.Pointer([i], unexpected) - if (options.errors === "all") { - if (state.issues) state.issues.push(issue) - else state.issues = [issue] - } else { - return yield* Effect.fail( - new SchemaIssue.Composite(ast, [issue], input, options) - ) - } - } - } - if (state.issues) { - return yield* Effect.fail( - new SchemaIssue.Composite(ast, state.issues, input, options) - ) - } - return state.output - }) + return makeArrayParser(this, compileConstructorDefault) } private _rebuild(recur: (ast: AST) => AST, checks: Checks | undefined, encodingChecks: Checks | undefined) { const elements = mapOrSame(this.elements, recur) @@ -2371,91 +2302,6 @@ export const Arrays: new( return "array" } } -const parseArray = iterateEager<{ - readonly ast: AST - readonly input: unknown - readonly len: number - readonly getParser: ( - tailThreshold: number, - index: number - ) => { readonly ast: AST; readonly parser: SchemaParser.Parser } - readonly tailThreshold: number - readonly options: ParseOptions - readonly output: Array - issues: Array | undefined -}, unknown>()({ - onItem(s, item, i) { - const value = i < s.len ? item : InternalParser.missing - return s.getParser(s.tailThreshold, i).parser(value, s.options) - }, - step(s, item, exit, i) { - if (exit._tag === "Failure") { - return wrapPropertyKeyIssue(s, s.ast, i, exit) - } - const value = exit === InternalParser.sameExit - ? item - : (exit as InternalParser.Success)[InternalParser.args] - if (value !== InternalParser.missing) { - s.output[i] = value - } else { - const p = s.getParser(s.tailThreshold, i) - if (isOptional(p.ast)) return - const issue = new SchemaIssue.Pointer([i], new SchemaIssue.MissingKey(p.ast.context?.annotations)) - if (s.options.errors === "all") { - if (s.issues) s.issues.push(issue) - else s.issues = [issue] - } else { - return Exit.fail( - new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) - ) - } - } - } -}) - -const resolveConcurrency = (value: number | "unbounded" | undefined) => { - value = value === "unbounded" ? Infinity : value ?? 1 - return value > 1 ? { concurrency: value } : undefined -} - -const wrapPropertyKeyIssue = ( - s: { - readonly input: unknown - readonly options: ParseOptions - issues: Array | undefined - }, - ast: AST, - key: PropertyKey, - exit: Exit.Failure -) => { - if (exit.cause.reasons.length === 0) { - return exit - } - const issue = InternalSchemaCause.getSchemaIssue(exit.cause) - if (issue === undefined) { - return Exit.failCause( - Cause.map( - exit.cause, - (issue) => - new SchemaIssue.Composite( - ast, - [new SchemaIssue.Pointer([key], issue)], - s.input, - s.options - ) - ) - ) - } - const pointer = new SchemaIssue.Pointer([key], issue) - if (s.options.errors === "all") { - if (s.issues) s.issues.push(pointer) - else s.issues = [pointer] - } else { - return Exit.fail( - new SchemaIssue.Composite(ast, [pointer], s.input, s.options) - ) - } -} /** * floating point or integer, with optional exponent @@ -2502,7 +2348,9 @@ export function getIndexSignatureKeys( * * Pairs a `name` (any `PropertyKey`) with a `type` ({@link AST}). The * property's optionality and mutability are determined by the `type`'s - * {@link Context}. + * {@link Context}. During object parsing, a property is present when its name + * exists anywhere on the input's prototype chain. The special `__proto__` + * property must be an own property. * * @see {@link Objects} * @category models @@ -2719,13 +2567,10 @@ export const Objects: new( ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this - const expectedKeys: Array = [] - for (const ps of ast.propertySignatures) { - expectedKeys.push(ps.name) - } + const expectedKeys = Diagnostics.getExpectedKeys(ast) const hasProperties = expectedKeys.length const indexCount = ast.indexSignatures.length - let expectedKeysSet = hasProperties && indexCount ? new Set(expectedKeys) : undefined + let expectedKeysSet = hasProperties && indexCount ? new Set(expectedKeys) : undefined // --------------------------------------------- // handle empty struct // --------------------------------------------- @@ -2752,11 +2597,15 @@ export const Objects: new( if (exitValue._tag === "Failure") { return wrapPropertyKeyIssue(s, ast, key, exitValue) ?? Exit.void } - const value = exitValue === InternalParser.sameExit - ? inputValue - : (exitValue as InternalParser.Success)[InternalParser.args] + const value = InternalParser.valueOrInput( + exitValue as InternalParser.Success, + inputValue + ) if (k2 !== InternalParser.missing && value !== InternalParser.missing) { - if (hasProperties && (expectedKeysSet!.has(key) || expectedKeysSet!.has(k2))) return Exit.void + if ( + hasProperties && + (expectedKeysSet!.has(key) || expectedKeysSet!.has(Diagnostics.normalizeKey(k2))) + ) return Exit.void InternalRecord.assignProperty(s.out, k2, value) } return Exit.void @@ -2777,9 +2626,10 @@ export const Objects: new( if (exitKey._tag === "Failure") { return wrapPropertyKeyIssue(s, ast, key, exitKey) ?? Exit.void } - const k2 = exitKey === InternalParser.sameExit - ? key - : (exitKey as InternalParser.Success)[InternalParser.args] + const k2 = InternalParser.valueOrInput( + exitKey as InternalParser.Success, + key + ) as PropertyKey const inputValue = s.input[key] const result = index.parserValue(inputValue, s.options) return effectIsExit(result) @@ -2797,20 +2647,21 @@ export const Objects: new( ? finishIndex(s, key, key, inputValue, result) : Effect.flatMap(Effect.exit(result), (exit) => finishIndex(s, key, key, inputValue, exit)) } - const parseIndexes = indexCount ? - iterateEager()({ - onItem: (s, [key, index]) => parseIndex(s, key, index), - step: (_s, _, exit: Exit.Exit) => exit._tag === "Failure" ? exit : undefined - }) : - undefined - const compileMembers = (): Array => { if (!properties) { - properties = ast.propertySignatures.map((ps) => ({ - parser: compileConstructorDefault(ps.type), - name: ps.name, - type: ps.type - })) + properties = ast.propertySignatures.map((ps) => { + const property: ParsedProperty = { + parser(input, options) { + const parser = compileConstructorDefault(ps.type) + Object.defineProperty(property, "parser", { value: parser }) + return parser(input, options) + }, + name: ps.name, + type: ps.type, + valueFirst: ps.name !== "__proto__" && !isOptional(ps.type) + } + return property + }) indexes = indexCount ? ast.indexSignatures.map((is) => ({ is, @@ -2844,79 +2695,62 @@ export const Objects: new( } const errorsAllOption = options.errors === "all" const onExcessPropertyError = options.onExcessProperty === "error" - const onExcessPropertyPreserve = options.onExcessProperty === "preserve" // --------------------------------------------- // handle excess properties // --------------------------------------------- - let inputKeys: Array | undefined - if (!indexCount && (onExcessPropertyError || onExcessPropertyPreserve)) { + const indexKeys = indexCount && onExcessPropertyError + ? ast.indexSignatures.map((index) => getIndexSignatureKeys(record, index.parameter, options)) + : undefined + if (onExcessPropertyError) { expectedKeysSet ??= new Set(expectedKeys) - inputKeys = Reflect.ownKeys(record) + const coveredKeys = Diagnostics.getCoveredKeys(expectedKeysSet, indexKeys) + const inputKeys = Reflect.ownKeys(record) for (let i = 0; i < inputKeys.length; i++) { const key = inputKeys[i] - if (!expectedKeysSet.has(key)) { + if (!coveredKeys.has(key)) { // key is unexpected - if (onExcessPropertyError) { - const unexpected = new SchemaIssue.UnexpectedKey(ast, record[key], options) - const issue = new SchemaIssue.Pointer([key], unexpected) - if (errorsAllOption) { - if (state.issues) { - state.issues.push(issue) - } else { - state.issues = [issue] - } - continue + const issue = Diagnostics.unexpectedKey(ast, key, record[key], options) + if (errorsAllOption) { + if (state.issues) { + state.issues.push(issue) } else { - return yield* Effect.fail( - new SchemaIssue.Composite(ast, [issue], input, options) - ) + state.issues = [issue] } + continue } else { - // preserve key - InternalRecord.assignProperty(out, key, record[key]) + return yield* Effect.fail( + new SchemaIssue.Composite(ast, [issue], input, options) + ) } } } } - const concurrency = resolveConcurrency(options?.concurrency) - // --------------------------------------------- // handle property signatures // --------------------------------------------- if (hasProperties) { - const eff = parseProperties(state, properties!, concurrency) + const eff = parseProperties(state, properties!) if (eff) yield* eff } // --------------------------------------------- // handle index signatures // --------------------------------------------- - if (indexCount && !concurrency) { + if (indexCount) { for (let i = 0; i < indexCount; i++) { const index = indexes![i] const parse = index.is.parameter === string ? parseStringIndex : parseIndex - const keys = index.is.parameter === string + const keys = indexKeys?.[i] ?? (index.is.parameter === string ? Object.keys(record) - : getIndexSignatureKeys(record, index.is.parameter, options) + : getIndexSignatureKeys(record, index.is.parameter, options)) for (let j = 0; j < keys.length; j++) { const eff = parse(state, keys[j], index) if (!effectIsExit(eff)) yield* eff else if (eff._tag === "Failure") return yield* eff as Exit.Exit } } - } else if (parseIndexes) { - const keyPairs = Arr.empty<[PropertyKey, Index]>() - for (let i = 0; i < indexCount; i++) { - const index = indexes![i] - const keys = getIndexSignatureKeys(record, index.is.parameter, options) - for (let j = 0; j < keys.length; j++) { - keyPairs.push([keys[j], index]) - } - } - const eff = parseIndexes(state, keyPairs, concurrency) - if (eff) yield* eff } if (state.issues) { @@ -2924,49 +2758,16 @@ export const Objects: new( new SchemaIssue.Composite(ast, state.issues, input, options) ) } - if (options.propertyOrder === "original") { - // preserve input keys order - const keys = (inputKeys ?? Reflect.ownKeys(record)).concat(expectedKeys) - const preserved: Record = {} - for (const key of keys) { - if (Object.hasOwn(out, key)) { - InternalRecord.assignProperty(preserved, key, out[key]) - } - } - return preserved - } return out }) if (indexCount) return fallback - // Resumes at the property whose parser suspended, without replaying the - // properties already parsed. - const resume = ( - state: ObjectParserState, - index: number, - pending: Effect.Effect - ): Effect.Effect => { - const property = properties![index] - return Effect.flatMap(Effect.exit(pending), (exit) => { - const terminal = stepProperty(state, property, exit) - if (terminal) return terminal - const done = () => InternalParser.succeed(state.out) - const eff = parseProperties(state, properties!.slice(index + 1)) - return eff ? Effect.flatMapEager(eff, done) : done() - }) - } - // Fast path: a struct without index signatures, under the default parse // options, needs none of the generator the fallback runs per value. return (input, options) => { if (input === InternalParser.missing) return InternalParser.missingExit - if ( - options.errors === "all" || - options.onExcessProperty !== undefined || - options.propertyOrder === "original" || - options.concurrency !== undefined - ) { + if (!hasDefaultObjectOptions(options)) { return fallback(input, options) } if (!(typeof input === "object" && input !== null && !Array.isArray(input))) { @@ -2980,14 +2781,19 @@ export const Objects: new( for (let index = 0; index < props.length; index++) { const property = props[index] const name = property.name - const hasKey = Object.hasOwn(record, name) - const value = hasKey ? record[name] : InternalParser.missing + let value: unknown + if (property.valueFirst) { + value = record[name] + if (value === undefined && !(name in record)) value = InternalParser.missing + } else { + value = hasPropertySignature(record, name) ? record[name] : InternalParser.missing + } const exit = property.parser(value, options) if (!effectIsExit(exit)) { - return resume(state, index, exit) + return resumeProperties(state, props, index, exit) } - if (exit === InternalParser.sameExit) { - if (hasKey) InternalRecord.assignProperty(out, name, value) + if (exit === InternalParser.unchangedExit) { + InternalRecord.assignProperty(out, name, value) continue } const terminal = stepProperty(state, property, exit) @@ -3047,61 +2853,6 @@ export const Objects: new( } } -type ObjectParserState = { - readonly ast: Objects - readonly input: Record - readonly options: ParseOptions - readonly out: Record - issues: Array | undefined -} - -type ParsedProperty = { - readonly parser: SchemaParser.Parser - readonly name: PropertyKey - readonly type: AST -} - -function stepProperty( - s: ObjectParserState, - p: ParsedProperty, - exit: Exit.Exit -): Exit.Exit | void { - if (exit._tag === "Failure") { - return wrapPropertyKeyIssue(s, s.ast, p.name, exit) - } - if (exit === InternalParser.sameExit) return - const value = (exit as InternalParser.Success)[InternalParser.args] - if (value !== InternalParser.missing) { - InternalRecord.assignProperty(s.out, p.name, value) - return - } - delete s.out[p.name] - if (!isOptional(p.type)) { - const issue = new SchemaIssue.Pointer([p.name], new SchemaIssue.MissingKey(p.type.context?.annotations)) - if (s.options.errors === "all") { - if (s.issues) s.issues.push(issue) - else s.issues = [issue] - return - } else { - return Exit.fail( - new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) - ) - } - } -} - -const parseProperties = iterateEager()({ - onItem(s, p) { - if (!Object.hasOwn(s.input, p.name)) { - return p.parser(InternalParser.missing, s.options) - } - const value = s.input[p.name] - InternalRecord.assignProperty(s.out, p.name, value) - return p.parser(value, s.options) - }, - step: stepProperty -}) - function combineChecks(a: Checks | undefined, b: Checks | undefined): Checks | undefined { if (!a) return b if (!b) return a @@ -3140,10 +2891,10 @@ export function tuple( /** @internal */ export function union>( members: Members, - mode: "anyOf" | "oneOf", + options: UnionOptions | undefined, checks: Checks | undefined ): Union { - return new Union(members.map(getAST), mode, undefined, checks) + return new Union(members.map(getAST), options, undefined, checks) } /** @internal */ @@ -3324,6 +3075,9 @@ type SentinelIndex = Map const candidateIndexCache = new WeakMap, CandidateIndex>() const emptyCandidates: ReadonlyArray = Object.freeze([]) +const hasPropertySignature = (input: object, key: PropertyKey): boolean => + key === "__proto__" ? Object.hasOwn(input, key) : key in input + function getIndex(types: ReadonlyArray): CandidateIndex { let index = candidateIndexCache.get(types) if (index) return index @@ -3381,7 +3135,7 @@ function getIndex(types: ReadonlyArray): CandidateIndex { } index = (input, isConstructor) => { if (Predicate.isObjectKeyword(input)) { - const value = Object.hasOwn(input, key) ? (input as any)[key] : undefined + const value = hasPropertySignature(input, key) ? (input as any)[key] : undefined if (value !== undefined) return candidates.get(value) ?? emptyCandidates if (isConstructor) return types } @@ -3413,7 +3167,7 @@ function getIndex(types: ReadonlyArray): CandidateIndex { // discriminated candidate. if (commonSentinel) { const [key, [byValue]] = commonSentinel - const hasKey = Object.hasOwn(input, key) + const hasKey = hasPropertySignature(input, key) const value = hasKey ? (input as any)[key] : undefined if (hasKey && (!isConstructor || value !== undefined)) { const match = byValue.get(value) @@ -3427,7 +3181,7 @@ function getIndex(types: ReadonlyArray): CandidateIndex { // absent and undefined keys as unconstrained and therefore selects every candidate that owns the key. if (directKey === undefined) { for (const [key, [byValue, all]] of bySentinel) { - const hasKey = Object.hasOwn(input, key) + const hasKey = hasPropertySignature(input, key) const value = hasKey ? (input as any)[key] : undefined if (hasKey && (!isConstructor || value !== undefined)) { const match = byValue.get(value) @@ -3442,7 +3196,7 @@ function getIndex(types: ReadonlyArray): CandidateIndex { // Missing keys are neutral. An observed key rejects only selected candidates that own it and do not match. for (const [key, [byValue, all]] of bySentinel) { if (key === directKey) continue - const hasKey = Object.hasOwn(input, key) + const hasKey = hasPropertySignature(input, key) const value = hasKey ? (input as any)[key] : undefined if (hasKey && (!isConstructor || value !== undefined)) { const match = byValue.get(value) @@ -3495,7 +3249,7 @@ export function getCandidates( * **Details** * * - `types` — the member AST nodes. - * - `mode` — `"anyOf"` succeeds on the first match (like TypeScript unions); + * - `options.mode` — `"anyOf"` succeeds on the first match (like TypeScript unions); * `"oneOf"` requires exactly one member to match (fails if multiple do). * * During parsing, members are tried in order. An internal candidate index @@ -3511,7 +3265,7 @@ export function getCandidates( * const ast = schema.ast * * if (SchemaAST.isUnion(ast)) { - * [ast.types.length, ast.mode] // => [2, "anyOf"] + * [ast.types.length, ast.options?.mode ?? "anyOf"] // => [2, "anyOf"] * } * ``` * @@ -3522,13 +3276,16 @@ export function getCandidates( export interface Union extends ASTNode { readonly _tag: "Union" readonly types: ReadonlyArray - readonly mode: "anyOf" | "oneOf" + readonly options: UnionOptions | undefined readonly encodingChecks: Checks | undefined /** @internal */ getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser /** @internal */ + getCandidates(input: unknown, isConstructor?: boolean): ReadonlyArray + /** @internal */ + recur(recur: (ast: AST) => AST): Union /** @internal */ @@ -3541,6 +3298,17 @@ export interface Union extends ASTNode { getExpected(getExpected: (ast: AST) => string): string } +/** + * Local union matching options. Treat them as immutable after AST construction. + * + * @category options + * @since 4.0.0 + */ +export interface UnionOptions { + /** Defaults to `"anyOf"`; `"oneOf"` requires exactly one matching member. */ + readonly mode?: "anyOf" | "oneOf" | undefined +} + /** * Constructs a {@link Union}. * @@ -3549,7 +3317,7 @@ export interface Union extends ASTNode { */ export const Union: new( types: ReadonlyArray, - mode: "anyOf" | "oneOf", + options?: UnionOptions, annotations?: Schema.Annotations.Annotations, checks?: Checks, encoding?: Encoding, @@ -3558,12 +3326,12 @@ export const Union: new( ) => Union = class extends ASTNodeImpl { readonly _tag = "Union" readonly types: ReadonlyArray - readonly mode: "anyOf" | "oneOf" + readonly options: UnionOptions | undefined readonly encodingChecks: Checks | undefined constructor( types: ReadonlyArray, - mode: "anyOf" | "oneOf", + options?: UnionOptions, annotations?: Schema.Annotations.Annotations, checks?: Checks, encoding?: Encoding, @@ -3572,7 +3340,7 @@ export const Union: new( ) { super(annotations, checks, encoding, context) this.types = types - this.mode = mode + this.options = options this.encodingChecks = encodingChecks } /** @internal */ @@ -3580,44 +3348,11 @@ export const Union: new( compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler ): SchemaParser.Parser { - // oxlint-disable-next-line @typescript-eslint/no-this-alias - const ast = this - - return (input, options) => { - if (input === InternalParser.missing) { - return InternalParser.missingExit - } - const candidates = getCandidates(input, ast.types, compileConstructorDefault !== undefined) - - if (candidates.length === 1) { - const result = compile(candidates[0])(input, options) - if ((result as Exit.Exit)._tag === "Success") return result - return effectIsExit(result) - ? failSingleUnionCandidate(ast, (result as Exit.Failure).cause, input, options) - : Effect.catchCause(result, (cause) => failSingleUnionCandidate(ast, cause, input, options)) - } - - const state = { - ast, - compile, - input, - out: undefined, - successes: ast.mode === "oneOf" ? [] : undefined, - issues: undefined as Arr.NonEmptyArray | undefined, - options - } - const concurrency = resolveConcurrency(options?.concurrency) - const eff = parseUnion(state, candidates, concurrency ? { ...concurrency, orderedStep: true } : undefined) - if (!eff) { - if (state.out) return state.out - return Effect.fail(new SchemaIssue.AnyOf(ast, state.issues ?? [], input, options)) - } - return Effect.flatMapEager(eff, (_) => { - if (state.out === InternalParser.sameExit) return Effect.succeed(input) - if (state.out) return state.out - return Effect.fail(new SchemaIssue.AnyOf(ast, state.issues ?? [], input, options)) - }) - } + return makeUnionParser(this, compile, compileConstructorDefault !== undefined) + } + /** @internal */ + getCandidates(input: unknown, isConstructor = false): ReadonlyArray { + return getIndex(this.types)(input, isConstructor) } private _rebuild( recur: (ast: AST) => AST, @@ -3627,7 +3362,7 @@ export const Union: new( const types = mapOrSame(this.types, recur) return types === this.types && checks === this.checks && encodingChecks === this.encodingChecks ? this : - new Union(types, this.mode, this.annotations, checks, undefined, this.context, encodingChecks) + new Union(types, this.options, this.annotations, checks, undefined, this.context, encodingChecks) } /** @internal */ recur(recur: (ast: AST) => AST): Union { @@ -3684,58 +3419,11 @@ export const Union: new( } } -function failSingleUnionCandidate( - ast: Union, - cause: Cause.Cause, - input: unknown, - options: ParseOptions -) { - const issue = InternalSchemaCause.getSchemaIssue(cause) - if (!issue) return Exit.failCause(cause) - return Exit.fail(new SchemaIssue.AnyOf(ast, [issue], input, options)) -} - -const parseUnion = iterateEager<{ - readonly compile: (ast: AST) => SchemaParser.Parser - readonly ast: Union - readonly input: unknown - readonly options: ParseOptions - out: Exit.Success | undefined - readonly successes: Array | undefined - issues: Array | undefined -}, AST>()({ - onItem(s, ast) { - const parser = s.compile(ast) - return parser(s.input, s.options) - }, - step(s, candidate, exit) { - if (exit._tag === "Failure") { - const issue = InternalSchemaCause.getSchemaIssue(exit.cause) - if (issue === undefined) { - return exit - } - if (s.issues) s.issues.push(issue) - else s.issues = [issue] - } else { - if (s.out && s.successes) { - s.successes.push(candidate) - return Exit.fail(new SchemaIssue.OneOf(s.ast, s.successes, s.input, s.options)) - } - s.out = exit - if (s.successes) { - s.successes.push(candidate) - } else { - return Exit.void - } - } - } -}) - const nonFiniteLiterals = new Union([ new Literal("Infinity"), new Literal("-Infinity"), new Literal("NaN") -], "anyOf") +]) function formatIsMutable(isMutable: boolean | undefined): string { return isMutable ? "" : "readonly " @@ -4061,7 +3749,7 @@ export function isFinite(annotations?: Schema.Annotations.Filter) { export const finite = appendChecks(number, [isFinite()]) const numberToJson = new Link( - new Union([finite, nonFiniteLiterals], "anyOf"), + new Union([finite, nonFiniteLiterals]), new SchemaTransformation.Transformation( SchemaGetter.Number(), SchemaGetter.transform((n) => globalThis.Number.isFinite(n) ? n : globalThis.String(n)) @@ -4333,7 +4021,7 @@ const optionalKeyLastLink = applyToLastLink(optionalKey) /** @internal */ export const optional = memoize((ast: A): Union => - optionalKey(new Union([ast, undefined_], "anyOf")) + optionalKey(new Union([ast, undefined_])) ) /** @internal */ @@ -4605,7 +4293,7 @@ function fromConst( ast: AST, value: T ): SchemaParser.Parser { - const succeed = InternalParser.succeed(value) + const succeed = value === 0 ? InternalParser.unchangedExit : InternalParser.succeed(value) return (input, options) => { if (input === InternalParser.missing) return InternalParser.missingExit if (input === value) return succeed @@ -4619,7 +4307,7 @@ function fromRefinement( ): SchemaParser.Parser { return (input, options) => { if (input === InternalParser.missing) return InternalParser.missingExit - if (refinement(input)) return InternalParser.sameExit + if (refinement(input)) return InternalParser.unchangedExit return Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) } } @@ -4678,7 +4366,8 @@ function segmentTemplateLiteralParts( return go(0, 0) ? out : undefined } -const parameterFromPropertyKey = applyToSelfOrLastLinkEncodingIdempotent((ast) => { +/** @internal */ +export const parameterFromPropertyKey = applyToSelfOrLastLinkEncodingIdempotent((ast) => { switch (ast._tag) { default: return ast @@ -4749,7 +4438,7 @@ const finiteToString = new Link( ) const numberToString = new Link( - new Union([finiteString, nonFiniteLiterals], "anyOf"), + new Union([finiteString, nonFiniteLiterals]), SchemaTransformation.numberFromString ) @@ -5064,7 +4753,7 @@ export const Json = new Declaration( [], () => (input, ast, options) => isJson(input) ? - InternalParser.sameExit : + InternalParser.unchangedExit : Effect.fail(new SchemaIssue.InvalidType(ast, input, options)), { representation: { @@ -5096,7 +4785,7 @@ export const objectKeywordToJson = new Link( new Union([ new Arrays(false, [], [Json]), new Objects([], [new IndexSignature(string, Json)]) - ], "anyOf"), + ]), SchemaTransformation.passthrough() ) @@ -5115,7 +4804,7 @@ const StringTree = new Declaration( [], () => (input, ast, options) => isStringTree(input) ? - InternalParser.sameExit : + InternalParser.unchangedExit : Effect.fail(new SchemaIssue.InvalidType(ast, input, options)), { expected: "StringTree", toCodecStringTree: () => undefined } ) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 7cc35ebffe5..cd22001785d 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -13,9 +13,9 @@ import * as Cause from "./Cause.ts" import * as Effect from "./Effect.ts" import * as Exit from "./Exit.ts" -import { memoize } from "./Function.ts" import { effectIsExit } from "./internal/effect.ts" import * as InternalSchemaCause from "./internal/schema/cause.ts" +import * as CompilerRegistry from "./internal/schema/compilerRegistry.ts" import * as InternalParser from "./internal/schema/parser.ts" import * as Option from "./Option.ts" import * as Result from "./Result.ts" @@ -36,6 +36,10 @@ import * as SchemaIssue from "./SchemaIssue.ts" * The returned function accepts constructor input, applies constructor defaults, * runs type-side validation unless checks are disabled, and fails with a * `SchemaIssue.Issue` when construction fails. + * Makers use the shared compiler registry at `SchemaAST.toType(schema.ast)`. + * Construction initializes independently from decoding and never uses a + * validation-and-replay pass. Install JIT or AOT before the maker's first use + * to accelerate it; previously resolved makers retain their existing entry. * * @category constructors * @since 4.0.0 @@ -134,7 +138,10 @@ export function make(schema: S) { * **Details** * * The guard returns `true` on successful validation and `false` when validation - * fails only with schema issues, without exposing issue details. + * fails only with schema issues, without exposing issue details. It always + * checks the schema's decoded type side with the default parse options: + * excess properties are ignored and refinement checks are enabled. For + * configurable validation, use a decoding API with `Schema.toType(schema)`. * * **Gotchas** * @@ -145,15 +152,38 @@ export function make(schema: S) { * @category guards * @since 3.10.0 */ -export function is(schema: S): (input: I) => input is I & S["Type"] { +export function is( + schema: S +): (input: I) => input is I & S["Type"] { return _is(schema.ast) } /** @internal */ export function _is(ast: SchemaAST.AST) { - const parser = asExit(run(SchemaAST.toType(ast))) + const options = SchemaAST.defaultParseOptions + const typeAST = SchemaAST.toType(ast) + let parser: Parser | undefined + let compiledGuard: ((input: unknown) => boolean) | undefined + let initialized = false return (input: I): input is I & T => { - const exit = parser(input, SchemaAST.defaultParseOptions) + if (!initialized) { + const entry = CompilerRegistry.resolve(typeAST) + parser = entry.parseEffect + compiledGuard = CompilerRegistry.prepareIs(entry, options) + initialized = true + } + if (compiledGuard !== undefined) { + try { + return compiledGuard(input) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow( + Cause.die(error), + "Type guard adapter can only return false for schema issues" + ) + return false + } + } + const exit = Effect.runSyncExit(runParser(parser!, input, options)) if (Exit.isSuccess(exit)) { return true } @@ -525,7 +555,7 @@ export function decodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Type"] { - return asSync(decodeUnknownEffect(schema, options)) + return makeSync(schema.ast, options) } /** @@ -870,7 +900,7 @@ export function encodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Encoded"] { - return asSync(encodeUnknownEffect(schema, options)) + return makeSync(SchemaAST.flip(schema.ast), options) } /** @@ -925,23 +955,32 @@ export function run(ast: SchemaAST.AST) { return runWithCompiler(normalCompiler, ast) } +function runParser( + parser: Parser, + input: unknown, + options: SchemaAST.ParseOptions +): Effect.Effect { + const result = parser(input, options) + if (result === InternalParser.unchangedExit) { + return Effect.succeed(input) as Effect.Effect + } + if (!effectIsExit(result)) { + return Effect.flatMapEager(result, getValue) + } + return (result as InternalParser.Success)[InternalParser.args] === + InternalParser.missing + ? getValue(InternalParser.missing) + : result as Effect.Effect +} + function runWithCompiler(compiler: Compiler, ast: SchemaAST.AST) { let parser: Parser return (input: unknown, options?: SchemaAST.ParseOptions): Effect.Effect => { - const result = (parser ??= compiler(ast))( + return runParser( + parser ??= compiler(ast), input, options ?? SchemaAST.defaultParseOptions ) - if (result === InternalParser.sameExit) { - return Effect.succeed(input) as Effect.Effect - } - if (!effectIsExit(result)) { - return Effect.flatMapEager(result, getValue) - } - return (result as InternalParser.Success)[InternalParser.args] === - InternalParser.missing - ? getValue(InternalParser.missing) - : result as Effect.Effect } } @@ -997,223 +1036,55 @@ function asResult( } } -function asSync( - parser: (input: E, options?: SchemaAST.ParseOptions) => Effect.Effect -): (input: E, options?: SchemaAST.ParseOptions) => T { - const parserExit = asExit(parser) - return (input: E, options?: SchemaAST.ParseOptions) => { - const exit = parserExit(input, options) - if (Exit.isSuccess(exit)) { - return exit.value +function makeSync( + ast: SchemaAST.AST, + options?: SchemaAST.ParseOptions +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + let entry: CompilerRegistry.Entry | undefined + return (input, overrideOptions) => { + entry ??= CompilerRegistry.resolve(ast) + const parseOptions = options === undefined + ? overrideOptions ?? SchemaAST.defaultParseOptions + : mergeParseOptions(options, overrideOptions) + const validate = entry.validate + if (validate !== undefined && input !== InternalParser.missing) { + let output: unknown + try { + output = validate(input, parseOptions) + } catch (error) { + return throwSyncDefect(error) + } + if (output !== CompilerRegistry.invalid) return output as T } - const issue = InternalSchemaCause.getSchemaIssueOrThrow(exit.cause, "Sync adapter can only throw schema issues") - throw new Error("Schema validation failed", { cause: issue }) + return runParserSync(entry.decodeEffect, input, parseOptions) } } -/** @internal */ -export interface Parser { - ( - input: unknown, - options: SchemaAST.ParseOptions - ): Effect.Effect +const throwSyncCause = (cause: Cause.Cause): never => { + const issue = InternalSchemaCause.getSchemaIssueOrThrow( + cause, + "Sync adapter can only throw schema issues" + ) + throw new Error("Schema validation failed", { cause: issue }) } -/** @internal */ -export interface Compiler { - (ast: SchemaAST.AST): Parser -} - -const normalCompiler: Compiler = memoize((ast) => makeParser(ast, normalCompiler)) -const constructorCompiler: Compiler = memoize((ast) => makeParser(ast, constructorCompiler, compileConstructorDefault)) -const compileDefaulted = memoize((ast: SchemaAST.AST) => - makeParser(ast, constructorCompiler, compileConstructorDefault, ast.context?.constructorDefault) -) - -function compileConstructorDefault(ast: SchemaAST.AST): Parser { - return ast.context?.constructorDefault ? compileDefaulted(ast) : constructorCompiler(ast) -} +const throwSyncDefect = (defect: unknown): never => throwSyncCause(Cause.die(defect)) -function applyTransformation( - result: Effect.Effect, - current: unknown, - transformation: SchemaAST.Link["transformation"], +const runParserSync = ( + parser: Parser, + input: unknown, options: SchemaAST.ParseOptions -): Effect.Effect { - let transformed: Effect.Effect, SchemaIssue.Issue, unknown> - if (effectIsExit(result) && result._tag === "Success") { - const optional = InternalParser.toOption( - result === InternalParser.sameExit - ? current - : (result as InternalParser.Success)[InternalParser.args] - ) - transformed = transformation._tag === "Transformation" - ? transformation.decode.run(optional, options) - : transformation.decode(InternalParser.succeed(optional), options) - } else if (transformation._tag === "Transformation") { - transformed = Effect.flatMapEager( - result, - (value) => transformation.decode.run(InternalParser.toOption(value), options) - ) - } else { - transformed = transformation.decode( - Effect.mapEager(result, InternalParser.toOption), - options - ) - } - return effectIsExit(transformed) && transformed._tag === "Success" - ? InternalParser.fromOptionExit( - (transformed as InternalParser.Success, SchemaIssue.Issue>)[InternalParser.args] - ) - : Effect.flatMapEager(transformed, InternalParser.fromOptionExit) +): T => { + const exit = Effect.runSyncExit(runParser(parser, input, options)) + return Exit.isSuccess(exit) ? exit.value : throwSyncCause(exit.cause) } -function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, compile: Compiler): Parser { - let sourceParser: Parser - return (input, options) => { - if (input === InternalParser.missing) return InternalParser.missingExit - if (descriptor.isConstructed(input)) return InternalParser.sameExit - const result = (sourceParser ??= compile(descriptor.link.to))(input, options) - return applyTransformation(result, input, descriptor.link.transformation, options) - } -} +/** @internal */ +export type Parser = CompilerRegistry.Parser -function makeParser( - ast: SchemaAST.AST, - compile: Compiler, - compileConstructorDefault?: Compiler, - constructorDefault?: SchemaAST.Link -): Parser { - const descriptor = compileConstructorDefault ? SchemaAST.getConstructorDescriptor(ast) : undefined - const parser = descriptor - ? makeConstructorParser(descriptor, compile) - : ast.getParser(compile, compileConstructorDefault) - const checks = ast.checks - const links = constructorDefault - ? ast.encoding ? [...ast.encoding, constructorDefault] : [constructorDefault] - : ast.encoding - const encodingChecks = (ast as any).encodingChecks - const astOptions = (checks ? checks[checks.length - 1].annotations : ast.annotations) - ?.["parseOptions"] - if (!links && !checks && !encodingChecks) { - if (!astOptions) { - return parser - } - return (input, options) => parser(input, mergeParseOptions(options, astOptions)) - } - let encodingParsers: ReadonlyArray | undefined - const parseLocal = ( - input: unknown, - options: SchemaAST.ParseOptions - ) => { - let result = parser(input, options) - if (encodingChecks && !options.disableChecks) { - if (effectIsExit(result)) { - if (result._tag === "Success") { - const output = result === InternalParser.sameExit - ? input - : (result as InternalParser.Success)[InternalParser.args] - if (input !== InternalParser.missing && output !== InternalParser.missing) { - const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) - if (issues) { - result = Effect.fail(new SchemaIssue.Composite(ast, issues, input, options)) - } - } - } - } else { - result = Effect.flatMap(result, (value) => { - if (input !== InternalParser.missing && value !== InternalParser.missing) { - const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) - if (issues) { - return Effect.fail(new SchemaIssue.Composite(ast, issues, input, options)) - } - } - return Effect.succeed(value) - }) - } - } +/** @internal */ +export type Compiler = CompilerRegistry.ResolveParser - if (checks && !options.disableChecks) { - if (effectIsExit(result)) { - if (result._tag === "Success") { - const value = result === InternalParser.sameExit - ? input - : (result as InternalParser.Success)[InternalParser.args] - if (value === InternalParser.missing) return result - const issues = SchemaAST.collectIssues(checks, value, undefined, ast, options) - if (issues) { - result = Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) - } - } - } else { - result = Effect.flatMap(result, (value) => { - if (value !== InternalParser.missing) { - const issues = SchemaAST.collectIssues(checks, value, undefined, ast, options) - if (issues) { - return Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) - } - } - return Effect.succeed(value) - }) - } - } +const normalCompiler: Compiler = CompilerRegistry.resolveParser - return result - } - if (!links) { - return astOptions - ? (input, options) => parseLocal(input, mergeParseOptions(options, astOptions)) - : parseLocal - } - return ( - input: unknown, - options: SchemaAST.ParseOptions - ) => { - if (astOptions) { - options = mergeParseOptions(options, astOptions) - } - const parsers = encodingParsers ??= links.map((link) => compile(link.to)) - let current = input - let result = parsers[parsers.length - 1](input, options) - for (let i = links.length - 1; i >= 0; i--) { - result = applyTransformation(result, current, links[i].transformation, options) - if (i !== 0) { - const next = parsers[i - 1] - if ((result as Exit.Exit)._tag === "Success") { - current = (result as InternalParser.Success)[InternalParser.args] - result = next(current, options) - } else { - result = Effect.flatMapEager(result, (value) => { - const nextResult = next(value, options) - return nextResult === InternalParser.sameExit ? InternalParser.succeed(value) : nextResult - }) - } - } - } - if ((result as Exit.Exit)._tag === "Success") { - const value = (result as InternalParser.Success)[InternalParser.args] - const local = parseLocal(value, options) - return local === InternalParser.sameExit ? result : local - } - result = Effect.catchCause( - result, - (cause) => - Effect.failCauseSync(() => - Cause.map( - cause, - (issue) => - new SchemaIssue.Encoding( - ast, - issue, - input, - options - ) - ) - ) - ) - return Effect.flatMapEager(result, (value) => { - const local = parseLocal(value, options) - return local === InternalParser.sameExit ? InternalParser.succeed(value) : local - }) - } -} +const constructorCompiler: Compiler = CompilerRegistry.resolveConstructor diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index aa4417f28a4..fbc168d06d9 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -393,7 +393,7 @@ export interface Objects extends Keyword<"Objects"> { */ export interface Union extends Keyword<"Union"> { readonly types: ReadonlyArray - readonly mode: "anyOf" | "oneOf" + readonly options?: SchemaAST.UnionOptions | undefined } /** @@ -2614,7 +2614,9 @@ const UnionSchema = Schema.Struct({ _tag: Schema.tag("Union"), ...KeywordFields, types: RepresentationsSchema, - mode: Schema.Literals(["anyOf", "oneOf"]) + options: Schema.optionalKey(Schema.Struct({ + mode: Schema.optionalKey(Schema.Literals(["anyOf", "oneOf"])) + })) }) const ReferenceSchema = Schema.Struct({ _tag: Schema.tag("Reference"), diff --git a/packages/effect/src/internal/arbitrary/schema.ts b/packages/effect/src/internal/arbitrary/schema.ts index ec49f562570..7ec3b7c68d6 100644 --- a/packages/effect/src/internal/arbitrary/schema.ts +++ b/packages/effect/src/internal/arbitrary/schema.ts @@ -1292,7 +1292,7 @@ export function compile(schema: S): Model.Compiled< const members = ast.types.map((member) => recur(member, path, constraint)) if (members.length === 0) throw arbitraryError("a union with no members", path) let generate = (state: Model.GenerationState): Model.Generation => Model.generateUnion(members, state) - if (ast.mode === "oneOf") { + if (ast.options?.mode === "oneOf") { const parse = SchemaParser.run(ast) const validate = (value: unknown) => optionComputation(parse(value)) generate = (state) => Model.filterMapGeneration(Model.generateUnion(members, state), validate) diff --git a/packages/effect/src/internal/effect.ts b/packages/effect/src/internal/effect.ts index 6383deb7f04..56a50f34038 100644 --- a/packages/effect/src/internal/effect.ts +++ b/packages/effect/src/internal/effect.ts @@ -4915,19 +4915,16 @@ const forEachSequential = ( ) }) -type IterateEagerOptions = { - readonly concurrency?: number | undefined - readonly end?: number | undefined - readonly orderedStep?: boolean | undefined -} - -const iterateEagerImpl = (options: { +/** @internal */ +export const iterateEager = () => +(options: { readonly onItem: (state: S, item: A, index: number) => Effect.Effect readonly step: (state: NoInfer, item: A, exit: Exit.Exit, index: number) => Exit.Exit | void }): ( initialState: S, items: ReadonlyArray, - options?: IterateEagerOptions + start?: number, + end?: number ) => Effect.Effect | undefined => { const onItem = options.onItem const step = options.step @@ -4935,8 +4932,8 @@ const iterateEagerImpl = (options: { const runSequential = ( state: S, items: ReadonlyArray, - index: number, - end: number + index = 0, + end = items.length ): Effect.Effect | undefined => { for (; index < end; index++) { const item = items[index] @@ -4952,18 +4949,23 @@ const iterateEagerImpl = (options: { } } + return runSequential +} + +const iterateConcurrent = (options: { + readonly onItem: (state: S, item: A, index: number) => Effect.Effect + readonly step: (state: NoInfer, item: A, exit: Exit.Exit, index: number) => Exit.Exit | void +}) => { + const onItem = options.onItem + const step = options.step return ( state: S, items: ReadonlyArray, - opts: IterateEagerOptions | undefined + opts: { readonly concurrency: number } ): Effect.Effect | undefined => { let index = 0 - const end = opts?.end ?? items.length - const concurrency = opts?.concurrency ?? 1 - if (concurrency === 1) { - return runSequential(state, items, 0, end) - } - const orderedStep = opts?.orderedStep === true + const end = items.length + const concurrency = opts.concurrency let done = false let parentFiber: Fiber.Fiber | undefined let fibers: Set> | undefined @@ -4971,8 +4973,6 @@ const iterateEagerImpl = (options: { let interrupted = false let terminal: Exit.Exit | void let effect: Effect.Effect | undefined - let nextIndex = index - const exits: Array | undefined> | undefined = orderedStep ? new Array(end) : undefined const failDefect = (error: unknown): Effect.Effect => { const defect = exitDie(error) @@ -4984,20 +4984,6 @@ const iterateEagerImpl = (options: { : defect } - const runStep = (item: A, exit: Exit.Exit, currentIndex: number): Exit.Exit | void => { - if (!orderedStep) return step(state, item, exit, currentIndex) - if (terminal) return terminal - exits![currentIndex] = exit - while (nextIndex < end) { - const nextExit = exits![nextIndex] - if (nextExit === undefined) return - exits![nextIndex] = undefined - const index = nextIndex++ - const result = step(state, items[index], nextExit, index) - if (result) return result - } - } - const go = (): Effect.Effect | undefined => { let paused = false for (; !terminal && index < end; index++) { @@ -5006,7 +4992,7 @@ const iterateEagerImpl = (options: { // fast case (already an exit) if (effectIsExit(eff)) { - terminal = runStep(item, eff, index) + terminal = step(state, item, eff, index) if (terminal) break // We have an effect, so enter "async" mode @@ -5037,7 +5023,7 @@ const iterateEagerImpl = (options: { const fiber = forkUnsafe(parentFiber, eff, true, true, "inherit") if (fiber._exit) { - terminal = runStep(item, fiber._exit, index) + terminal = step(state, item, fiber._exit, index) if (terminal) break continue } @@ -5061,7 +5047,7 @@ const iterateEagerImpl = (options: { } } } else { - const result = runStep(item, exit, currentIndex) + const result = step(state, item, exit, currentIndex) if (result) { terminal = result._tag === "Failure" ? exitFailCause(causeFromReasons(result.cause.reasons.slice())) @@ -5113,17 +5099,7 @@ const iterateEagerImpl = (options: { } } -/** @internal */ -export const iterateEager = (): (options: { - readonly onItem: (state: S, item: A, index: number) => Effect.Effect - readonly step: (state: NoInfer, item: A, exit: Exit.Exit, index: number) => Exit.Exit | void -}) => ( - initialState: S, - items: ReadonlyArray, - options?: IterateEagerOptions -) => Effect.Effect | undefined => iterateEagerImpl - -const forEachConcurrent = iterateEagerImpl({ +const forEachConcurrent = iterateConcurrent({ onItem( state: { readonly f: (a: any, i: number) => Effect.Effect diff --git a/packages/effect/src/internal/schema/arrays.ts b/packages/effect/src/internal/schema/arrays.ts new file mode 100644 index 00000000000..14309d0b4eb --- /dev/null +++ b/packages/effect/src/internal/schema/arrays.ts @@ -0,0 +1,132 @@ +import type * as Arr from "../../Array.ts" +import * as Effect from "../../Effect.ts" +import * as Exit from "../../Exit.ts" +import type { Arrays, AST, ParseOptions } from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import type * as SchemaParser from "../../SchemaParser.ts" +import { iterateEager } from "../effect.ts" +import { wrapPropertyKeyIssue } from "./cause.ts" +import * as Diagnostics from "./diagnostics.ts" +import * as InternalParser from "./parser.ts" + +/** @internal */ +export function makeArrayParser(ast: Arrays, compile: SchemaParser.Compiler): SchemaParser.Parser { + type ElementParser = { readonly ast: AST; readonly parser: SchemaParser.Parser } + let elements: Array | undefined + let rest: Array | undefined + const elementLen = ast.elements.length + const tailLen = Math.max(0, ast.rest.length - 1) + + function getParser( + tailThreshold: number, + index: number + ): { readonly ast: AST; readonly parser: SchemaParser.Parser } { + if (index < elementLen) return elements![index] + if (index >= tailThreshold) return rest![index - tailThreshold + 1] + return rest![0] + } + + return Effect.fnUntracedEager(function*(input, options) { + if (input === InternalParser.missing) { + return InternalParser.missing + } + + // If the input is not an array, return early with an error + if (!Array.isArray(input)) { + return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + if (!elements) { + const makeElement = (ast: AST): ElementParser => { + const out: ElementParser = { + ast, + parser(input, options) { + const parser = compile(ast) + Object.defineProperty(out, "parser", { value: parser }) + return parser(input, options) + } + } + return out + } + elements = ast.elements.map(makeElement) + rest = ast.rest.map(makeElement) + } + + const len = input.length + const state = { + ast, + getParser, + input, + len, + tailThreshold: Math.max(elementLen, len - tailLen), + output: new globalThis.Array(len), + issues: undefined as Arr.NonEmptyArray | undefined, + options + } + const eff = parseArray(state, input, 0, ast.rest.length === 0 ? elementLen : Math.max(len, elementLen + tailLen)) + if (eff) yield* eff + + // --------------------------------------------- + // handle excess indexes + // --------------------------------------------- + if (ast.rest.length === 0 && len > elementLen) { + for (let i = elementLen; i <= len - 1; i++) { + const issue = Diagnostics.unexpectedKey(ast, i, input[i], options) + if (options.errors === "all") { + if (state.issues) state.issues.push(issue) + else state.issues = [issue] + } else { + return yield* Effect.fail( + new SchemaIssue.Composite(ast, [issue], input, options) + ) + } + } + } + if (state.issues) { + return yield* Effect.fail( + new SchemaIssue.Composite(ast, state.issues, input, options) + ) + } + return state.output + }) +} +const parseArray = iterateEager<{ + readonly ast: AST + readonly input: unknown + readonly len: number + readonly getParser: ( + tailThreshold: number, + index: number + ) => { readonly ast: AST; readonly parser: SchemaParser.Parser } + readonly tailThreshold: number + readonly options: ParseOptions + readonly output: Array + issues: Array | undefined +}, unknown>()({ + onItem(s, item, i) { + const value = i < s.len ? item : InternalParser.missing + return s.getParser(s.tailThreshold, i).parser(value, s.options) + }, + step(s, item, exit, i) { + if (exit._tag === "Failure") { + return wrapPropertyKeyIssue(s, s.ast, i, exit) + } + const value = exit === InternalParser.unchangedExit + ? item + : (exit as InternalParser.Success)[InternalParser.args] + if (value !== InternalParser.missing) { + s.output[i] = value + } else { + const p = s.getParser(s.tailThreshold, i) + if (p.ast.context?.isOptional) return + const issue = Diagnostics.missingKey(i, p.ast) + if (s.options.errors === "all") { + if (s.issues) s.issues.push(issue) + else s.issues = [issue] + } else { + return Exit.fail( + new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) + ) + } + } + } +}) diff --git a/packages/effect/src/internal/schema/cause.ts b/packages/effect/src/internal/schema/cause.ts index 39d91d7d0d3..786eeb1c841 100644 --- a/packages/effect/src/internal/schema/cause.ts +++ b/packages/effect/src/internal/schema/cause.ts @@ -1,6 +1,38 @@ import * as Cause from "../../Cause.ts" +import * as Exit from "../../Exit.ts" +import type * as SchemaAST from "../../SchemaAST.ts" import * as SchemaIssue from "../../SchemaIssue.ts" +/** @internal */ +export function wrapPropertyKeyIssue( + state: { + readonly input: unknown + readonly options: SchemaAST.ParseOptions + issues: Array | undefined + }, + ast: SchemaAST.AST, + key: PropertyKey, + exit: Exit.Failure +): Exit.Exit | undefined { + if (exit.cause.reasons.length === 0) return exit + const issue = getSchemaIssue(exit.cause) + if (issue === undefined) { + return Exit.failCause( + Cause.map( + exit.cause, + (issue) => new SchemaIssue.Composite(ast, [new SchemaIssue.Pointer([key], issue)], state.input, state.options) + ) + ) + } + const pointer = new SchemaIssue.Pointer([key], issue) + if (state.options.errors === "all") { + if (state.issues) state.issues.push(pointer) + else state.issues = [pointer] + } else { + return Exit.fail(new SchemaIssue.Composite(ast, [pointer], state.input, state.options)) + } +} + /** @internal */ export function getSchemaIssue(cause: Cause.Cause): SchemaIssue.Issue | undefined { let issue: SchemaIssue.Issue | undefined diff --git a/packages/effect/src/internal/schema/checks.ts b/packages/effect/src/internal/schema/checks.ts new file mode 100644 index 00000000000..60695d51416 --- /dev/null +++ b/packages/effect/src/internal/schema/checks.ts @@ -0,0 +1,26 @@ +import * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import { missing } from "./parser.ts" + +/** @internal */ +export const getEncodingChecks = (ast: SchemaAST.AST): SchemaAST.Checks | undefined => + "encodingChecks" in ast ? ast.encodingChecks : undefined + +/** @internal */ +export const checkOutput = ( + ast: SchemaAST.AST, + input: unknown, + output: unknown, + options: SchemaAST.ParseOptions +): SchemaIssue.Composite | undefined => { + if (output === missing || options.disableChecks) return + const encodingChecks = getEncodingChecks(ast) + if (encodingChecks !== undefined && input !== missing) { + const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) + if (issues !== undefined) return new SchemaIssue.Composite(ast, issues, input, options) + } + if (ast.checks !== undefined) { + const issues = SchemaAST.collectIssues(ast.checks, output, undefined, ast, options) + if (issues !== undefined) return new SchemaIssue.Composite(ast, issues, output, options) + } +} diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts new file mode 100644 index 00000000000..271b1a7665a --- /dev/null +++ b/packages/effect/src/internal/schema/codegen.ts @@ -0,0 +1,722 @@ +import * as SchemaAST from "../../SchemaAST.ts" +import type { runtime } from "../../unstable/schema/SchemaCompiler/runtime.ts" +import { getEncodingChecks } from "./checks.ts" +import { getExpectedKeys } from "./diagnostics.ts" + +const isOptional = (ast: SchemaAST.AST): boolean => ast.context?.isOptional ?? false + +const maxGeneratedDepth = 256 +/** @internal */ +export const maxGeneratedNodes = 2048 + +type Emission = "unsupported" | "validate" | "is" +type Operation = "validate" | "is" + +const failureExpression = (operation: Operation): string => operation === "validate" ? "I" : "false" + +/** @internal */ +const getEmission = ( + ast: SchemaAST.AST, + depth = 0, + local = false, + budget = { remaining: maxGeneratedNodes } +): Emission => { + // Count occurrences, not distinct ASTs: shared subgraphs are expanded by the emitter. + if (--budget.remaining < 0 || depth > maxGeneratedDepth || !local && ast.encoding !== undefined) return "unsupported" + switch (ast._tag) { + case "Null": + case "Undefined": + case "Void": + case "Never": + case "Any": + case "Unknown": + case "ObjectKeyword": + case "Enum": + case "UniqueSymbol": + case "Literal": + case "String": + case "Number": + case "Boolean": + case "Symbol": + case "BigInt": + return "is" + case "TemplateLiteral": { + for (const part of ast.parts) { + if (getEmission(part, depth + 1, false, budget) === "unsupported") return "unsupported" + } + return "is" + } + case "Arrays": { + let isOutputFree = ast.checks === undefined + for (const element of ast.elements) { + const emission = getEmission(element, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + for (const element of ast.rest) { + const emission = getEmission(element, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + return isOutputFree ? "is" : "validate" + } + case "Objects": { + let isOutputFree = ast.checks === undefined + for (const property of ast.propertySignatures) { + const emission = getEmission(property.type, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + for (const signature of ast.indexSignatures) { + const key = getEmission(SchemaAST.parameterFromPropertyKey(signature.parameter), depth + 1, false, budget) + const value = getEmission(signature.type, depth + 1, false, budget) + if (key === "unsupported" || value === "unsupported") return "unsupported" + if (key === "validate" || value === "validate") isOutputFree = false + } + return isOutputFree ? "is" : "validate" + } + case "Union": { + let isOutputFree = ast.checks === undefined + for (const type of ast.types) { + const emission = getEmission(type, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + return isOutputFree ? "is" : "validate" + } + default: + return "unsupported" + } +} + +const canEmit = (ast: SchemaAST.AST, depth = 0): boolean => getEmission(ast, depth) !== "unsupported" + +type Emitter = { + readonly statements: Array + readonly helpers: Array + readonly initializers: Array + readonly decoderHelpers: Map + readonly unionHelpers: Map + readonly bindings: Array + readonly constantIndexes: Map + next: number +} + +const variable = (emitter: Emitter): string => `v${emitter.next++}` + +const propertyKey = (emitter: Emitter, key: PropertyKey, reference: string): string => + typeof key === "string" ? JSON.stringify(key) : constant(emitter, key, reference) + +const propertyPresence = (input: string, key: string, name: PropertyKey): string => + name === "__proto__" ? `Object.hasOwn(${input},${key})` : `${key} in ${input}` + +const assignProperty = (output: string, key: string, value: string, name: PropertyKey): string => + name === "__proto__" + ? `Object.defineProperty(${output},${key},{value:${value},writable:true,enumerable:true,configurable:true})` + : `${output}[${key}]=${value}` + +const constant = (emitter: Emitter, value: unknown, reference: string): string => { + const cached = emitter.constantIndexes.get(value) + if (cached !== undefined) return `C[${cached}]` + const index = emitter.bindings.length + emitter.bindings.push({ value, reference }) + emitter.constantIndexes.set(value, index) + return `C[${index}]` +} + +const needsPresenceCheck = (ast: SchemaAST.AST): boolean => { + if (!canEmit(ast)) return true + switch (ast._tag) { + case "Undefined": + case "Void": + case "Any": + case "Unknown": + return true + case "Union": + return ast.types.some(needsPresenceCheck) + default: + return false + } +} + +const propertyNeedsPresenceCheck = (name: PropertyKey, ast: SchemaAST.AST): boolean => + name === "__proto__" || needsPresenceCheck(ast) + +/** @internal */ +const shouldCompileParser = (ast: SchemaAST.AST, local = false): boolean => { + if (!local && ast.encoding !== undefined) return true + if (ast.checks !== undefined || getEncodingChecks(ast) !== undefined) return true + switch (ast._tag) { + case "TemplateLiteral": + case "Arrays": + case "Objects": + case "Union": + return true + default: + return false + } +} + +const lookupMemberValues = (ast: SchemaAST.AST): ReadonlyArray | undefined => { + if (ast.checks !== undefined) return undefined + switch (ast._tag) { + case "Null": + return [null] + case "Undefined": + return [undefined] + case "Literal": + return [ast.literal] + case "UniqueSymbol": + return [ast.symbol] + case "Enum": + return [...new Set(ast.enums.map((entry) => entry[1]))] + default: + return undefined + } +} + +const lookupMemberReferences = (ast: SchemaAST.AST, path: string): ReadonlyArray => { + switch (ast._tag) { + case "Null": + return ["null"] + case "Undefined": + return ["void 0"] + case "Literal": + return [`${path}.literal`] + case "UniqueSymbol": + return [`${path}.symbol`] + case "Enum": { + const references = new Map() + ast.enums.forEach((entry, index) => { + if (!references.has(entry[1])) references.set(entry[1], `${path}.enums[${index}][1]`) + }) + return [...references.values()] + } + default: + throw new Error(`Unsupported lookup member: ${ast._tag}`) + } +} + +function emit( + ast: SchemaAST.AST, + input: string, + statements: Array, + emitter: Emitter, + operation: Operation, + path: string +): string { + const output = emitBase(ast, input, statements, emitter, operation, path) + const invalid = failureExpression(operation) + const encodingChecks = getEncodingChecks(ast) + const astConstant = ast.checks !== undefined || encodingChecks !== undefined + ? constant(emitter, ast, path) + : undefined + if (encodingChecks !== undefined) { + statements.push(`if(K(${astConstant},${input},1,o))return ${invalid}`) + } + if (ast.checks === undefined) return operation === "validate" ? output : "true" + const checked = variable(emitter) + statements.push( + `const ${checked}=${output}`, + `if(K(${astConstant},${checked},0,o))return ${invalid}` + ) + return operation === "validate" ? checked : "true" +} + +const emitDecoderHelper = (ast: SchemaAST.AST, emitter: Emitter, operation: Operation, path: string): string => { + const cached = emitter.decoderHelpers.get(ast) + if (cached !== undefined) return cached + const name = `d${emitter.next++}` + emitter.decoderHelpers.set(ast, name) + const statements: Array = [] + const output = emit(ast, "i", statements, emitter, operation, path) + emitter.helpers.push( + `function ${name}(i,o){${statements.join(";")};return ${output}}` + ) + return name +} + +const emitUnionHelper = (ast: SchemaAST.Union, emitter: Emitter, operation: Operation, path: string): string => { + const cached = emitter.unionHelpers.get(ast) + if (cached !== undefined) return cached + const name = `u${emitter.next++}` + emitter.unionHelpers.set(ast, name) + const entries = ast.types.map((type, index) => + `[${constant(emitter, type, `${path}.types[${index}]`)},${ + emitDecoderHelper(type, emitter, operation, `${path}.types[${index}]`) + }]` + ) + emitter.initializers.push(`const ${name}=new Map([${entries.join(",")}])`) + return name +} + +const emitIndexes = ( + ast: SchemaAST.Objects, + input: string, + output: string | undefined, + statements: Array, + emitter: Emitter, + operation: Operation, + path: string +): void => { + const fixedKeys = output === undefined || ast.propertySignatures.length === 0 + ? undefined + : constant( + emitter, + new Set(getExpectedKeys(ast)), + `new Set(${runtimeReference("getExpectedKeys")}(${path}))` + ) + for (let signatureIndex = 0; signatureIndex < ast.indexSignatures.length; signatureIndex++) { + const signature = ast.indexSignatures[signatureIndex] + const signaturePath = `${path}.indexSignatures[${signatureIndex}]` + const keys = variable(emitter) + const index = variable(emitter) + const key = variable(emitter) + const parameter = signature.parameter + statements.push( + `const ${keys}=${ + parameter._tag === "String" && parameter.checks === undefined + ? `Object.keys(${input})` + : `G(${input},${constant(emitter, parameter, `${signaturePath}.parameter`)},o)` + }` + ) + const loop: Array = [`const ${key}=${keys}[${index}]`] + const decodedKey = parameter._tag === "String" && parameter.checks === undefined && parameter.encoding === undefined + ? key + : emit( + SchemaAST.parameterFromPropertyKey(parameter), + key, + loop, + emitter, + operation, + `${runtimeReference("parameterFromPropertyKey")}(${signaturePath}.parameter)` + ) + const value = variable(emitter) + loop.push(`const ${value}=${input}[${key}]`) + const decoded = emit(signature.type, value, loop, emitter, operation, `${signaturePath}.type`) + if (output !== undefined) { + const assign = + `if(${decodedKey}==="__proto__")Object.defineProperty(${output},${decodedKey},{value:${decoded},writable:true,enumerable:true,configurable:true});else ${output}[${decodedKey}]=${decoded}` + loop.push( + fixedKeys === undefined + ? assign + : `if(!${fixedKeys}.has(${key})&&!${fixedKeys}.has(${decodedKey})){${assign}}` + ) + } + statements.push(`for(let ${index}=0;${index}<${keys}.length;${index}++){${loop.join(";")}}`) + } +} + +const emitBase = ( + ast: SchemaAST.AST, + input: string, + statements: Array, + emitter: Emitter, + operation: Operation, + path: string +): string => { + const needsValue = operation === "validate" + const invalid = failureExpression(operation) + switch (ast._tag) { + case "Null": + statements.push(`if(${input}!==null)return ${invalid}`) + return input + case "Undefined": + statements.push(`if(${input}!==void 0)return ${invalid}`) + return input + case "Void": + return "void 0" + case "Never": + statements.push(`return ${invalid}`) + return input + case "Any": + case "Unknown": + return input + case "ObjectKeyword": + statements.push( + `if((${input}===null||typeof ${input}!=="object")&&typeof ${input}!=="function")return ${invalid}` + ) + return input + case "Enum": { + const values = constant( + emitter, + new Set(ast.enums.map((entry) => entry[1])), + `new Set(${path}.enums.map(entry=>entry[1]))` + ) + statements.push(`if(!${values}.has(${input}))return ${invalid}`) + return input + } + case "UniqueSymbol": { + const value = constant(emitter, ast.symbol, `${path}.symbol`) + statements.push(`if(${input}!==${value})return ${invalid}`) + return input + } + case "Literal": { + const value = constant(emitter, ast.literal, `${path}.literal`) + statements.push(`if(${input}!==${value})return ${invalid}`) + return input + } + case "String": + statements.push(`if(typeof ${input}!=="string")return ${invalid}`) + return input + case "Number": + statements.push(`if(typeof ${input}!=="number")return ${invalid}`) + return input + case "Boolean": + statements.push(`if(typeof ${input}!=="boolean")return ${invalid}`) + return input + case "Symbol": + statements.push(`if(typeof ${input}!=="symbol")return ${invalid}`) + return input + case "BigInt": + statements.push(`if(typeof ${input}!=="bigint")return ${invalid}`) + return input + case "TemplateLiteral": { + const template = constant(emitter, ast, path) + statements.push(`if(!T(${template},${input},o))return ${invalid}`) + return input + } + case "Arrays": { + statements.push(`if(!Array.isArray(${input}))return ${invalid}`) + const length = variable(emitter) + statements.push(`const ${length}=${input}.length`) + const elementLength = ast.elements.length + const requiredElementLength = ast.elements.findIndex(isOptional) + const minimumElementLength = requiredElementLength === -1 ? elementLength : requiredElementLength + const tailLength = Math.max(0, ast.rest.length - 1) + if (ast.rest.length === 0) { + statements.push( + minimumElementLength === elementLength + ? `if(${length}!==${elementLength})return ${invalid}` + : `if(${length}<${minimumElementLength}||${length}>${elementLength})return ${invalid}` + ) + if (minimumElementLength === elementLength) { + const elements = ast.elements.map((element, index) => { + const value = variable(emitter) + statements.push(`const ${value}=${input}[${index}]`) + return emit(element, value, statements, emitter, operation, `${path}.elements[${index}]`) + }) + return needsValue ? `[${elements.join(",")}]` : input + } + const output = needsValue ? variable(emitter) : undefined + if (output !== undefined) statements.push(`const ${output}=new Array(${length})`) + for (let index = 0; index < elementLength; index++) { + const value = variable(emitter) + const elementStatements: Array = [`const ${value}=${input}[${index}]`] + const decoded = emit( + ast.elements[index], + value, + elementStatements, + emitter, + operation, + `${path}.elements[${index}]` + ) + if (output !== undefined) elementStatements.push(`${output}[${index}]=${decoded}`) + statements.push( + index < minimumElementLength + ? elementStatements.join(";") + : `if(${index}<${length}){${elementStatements.join(";")}}` + ) + } + return output ?? input + } + statements.push(`if(${length}<${minimumElementLength + tailLength})return ${invalid}`) + const output = needsValue ? variable(emitter) : undefined + if (output !== undefined) statements.push(`const ${output}=new Array(${length})`) + for (let index = 0; index < elementLength; index++) { + const value = variable(emitter) + const elementStatements: Array = [`const ${value}=${input}[${index}]`] + const decoded = emit( + ast.elements[index], + value, + elementStatements, + emitter, + operation, + `${path}.elements[${index}]` + ) + if (output !== undefined) elementStatements.push(`${output}[${index}]=${decoded}`) + statements.push( + index < minimumElementLength + ? elementStatements.join(";") + : `if(${index}<${length}){${elementStatements.join(";")}}` + ) + } + const index = variable(emitter) + const restStatements: Array = [] + const value = variable(emitter) + restStatements.push(`const ${value}=${input}[${index}]`) + const decoded = emit(ast.rest[0], value, restStatements, emitter, operation, `${path}.rest[0]`) + if (output !== undefined) restStatements.push(`${output}[${index}]=${decoded}`) + statements.push( + `for(let ${index}=${elementLength};${index}<${length}-${tailLength};${index}++){${restStatements.join(";")}}` + ) + for (let index = 0; index < tailLength; index++) { + const inputIndex = `${length}-${tailLength - index}` + const value = variable(emitter) + statements.push(`const ${value}=${input}[${inputIndex}]`) + const decoded = emit(ast.rest[index + 1], value, statements, emitter, operation, `${path}.rest[${index + 1}]`) + if (output !== undefined) statements.push(`${output}[${inputIndex}]=${decoded}`) + } + return output ?? input + } + case "Objects": { + if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { + statements.push(`if(${input}===null||${input}===void 0)return ${invalid}`) + return input + } + statements.push( + `if(typeof ${input}!=="object"||${input}===null||Array.isArray(${input}))return ${invalid}` + ) + statements.push( + `if(o!==D&&o.onExcessProperty==="error"&&E(${constant(emitter, ast, path)},${input},o))return ${invalid}` + ) + const hasOptional = ast.propertySignatures.some((property) => isOptional(property.type)) + if (needsValue && ast.propertySignatures.length > 0 && !hasOptional) { + const output = variable(emitter) + const properties = ast.propertySignatures.map((property, index) => { + const propertyPath = `${path}.propertySignatures[${index}]` + const key = propertyKey(emitter, property.name, `${propertyPath}.name`) + const outputKey = typeof property.name === "string" && property.name !== "__proto__" ? key : `[${key}]` + const value = variable(emitter) + if (propertyNeedsPresenceCheck(property.name, property.type)) { + statements.push(`if(!(${propertyPresence(input, key, property.name)}))return ${invalid}`) + } + statements.push(`const ${value}=${input}[${key}]`) + return `${outputKey}:${emit(property.type, value, statements, emitter, operation, `${propertyPath}.type`)}` + }) + statements.push(`const ${output}={${properties.join(",")}}`) + if (ast.indexSignatures.length > 0) emitIndexes(ast, input, output, statements, emitter, operation, path) + return output + } + const output = needsValue ? variable(emitter) : undefined + if (output !== undefined) statements.push(`const ${output}={}`) + for (let propertyIndex = 0; propertyIndex < ast.propertySignatures.length; propertyIndex++) { + const property = ast.propertySignatures[propertyIndex] + const propertyPath = `${path}.propertySignatures[${propertyIndex}]` + const key = propertyKey(emitter, property.name, `${propertyPath}.name`) + const value = variable(emitter) + const propertyStatements: Array = [`const ${value}=${input}[${key}]`] + const decoded = emit(property.type, value, propertyStatements, emitter, operation, `${propertyPath}.type`) + if (output !== undefined) propertyStatements.push(assignProperty(output, key, decoded, property.name)) + statements.push( + isOptional(property.type) + ? `if(${propertyPresence(input, key, property.name)}){${propertyStatements.join(";")}}` + : `${ + propertyNeedsPresenceCheck(property.name, property.type) + ? `if(!(${propertyPresence(input, key, property.name)}))return ${invalid};` + : "" + }${propertyStatements.join(";")}` + ) + } + if (ast.indexSignatures.length > 0) emitIndexes(ast, input, output, statements, emitter, operation, path) + return output ?? input + } + case "Union": { + const memberValues = ast.types.map(lookupMemberValues) + if (memberValues.every((values) => values !== undefined)) { + const references = ast.types.map((type, index) => lookupMemberReferences(type, `${path}.types[${index}]`)) + if (ast.options?.mode !== "oneOf") { + const values = constant(emitter, new Set(memberValues.flat()), `new Set([${references.flat().join(",")}])`) + statements.push(`if(!${values}.has(${input}))return ${invalid}`) + } else { + const counts = new Map() + const valueReferences = new Map() + for (let memberIndex = 0; memberIndex < memberValues.length; memberIndex++) { + const values = memberValues[memberIndex] + for (let valueIndex = 0; valueIndex < values.length; valueIndex++) { + const value = values[valueIndex] + counts.set(value, (counts.get(value) ?? 0) + 1) + if (!valueReferences.has(value)) valueReferences.set(value, references[memberIndex][valueIndex]) + } + } + const entries = [...counts].map(([value, count]) => `[${valueReferences.get(value)},${count}]`) + const lookup = constant(emitter, counts, `new Map([${entries.join(",")}])`) + statements.push(`if(${lookup}.get(${input})!==1)return ${invalid}`) + } + return input + } + const candidates = variable(emitter) + const output = variable(emitter) + const candidate = variable(emitter) + const index = variable(emitter) + const decoder = variable(emitter) + const types = constant(emitter, ast.types, `${path}.types`) + const decoders = emitUnionHelper(ast, emitter, operation, path) + statements.push( + `const ${candidates}=U(${input},${types})`, + `let ${output}=${invalid},${candidate},${decoder}` + ) + if (ast.options?.mode !== "oneOf") { + statements.push( + `for(let ${index}=0;${index}<${candidates}.length;${index}++){${decoder}=${decoders}.get(${candidates}[${index}]);${candidate}=${decoder}(${input},o);if(${candidate}!==${invalid}){${output}=${candidate};break}}` + ) + statements.push(`if(${output}===${invalid})return ${invalid}`) + } else { + const successes = variable(emitter) + statements.push(`let ${successes}=0`) + statements.push( + `for(let ${index}=0;${index}<${candidates}.length;${index}++){${decoder}=${decoders}.get(${candidates}[${index}]);${candidate}=${decoder}(${input},o);if(${candidate}!==${invalid}){if(++${successes}>1)return ${invalid};${output}=${candidate}}}` + ) + statements.push(`if(${successes}!==1)return ${invalid}`) + } + return output + } + default: + throw new Error(`Unsupported Schema AST: ${ast._tag}`) + } +} + +/** @internal */ +export type Selection = + | { readonly _tag: "Fallback" } + | { readonly _tag: "Type"; readonly ast: SchemaAST.AST; readonly outputFree: boolean } + | { readonly _tag: "Encoding"; readonly ast: SchemaAST.AST } + | { readonly _tag: "Object"; readonly ast: SchemaAST.Objects } + +/** @internal */ +export const select = (ast: SchemaAST.AST, local = false): Selection => { + if (!shouldCompileParser(ast, local)) return { _tag: "Fallback" } + const emission = getEmission(ast, 0, local) + if (emission !== "unsupported") return { _tag: "Type", ast, outputFree: emission === "is" } + if (!local && ast.encoding !== undefined) return { _tag: "Encoding", ast } + if (ast._tag === "Objects" && ast.indexSignatures.length === 0) { + return { _tag: "Object", ast } + } + return { _tag: "Fallback" } +} + +/** @internal */ +export const selectConstructor = ( + ast: SchemaAST.AST +): "Object" | "Objects" | "Arrays" | "Union" | "Class" | "Leaf" | undefined => { + if (ast.encoding !== undefined) return undefined + if (SchemaAST.getConstructorDescriptor(ast) !== undefined) return "Class" + switch (ast._tag) { + case "Objects": + return ast.propertySignatures.length > 0 && ast.propertySignatures.length <= maxGeneratedNodes && + ast.indexSignatures.length === 0 ? + "Object" : + "Objects" + case "Declaration": + case "Suspend": + return undefined + case "Arrays": + return "Arrays" + case "Union": + return "Union" + default: + return "Leaf" + } +} + +/** @internal */ +export interface Binding { + readonly value: unknown + readonly reference: string +} + +/** @internal */ +export const runtimeReference = (name: keyof typeof runtime): string => `R.${name}` + +const runtimeBindings = (aliases: Readonly>): string => + `const {${Object.entries(aliases).map(([alias, name]) => `${name}:${alias}`).join(",")}}=R;` + +/** @internal */ +export interface GeneratedOperation { + readonly source: string + readonly bindings: ReadonlyArray +} + +const emitOperation = (ast: SchemaAST.AST, operation: Operation): GeneratedOperation => { + const emitter: Emitter = { + statements: [], + helpers: [], + initializers: [], + decoderHelpers: new Map(), + unionHelpers: new Map(), + bindings: [], + constantIndexes: new Map(), + next: 0 + } + const output = emit(ast, "i", emitter.statements, emitter, operation, "ast") + const bindings = { + K: "failsChecks", + T: "matchesTemplateLiteral", + U: "getCandidates", + G: "getIndexSignatureKeys", + D: "defaultParseOptions", + E: "hasExcessProperties" + } as const + const source = `"use strict";${runtimeBindings(operation === "validate" ? { I: "invalid", ...bindings } : bindings)}${ + emitter.helpers.join(";") + };${emitter.initializers.join(";")};return function(i,o){${emitter.statements.join(";")};return ${output}}` + return { source, bindings: emitter.bindings } +} + +/** @internal */ +export const emitIs = (ast: SchemaAST.AST): GeneratedOperation => emitOperation(ast, "is") + +/** @internal */ +export const emitValidate = (ast: SchemaAST.AST): GeneratedOperation => emitOperation(ast, "validate") + +/** @internal */ +export const emitComposedObject = (ast: SchemaAST.Objects): string => { + const properties = ast.propertySignatures + const statements = [ + "if(o!==D&&!O(o))return F(i,o)", + "if(i===M)return MX", + "if(typeof i!==\"object\"||i===null||Array.isArray(i))return IT(T,i,o)", + "const out={}", + "let r,x" + ] + for (let index = 0; index < properties.length; index++) { + const property = properties[index] + const key = typeof property.name === "string" ? JSON.stringify(property.name) : "P[" + index + "].name" + const value = "v" + index + const assignInput = property.name === "__proto__" || typeof property.name !== "string" + ? "AP(out," + key + "," + value + ")" + : "out[" + key + "]=" + value + const assignDecoded = property.name === "__proto__" || typeof property.name !== "string" + ? "AP(out," + key + ",x)" + : "out[" + key + "]=x" + const present = property.name === "__proto__" ? "Object.hasOwn(i," + key + ")" : key + " in i" + if (property.name !== "__proto__" && !isOptional(property.type)) { + statements.push( + "let " + value + "=i[" + key + "]", + "if(" + value + "===void 0&&!(" + present + "))" + value + "=M;else " + assignInput + ) + } else { + statements.push( + "let " + value, + "if(" + present + "){" + value + "=i[" + key + "];" + assignInput + "}else " + value + "=M" + ) + } + statements.push( + "r=P[" + index + "].parser(" + value + ",o)", + "if(r!==UE){if(!X(r))return RSC(T,P,i,out," + index + + ",r,o);if(r._tag===\"Failure\")return W(T,i,o," + key + + ",r);x=r[A];if(x===M){delete out[" + key + "];" + + (isOptional(property.type) ? "" : "return N(T,i,o,P[" + index + "])") + + "}else{" + assignDecoded + "}}" + ) + } + statements.push("return SU(out)") + return "\"use strict\";const {ast:T,properties:P,fallback:F}=context;" + runtimeBindings({ + D: "defaultParseOptions", + O: "hasDefaultObjectOptions", + M: "missing", + MX: "missingExit", + UE: "unchangedExit", + IT: "invalidTypeIssue", + AP: "assignDecodedProperty", + A: "args", + X: "effectIsExit", + RSC: "resumeComposedObject", + W: "failComposedObjectProperty", + N: "failMissingComposedObjectProperty", + SU: "succeed", + DIE: "die" + }) + "return function(i,o){try{" + + statements.join(";") + + "}catch(e){return DIE(e)}}" +} diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts new file mode 100644 index 00000000000..3eb6ef1c81b --- /dev/null +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -0,0 +1,220 @@ +import * as Effect from "../../Effect.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaCompiler from "../../unstable/schema/SchemaCompiler.ts" +import * as Interpreter from "./interpreter.ts" +import * as InternalParser from "./parser.ts" + +/** @internal */ +export const invalid = Symbol() + +/** @internal */ +export type Is = SchemaCompiler.Is + +/** @internal */ +export type Validate = SchemaCompiler.Validate + +/** @internal */ +export type Decode = SchemaCompiler.Decode + +/** @internal */ +export type CompiledDecoder = SchemaCompiler.CompiledDecoder + +/** @internal */ +export type Parser = Decode + +/** @internal */ +export interface ResolveParser { + (ast: SchemaAST.AST): Parser + readonly resolve: ResolveEntry +} + +/** @internal */ +export interface Compiler { + (ast: SchemaAST.AST, resolve: ResolveEntry): CompiledDecoder | undefined +} + +/** @internal */ +export interface ResolveEntry { + (ast: SchemaAST.AST): Entry +} + +/** @internal */ +export class Entry implements CompiledDecoder { + private readonly source: CompiledDecoder | undefined + private readonly ast: SchemaAST.AST + private readonly resolve: ResolveEntry + + constructor(ast: SchemaAST.AST, source: CompiledDecoder | undefined, resolve: ResolveEntry) { + this.ast = ast + this.resolve = resolve + this.source = source + } + + get origin(): "interpreted" | "installed" { + return this.source === undefined ? "interpreted" : "installed" + } + + get parseEffect(): Parser { + let parser: Parser | undefined + const parse: Parser = (input, options) => + InternalParser.materialize((parser ??= this.parser)(input, options), input) + Object.defineProperty(this, "parseEffect", { value: parse }) + return parse + } + + /** @internal */ + get parser(): Parser { + const parser = makeParser(this) + Object.defineProperty(this, "parser", { value: parser }) + return parser + } + + get is(): Is | undefined { + const is = this.source?.is + Object.defineProperty(this, "is", { value: is }) + return is + } + + get validate(): Validate | undefined { + const validate = this.source?.validate + Object.defineProperty(this, "validate", { value: validate }) + return validate + } + + get decodeEffect(): Decode { + const decode = this.source === undefined + ? Interpreter.compile(this.ast, makeResolveParser(this.resolve)) + : this.source.decodeEffect + Object.defineProperty(this, "decodeEffect", { value: decode }) + return decode + } + + get makeEffect(): Decode { + const make = this.source?.makeEffect ?? Interpreter.compileConstructor(this.ast, constructorResolver(this.resolve)) + Object.defineProperty(this, "makeEffect", { value: make }) + return make + } +} + +const makeParser = (entry: CompiledDecoder): Parser => { + const validate = entry.validate + if (validate === undefined) return entry.decodeEffect + return (input, options) => { + if (input !== InternalParser.missing) { + try { + const output = validate(input, options) + if (output !== invalid) { + return output === input ? InternalParser.unchangedExit : InternalParser.succeed(output) + } + } catch (error) { + return Effect.die(error) + } + } + return entry.decodeEffect(input, options) + } +} + +/** @internal */ +export const prepareDecode = (decoder: CompiledDecoder): Parser => { + let parser: Parser | undefined + let decode: Decode | undefined + const source: CompiledDecoder = { + get validate() { + return decoder.validate + }, + get decodeEffect() { + return decode ??= decoder.decodeEffect + } + } + return (input, options) => (parser ??= makeParser(source))(input, options) +} + +/** @internal */ +export const prepareIs = (entry: Entry, options: SchemaAST.ParseOptions): ((input: unknown) => boolean) | undefined => { + const is = entry.is + if (is !== undefined) return (input) => is(input, options) + const validate = entry.validate + if (validate !== undefined) return (input) => validate(input, options) !== invalid +} + +const cache = new WeakMap() + +/** @internal */ +export const set = (ast: SchemaAST.AST, decoder: CompiledDecoder, resolveChild: ResolveEntry = resolve): Entry => { + const entry = new Entry(ast, decoder, resolveChild) + cache.set(ast, entry) + return entry +} + +const setInterpreted = (ast: SchemaAST.AST, resolve: ResolveEntry): Entry => { + const entry = new Entry(ast, undefined, resolve) + cache.set(ast, entry) + return entry +} + +let installedCompiler: Compiler | undefined + +/** @internal */ +export const install = (compiler: Compiler): void => { + installedCompiler = compiler +} + +/** @internal */ +export const resolve = (ast: SchemaAST.AST): Entry => { + const cached = cache.get(ast) + if (cached !== undefined) return cached + const compiled = installedCompiler?.(ast, resolve) + return compiled !== undefined ? set(ast, compiled) : setInterpreted(ast, resolve) +} + +/** @internal */ +export const makeResolveParser = ( + resolveEntry: ResolveEntry, + getParser: (ast: SchemaAST.AST) => Parser = (ast) => resolveEntry(ast).parser +): ResolveParser => Object.assign(getParser, { resolve: resolveEntry }) + +/** @internal */ +export const resolveParser: ResolveParser = makeResolveParser(resolve) + +/** @internal */ +export const resolveConstructor: ResolveParser = Object.assign( + (ast: SchemaAST.AST) => resolve(ast).makeEffect, + { resolve } +) + +/** @internal */ +export const constructorResolver = (resolve: ResolveEntry): ResolveParser => + Object.assign( + (ast: SchemaAST.AST): Parser => { + // Declaration callbacks can use public decoders for these ASTs before invoking + // this constructor. Register the entry now, but leave its operations lazy. + const entry = resolve(ast) + return (input, options) => { + const parser = entry.makeEffect + return parser(input, options) + } + }, + { resolve } + ) + +/** @internal */ +export const makeScopedCompiler = (compiler: Compiler): (ast: SchemaAST.AST) => void => { + const resolveScoped: ResolveEntry = (ast) => { + const cached = cache.get(ast) + return cached?.origin === "installed" ? cached : compileAndSet(ast, cached) + } + + const compileAndSet = (ast: SchemaAST.AST, cached: Entry | undefined): Entry => { + // Operation factories resolve children only after this entry is installed. + const compiled = compiler(ast, resolveScoped) + return compiled !== undefined + ? set(ast, compiled, resolveScoped) + : cached?.origin === "installed" + ? cached + : setInterpreted(ast, resolveScoped) + } + + return (ast) => { + compileAndSet(ast, cache.get(ast)) + } +} diff --git a/packages/effect/src/internal/schema/constructors.ts b/packages/effect/src/internal/schema/constructors.ts new file mode 100644 index 00000000000..ce5fdedd8a0 --- /dev/null +++ b/packages/effect/src/internal/schema/constructors.ts @@ -0,0 +1,130 @@ +import { isArrayNonEmpty } from "../../Array.ts" +import * as Effect from "../../Effect.ts" +import * as Exit from "../../Exit.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import { effectIsExit } from "../effect.ts" +import { assignProperty } from "../record.ts" +import { makeArrayParser } from "./arrays.ts" +import { wrapPropertyKeyIssue } from "./cause.ts" +import { makeResolveParser, type Parser, type ResolveEntry } from "./compilerRegistry.ts" +import * as Diagnostics from "./diagnostics.ts" +import { withConstructorDefault } from "./interpreter.ts" +import { type ObjectParserState, type ParsedProperty, parseProperties } from "./objects.ts" +import * as InternalParser from "./parser.ts" +import { makeUnionParser } from "./unions.ts" + +/** @internal */ +export function node(ast: SchemaAST.AST, resolve: ResolveEntry): Parser { + let parser: Parser | undefined + return (input, options) => (parser ??= resolve(ast).makeEffect)(input, options) +} + +/** @internal */ +export function field(ast: SchemaAST.AST, resolve: ResolveEntry): Parser { + return withConstructorDefault(ast, node(ast, resolve), makeResolveParser(resolve, (ast) => node(ast, resolve))) +} + +/** @internal */ +export function properties(ast: SchemaAST.Objects, resolve: ResolveEntry): ReadonlyArray { + return ast.propertySignatures.map((property) => ({ + type: property.type, + name: property.name, + parser: field(property.type, resolve), + valueFirst: property.name !== "__proto__" && !property.type.context?.isOptional + })) +} + +/** @internal */ +export function objects( + ast: SchemaAST.Objects, + resolve: ResolveEntry, + fields = properties(ast, resolve) +): Parser { + const expected = new Set(Diagnostics.getExpectedKeys(ast)) + const indexes = ast.indexSignatures.map((signature) => ({ + signature, + key: node(SchemaAST.parameterFromPropertyKey(signature.parameter), resolve), + value: field(signature.type, resolve) + })) + return Effect.fnUntracedEager(function*(input, options) { + if (input === InternalParser.missing) return input + if (fields.length === 0 && indexes.length === 0) { + return input !== null && input !== undefined + ? input + : yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + const record = input as Record + const state: ObjectParserState = { ast, input: record, options, out: {}, issues: undefined } + const indexKeys = options.onExcessProperty === "error" + ? indexes.map(({ signature }) => SchemaAST.getIndexSignatureKeys(record, signature.parameter, options)) + : undefined + if (options.onExcessProperty === "error") { + const covered = Diagnostics.getCoveredKeys(expected, indexKeys) + for (const key of Reflect.ownKeys(record)) { + if (covered.has(key)) continue + const issue = Diagnostics.unexpectedKey(ast, key, record[key], options) + if (options.errors !== "all") return yield* Effect.fail(new SchemaIssue.Composite(ast, [issue], input, options)) + ;(state.issues ??= []).push(issue) + } + } + const pending = parseProperties(state, fields) + if (pending) yield* pending + for (let index = 0; index < indexes.length; index++) { + const member = indexes[index] + const parameter = member.signature.parameter + const keys = indexKeys?.[index] ?? (parameter === SchemaAST.string + ? Object.keys(record) + : SchemaAST.getIndexSignatureKeys(record, parameter, options)) + for (const key of keys) { + let outputKey: unknown = key + if (parameter !== SchemaAST.string) { + const effect = member.key(key, options) + const result = effectIsExit(effect) ? effect : yield* Effect.exit(effect) + if (Exit.isFailure(result)) { + const terminal = wrapPropertyKeyIssue(state, ast, key, result) + if (terminal) return yield* terminal + continue + } + outputKey = InternalParser.valueOrInput( + result as InternalParser.Success, + key + ) + } + const effect = member.value(record[key], options) + const result = effectIsExit(effect) ? effect : yield* Effect.exit(effect) + if (Exit.isFailure(result)) { + const terminal = wrapPropertyKeyIssue(state, ast, key, result) + if (terminal) return yield* terminal + continue + } + const outputValue = InternalParser.valueOrInput( + result as InternalParser.Success, + record[key] + ) + if (outputKey === InternalParser.missing || outputValue === InternalParser.missing) continue + const name = outputKey as PropertyKey + if (fields.length > 0 && (expected.has(key) || expected.has(Diagnostics.normalizeKey(name)))) continue + assignProperty(state.out, name, outputValue) + } + } + if (state.issues && isArrayNonEmpty(state.issues)) { + return yield* Effect.fail(new SchemaIssue.Composite(ast, state.issues, input, options)) + } + return state.out + }) +} + +/** @internal */ +export function arrays(ast: SchemaAST.Arrays, resolve: ResolveEntry): Parser { + return makeArrayParser(ast, makeResolveParser(resolve, (ast) => field(ast, resolve))) +} + +/** @internal */ +export function union(ast: SchemaAST.Union, resolve: ResolveEntry): Parser { + const members = new Map(ast.types.map((ast) => [ast, node(ast, resolve)])) + return makeUnionParser(ast, makeResolveParser(resolve, (ast) => members.get(ast)!), true) +} diff --git a/packages/effect/src/internal/schema/diagnostics.ts b/packages/effect/src/internal/schema/diagnostics.ts new file mode 100644 index 00000000000..df9d55d3836 --- /dev/null +++ b/packages/effect/src/internal/schema/diagnostics.ts @@ -0,0 +1,42 @@ +import type * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" + +/** @internal */ +export const normalizeKey = (key: PropertyKey): string | symbol => typeof key === "number" ? String(key) : key + +/** @internal */ +export const getExpectedKeys = (ast: SchemaAST.Objects): Array => + ast.propertySignatures.map((property) => normalizeKey(property.name)) + +/** @internal */ +export const getCoveredKeys = ( + expected: ReadonlySet, + indexKeys: ReadonlyArray> | undefined +): ReadonlySet => { + if (indexKeys === undefined) return expected + const covered = new Set(expected) + for (const keys of indexKeys) { + for (const key of keys) covered.add(key) + } + return covered +} + +/** @internal */ +export const missingKey = (key: PropertyKey, child: SchemaAST.AST): SchemaIssue.Pointer => + new SchemaIssue.Pointer([key], new SchemaIssue.MissingKey(child.context?.annotations)) + +/** @internal */ +export const unexpectedKey = ( + ast: SchemaAST.AST, + key: PropertyKey, + value: unknown, + options: SchemaAST.ParseOptions +): SchemaIssue.Pointer => new SchemaIssue.Pointer([key], new SchemaIssue.UnexpectedKey(ast, value, options)) + +/** @internal */ +export const getTupleElement = ( + elements: ReadonlyArray, + rest: ReadonlyArray, + tailThreshold: number, + index: number +): A => index < elements.length ? elements[index] : index >= tailThreshold ? rest[index - tailThreshold + 1] : rest[0] diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 7d2b5aee682..8aafac08c39 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -634,7 +634,7 @@ function translateJsonSchemaMultiDocument( right: SchemaRepresentation.Union, path: Path ): ImportedJsonSchemaRepresentation | undefined { - if (left.mode !== "anyOf" || right.mode !== "anyOf") return undefined + if (left.options?.mode === "oneOf" || right.options?.mode === "oneOf") return undefined const rightByValue = new Map() for (const type of right.types) { const representation = type as ImportedJsonSchemaRepresentation @@ -909,7 +909,7 @@ function translateJsonSchemaMultiDocument( representation, types.length === 1 ? types[0] - : { _tag: "Union", types, mode: "anyOf", checks: [] }, + : { _tag: "Union", types, checks: [] }, [...path, "enum"] ) } @@ -957,7 +957,7 @@ function translateJsonSchemaMultiDocument( const union: ImportedJsonSchemaRepresentation = { _tag: "Union", types: members.map((member, index) => recur(member, [...path, mode, index])), - mode, + ...(mode === "oneOf" ? { options: { mode } } : {}), checks: [] } representation = combine(union, representation, [...path, mode]) @@ -976,7 +976,6 @@ function translateJsonSchemaMultiDocument( return { _tag: "Union", types: types.map((type) => on({ ...schema, type }, path)), - mode: "anyOf", checks: [] } } diff --git a/packages/effect/src/internal/schema/fromRepresentation.ts b/packages/effect/src/internal/schema/fromRepresentation.ts index a9823fedf1a..da18ec7b752 100644 --- a/packages/effect/src/internal/schema/fromRepresentation.ts +++ b/packages/effect/src/internal/schema/fromRepresentation.ts @@ -316,7 +316,7 @@ function revivePersisted( } case "Union": { const members = representation.types.map((member, index) => recur(member, [...path, "types", index])) - return finishStructural(Schema.Union(members, { mode: representation.mode }), representation, path) + return finishStructural(Schema.Union(members, representation.options), representation, path) } } } diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts new file mode 100644 index 00000000000..8e53f4fe6d0 --- /dev/null +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -0,0 +1,84 @@ +import * as Effect from "../../Effect.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaIssue from "../../SchemaIssue.ts" +import { effectIsExit } from "../effect.ts" +import { checkOutput, getEncodingChecks } from "./checks.ts" +import type { Parser, ResolveParser } from "./compilerRegistry.ts" +import * as InternalParser from "./parser.ts" +import { applyTransformation, makeEncoding } from "./transformation.ts" + +/** @internal */ +export function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, resolve: ResolveParser): Parser { + let sourceParser: Parser + return (input, options) => { + if (input === InternalParser.missing) return InternalParser.missingExit + if (descriptor.isConstructed(input)) return InternalParser.unchangedExit + const result = (sourceParser ??= resolve(descriptor.link.to))(input, options) + return Effect.flatMapEager( + applyTransformation(result, input, descriptor.link.transformation, options), + InternalParser.fromOptionExit + ) + } +} + +/** @internal */ +export function withConstructorDefault(ast: SchemaAST.AST, parser: Parser, resolve: ResolveParser): Parser { + const link = ast.context?.constructorDefault + if (link === undefined) return parser + let source: Parser | undefined + return makeEncoding(ast, [link], [(input, options) => (source ??= resolve(link.to))(input, options)], parser) +} + +/** @internal */ +export function compileConstructor(ast: SchemaAST.AST, resolve: ResolveParser): Parser { + const resolveConstructorDefault: ResolveParser = Object.assign( + (ast: SchemaAST.AST) => withConstructorDefault(ast, resolve(ast), resolve), + { resolve: resolve.resolve } + ) + return compile( + ast, + resolve, + resolveConstructorDefault + ) +} + +/** @internal */ +export function compile( + ast: SchemaAST.AST, + resolve: ResolveParser, + resolveConstructorDefault?: ResolveParser +): Parser { + const descriptor = resolveConstructorDefault ? SchemaAST.getConstructorDescriptor(ast) : undefined + const parser = descriptor + ? makeConstructorParser(descriptor, resolve) + : ast.getParser(resolve, resolveConstructorDefault) + const links = ast.encoding + const parseLocal = applyChecks(ast, parser) + if (!links) return parseLocal + let encodingParser: Parser | undefined + return (input, options) => + (encodingParser ??= makeEncoding(ast, links, links.map((link) => resolve(link.to)), parseLocal))(input, options) +} + +/** @internal */ +export function applyChecks(ast: SchemaAST.AST, parser: Parser): Parser { + const checks = ast.checks + const encodingChecks = getEncodingChecks(ast) + if (!checks && !encodingChecks) return parser + return (input, options) => { + const result = parser(input, options) + if (effectIsExit(result)) { + if (result._tag === "Failure") return result + const output = InternalParser.valueOrInput( + result as InternalParser.Success, + input + ) + const issue = checkOutput(ast, input, output, options) + return issue === undefined ? result : Effect.fail(issue) + } + return Effect.flatMap(result, (output) => { + const issue = checkOutput(ast, input, output, options) + return issue === undefined ? Effect.succeed(output) : Effect.fail(issue) + }) + } +} diff --git a/packages/effect/src/internal/schema/jitCompiler.ts b/packages/effect/src/internal/schema/jitCompiler.ts new file mode 100644 index 00000000000..4f09361f239 --- /dev/null +++ b/packages/effect/src/internal/schema/jitCompiler.ts @@ -0,0 +1,162 @@ +import type * as SchemaAST from "../../SchemaAST.ts" +import { runtime as Runtime } from "../../unstable/schema/SchemaCompiler/runtime.ts" +import * as Codegen from "./codegen.ts" +import { + type CompiledDecoder, + constructorResolver, + type Is, + makeResolveParser, + type Parser, + prepareDecode, + type ResolveEntry, + type ResolveParser, + type Validate +} from "./compilerRegistry.ts" +import { compile as compileInterpreted } from "./interpreter.ts" + +let functionConstructor: FunctionConstructor | undefined +let functionConstructorSupported = false + +const supportsDynamicFunction = (): boolean => { + if (functionConstructor === globalThis.Function) return functionConstructorSupported + functionConstructor = globalThis.Function + try { + functionConstructor("return true") + return functionConstructorSupported = true + } catch { + return functionConstructorSupported = false + } +} + +const withCompilationFallback = ( + decoder: CompiledDecoder, + makeFallback: () => Parser +): CompiledDecoder => { + let failed = false + const getOperation = (key: K): CompiledDecoder[K] => { + if (!failed) { + try { + return decoder[key] + } catch { + // Only initialize operations here; never run a parser inside this catch. + failed = true + decoder = Runtime.fromDecode(makeFallback) + } + } + return decoder[key] + } + return { + get is() { + return getOperation("is") + }, + get validate() { + return getOperation("validate") + }, + get decodeEffect() { + return getOperation("decodeEffect") + } + } +} + +const makeOperation = (emitted: Codegen.GeneratedOperation): A => { + const factory = globalThis.Function("C", "R", emitted.source) + return factory(emitted.bindings.map((binding) => binding.value), Runtime) as A +} + +const makeIs = (ast: SchemaAST.AST): Is => makeOperation(Codegen.emitIs(ast)) + +const makeValidate = (ast: SchemaAST.AST): Validate => makeOperation(Codegen.emitValidate(ast)) + +const makeTypeDecoder = (ast: SchemaAST.AST, emitIs: boolean): CompiledDecoder => + Runtime.makeTypeDecoder(ast, () => makeValidate(ast), emitIs ? () => makeIs(ast) : undefined) + +const makeComposedObjectDecode = (ast: SchemaAST.Objects, resolve: ResolveParser): Parser | undefined => { + if (ast.propertySignatures.length > Codegen.maxGeneratedNodes) return undefined + const context = Runtime.makeComposedObjectContext(ast, resolve) + const factory = globalThis.Function("context", "R", Codegen.emitComposedObject(ast)) + return factory(context, Runtime) +} + +const makeLocalParser = (ast: SchemaAST.AST, resolve: ResolveParser): Parser => { + const selection = Codegen.select(ast, true) + if (selection._tag === "Type") { + return prepareDecode(withCompilationFallback( + makeTypeDecoder(ast, selection.outputFree), + // This checkpoint has already run the outer encoding. Do not run it again. + () => Runtime.makeLocalParser(ast, resolve) + )) + } + return Runtime.applyChecks( + ast, + selection._tag === "Object" + ? makeComposedObjectDecode(selection.ast, resolve) ?? ast.getParser(resolve) + : ast.getParser(resolve) + ) +} + +/** @internal */ +const compileDecoder = (ast: SchemaAST.AST, resolve: ResolveParser): CompiledDecoder | undefined => { + try { + const selection = Codegen.select(ast) + if (selection._tag === "Fallback" || !supportsDynamicFunction()) return undefined + let decoder: CompiledDecoder + switch (selection._tag) { + case "Type": + decoder = makeTypeDecoder(ast, selection.outputFree) + break + case "Encoding": + decoder = Runtime.makeEncodingDecoder(ast, resolve, () => makeLocalParser(ast, resolve)) + break + case "Object": + decoder = Runtime.fromDecode(() => + Runtime.applyChecks( + ast, + makeComposedObjectDecode(selection.ast, resolve) ?? + Runtime.makeComposedObjectFallback(selection.ast, resolve) + ) + ) + break + } + return withCompilationFallback(decoder, () => compileInterpreted(ast, resolve)) + } catch { + // The registry caches the interpreter when compilation fails before installation. + return undefined + } +} + +/** @internal */ +export const compile = (ast: SchemaAST.AST, resolve: ResolveEntry): CompiledDecoder | undefined => { + const decoder = compileDecoder(ast, makeResolveParser(resolve)) + let selection: ReturnType + try { + selection = Codegen.selectConstructor(ast) + if (selection === undefined || !supportsDynamicFunction()) return decoder + } catch { + return decoder + } + return Runtime.withConstructor(decoder ?? Runtime.interpretedDecoder(ast, resolve), (): Parser => { + try { + switch (selection) { + case "Class": + return Runtime.makeClassConstructor(ast, resolve) + case "Leaf": + return Runtime.makeLeafConstructor(ast) + case "Objects": + return Runtime.applyChecks(ast, Runtime.makeObjectConstructor(ast as SchemaAST.Objects, resolve)) + case "Arrays": + return Runtime.applyChecks(ast, Runtime.makeArrayConstructor(ast as SchemaAST.Arrays, resolve)) + case "Union": + return Runtime.applyChecks(ast, Runtime.makeUnionConstructor(ast as SchemaAST.Union, resolve)) + case "Object": { + const object = ast as SchemaAST.Objects + const context = Runtime.makeConstructionContext(object, resolve) + const factory = globalThis.Function("context", "R", Codegen.emitComposedObject(object)) + return Runtime.applyChecks(ast, factory(context, Runtime)) + } + } + } catch { + // Preparing this operation has not executed any constructor or default. + return Runtime.compileConstructor(ast, constructorResolver(resolve)) + } + }) +} diff --git a/packages/effect/src/internal/schema/objects.ts b/packages/effect/src/internal/schema/objects.ts new file mode 100644 index 00000000000..97386070d91 --- /dev/null +++ b/packages/effect/src/internal/schema/objects.ts @@ -0,0 +1,92 @@ +import * as Effect from "../../Effect.ts" +import * as Exit from "../../Exit.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import { iterateEager } from "../effect.ts" +import { assignProperty } from "../record.ts" +import { wrapPropertyKeyIssue } from "./cause.ts" +import type { Parser } from "./compilerRegistry.ts" +import { missingKey } from "./diagnostics.ts" +import * as InternalParser from "./parser.ts" + +/** @internal */ +export type ObjectParserState = { + readonly ast: SchemaAST.Objects + readonly input: Record + readonly options: SchemaAST.ParseOptions + readonly out: Record + issues: Array | undefined +} + +/** @internal */ +export type ParsedProperty = { + parser: Parser + readonly name: PropertyKey + readonly type: SchemaAST.AST + readonly valueFirst: boolean +} + +/** @internal */ +export const hasDefaultObjectOptions = (options: SchemaAST.ParseOptions): boolean => + options.errors !== "all" && options.onExcessProperty !== "error" + +/** @internal */ +export function stepProperty( + state: ObjectParserState, + property: ParsedProperty, + exit: Exit.Exit +): Exit.Exit | void { + if (exit._tag === "Failure") return wrapPropertyKeyIssue(state, state.ast, property.name, exit) + if (exit === InternalParser.unchangedExit) return + const value = (exit as InternalParser.Success)[InternalParser.args] + if (value !== InternalParser.missing) { + assignProperty(state.out, property.name, value) + return + } + delete state.out[property.name] + if (property.type.context?.isOptional) return + const issue = missingKey(property.name, property.type) + if (state.options.errors === "all") { + if (state.issues) state.issues.push(issue) + else state.issues = [issue] + } else { + return Exit.fail(new SchemaIssue.Composite(state.ast, [issue], state.input, state.options)) + } +} + +/** @internal */ +export const parseProperties = iterateEager()({ + onItem(state, property) { + const name = property.name + let value: unknown + if (property.valueFirst) { + value = state.input[name] + if (value === undefined && !(name in state.input)) { + return property.parser(InternalParser.missing, state.options) + } + } else { + const present = name === "__proto__" ? Object.hasOwn(state.input, name) : name in state.input + if (!present) return property.parser(InternalParser.missing, state.options) + value = state.input[name] + } + assignProperty(state.out, name, value) + return property.parser(value, state.options) + }, + step: stepProperty +}) + +// Continue after a suspended property without re-running earlier properties. +/** @internal */ +export const resumeProperties = ( + state: ObjectParserState, + properties: ReadonlyArray, + index: number, + pending: Effect.Effect +): Effect.Effect => + Effect.flatMap(Effect.exit(pending), (exit) => { + const terminal = stepProperty(state, properties[index], exit) + if (terminal) return terminal + const done = () => InternalParser.succeed(state.out) + const rest = parseProperties(state, properties, index + 1) + return rest ? Effect.flatMapEager(rest, done) : done() + }) diff --git a/packages/effect/src/internal/schema/parser.ts b/packages/effect/src/internal/schema/parser.ts index feb9aa8fafa..376109cf98c 100644 --- a/packages/effect/src/internal/schema/parser.ts +++ b/packages/effect/src/internal/schema/parser.ts @@ -1,3 +1,4 @@ +import type * as Effect from "../../Effect.ts" import * as Exit from "../../Exit.ts" import * as Option from "../../Option.ts" import { args } from "../core.ts" @@ -14,13 +15,29 @@ export type Success = Exit.Success & { readonly [args]: A } /** @internal */ export const succeed = Exit.succeed as (value: A) => Success -/** @internal */ +/** The input/output is absent. This is distinct from a present `undefined`. @internal */ export const missingExit = succeed(missing) -// Shared success for a present input returned unchanged. It must be resolved -// before crossing an asynchronous or transformation boundary. +/** + * A successful parser result whose present input is returned unchanged. + * + * This is distinct from {@link missingExit}: the payload is never interpreted + * as an absent value. Consumers must resolve it with {@link valueOrInput} or + * {@link materialize} before crossing a generic Effect boundary. + * + * @internal + */ +export const unchangedExit = succeed(Symbol()) + +/** @internal */ +export const valueOrInput = (exit: Success, input: unknown): unknown => + exit === unchangedExit ? input : exit[args] + /** @internal */ -export const sameExit: Success = succeed(missing) +export const materialize = ( + result: Effect.Effect, + input: unknown +): Effect.Effect => result === unchangedExit ? succeed(input) : result /** @internal */ export const toOption = (value: A): Option.Option => value === missing ? Option.none() : Option.some(value as A) diff --git a/packages/effect/src/internal/schema/toCodeDocument.ts b/packages/effect/src/internal/schema/toCodeDocument.ts index 0e6f1804f10..262b6a3a519 100644 --- a/packages/effect/src/internal/schema/toCodeDocument.ts +++ b/packages/effect/src/internal/schema/toCodeDocument.ts @@ -558,7 +558,7 @@ export function toCodeDocument( } case "Union": { if (representation.types.length === 0) return makeCode("Schema.Never", "never") - if (representation.types.every(isSimpleLiveLiteral)) { + if (representation.options?.mode !== "oneOf" && representation.types.every(isSimpleLiveLiteral)) { const literals = representation.types.map((literal) => format(literal.literal)) return literals.length === 1 ? makeCode(`Schema.Literal(${literals[0]})`, literals[0]) @@ -567,7 +567,7 @@ export function toCodeDocument( const types = representation.types.map((type, index) => recur(type, [...path, "types", index], includeTypeBrands) ) - const mode = representation.mode === "anyOf" ? "" : `, { mode: "oneOf" }` + const mode = representation.options?.mode !== "oneOf" ? "" : `, { mode: "oneOf" }` return makeCode( `Schema.Union([${types.map((type) => type.runtime).join(", ")}]${mode})`, types.map((type) => type.Type).join(" | ") diff --git a/packages/effect/src/internal/schema/toCodec.ts b/packages/effect/src/internal/schema/toCodec.ts index 48d515c97a5..d8ad70194a3 100644 --- a/packages/effect/src/internal/schema/toCodec.ts +++ b/packages/effect/src/internal/schema/toCodec.ts @@ -102,7 +102,7 @@ function toCodecJsonASTStep(ast: SchemaAST.AST, recur: (ast: SchemaAST.AST) => S if (sortedTypes !== ast.types) { return new SchemaAST.Union( sortedTypes, - ast.mode, + ast.options, ast.annotations, ast.checks, ast.encoding, @@ -226,7 +226,7 @@ function toCodecStringTreeASTStep( if (sortedTypes !== ast.types) { return new SchemaAST.Union( sortedTypes, - ast.mode, + ast.options, ast.annotations, ast.checks, ast.encoding, @@ -253,7 +253,7 @@ const nullToString = new SchemaAST.Link( ) const booleanToString = new SchemaAST.Link( - new SchemaAST.Union([new SchemaAST.Literal("true"), new SchemaAST.Literal("false")], "anyOf"), + new SchemaAST.Union([new SchemaAST.Literal("true"), new SchemaAST.Literal("false")]), new InternalTransformation.Transformation( SchemaGetter.transform((s) => s === "true"), SchemaGetter.String() @@ -294,7 +294,7 @@ const toCodecArrayFromSingleAST = SchemaAST.applyToSelfOrLastLinkEncodingIdempot ), SchemaAST.string ], - "anyOf" + undefined ), out, arrayFromSingleTransformation diff --git a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts index 99223c9992f..0f586338f59 100644 --- a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts @@ -487,11 +487,11 @@ function compileJsonSchema( case "Union": { const types = representation.types.map((type, index) => recur(type, [...path, "types", index])) if (types.length === 0) return { not: {} } - if (representation.mode === "anyOf" && types.length > 1) { + if (representation.options?.mode !== "oneOf" && types.length > 1) { const compacted = compactEnums(types) if (compacted !== undefined) return compacted } - return representation.mode === "anyOf" ? { anyOf: types } : { oneOf: types } + return representation.options?.mode !== "oneOf" ? { anyOf: types } : { oneOf: types } } } } diff --git a/packages/effect/src/internal/schema/toRepresentation.ts b/packages/effect/src/internal/schema/toRepresentation.ts index 89474d36bfa..f44431b4d5b 100644 --- a/packages/effect/src/internal/schema/toRepresentation.ts +++ b/packages/effect/src/internal/schema/toRepresentation.ts @@ -283,7 +283,7 @@ export function toRepresentations( return { _tag: "Union", types: ast.types.map((ast) => recur(ast)), - mode: ast.mode, + ...(ast.options === undefined ? {} : { options: ast.options }), checks, ...annotationsField(ast.annotations) } diff --git a/packages/effect/src/internal/schema/transformation.ts b/packages/effect/src/internal/schema/transformation.ts new file mode 100644 index 00000000000..f112f8f4745 --- /dev/null +++ b/packages/effect/src/internal/schema/transformation.ts @@ -0,0 +1,79 @@ +import * as Cause from "../../Cause.ts" +import * as Effect from "../../Effect.ts" +import type * as Option from "../../Option.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import { effectIsExit } from "../effect.ts" +import type { Parser } from "./compilerRegistry.ts" +import * as InternalParser from "./parser.ts" + +/** @internal */ +export function applyTransformation( + result: Effect.Effect, + input: unknown, + transformation: SchemaAST.Link["transformation"], + options: SchemaAST.ParseOptions +): Effect.Effect, SchemaIssue.Issue, unknown> { + if (effectIsExit(result) && result._tag === "Success") { + const optional = InternalParser.toOption( + InternalParser.valueOrInput( + result as InternalParser.Success, + input + ) + ) + return transformation._tag === "Transformation" + ? transformation.decode.run(optional, options) + : transformation.decode(InternalParser.succeed(optional), options) + } else if (transformation._tag === "Transformation") { + return Effect.flatMapEager( + result, + (value) => transformation.decode.run(InternalParser.toOption(value), options) + ) + } else { + return transformation.decode( + Effect.mapEager(result, InternalParser.toOption), + options + ) + } +} + +const fromOption = (option: Option.Option): unknown => + option._tag === "None" ? InternalParser.missing : option.value + +/** @internal */ +export const makeEncoding = ( + ast: SchemaAST.AST, + links: SchemaAST.Encoding, + parsers: ReadonlyArray, + local: Parser +): Parser => +(input, options) => { + let current = input + let result = parsers[parsers.length - 1](input, options) + for (let index = links.length - 1; index >= 0; index--) { + let transformed = applyTransformation(result, current, links[index].transformation, options) + const next = index === 0 ? local : parsers[index - 1] + if (effectIsExit(transformed) && transformed._tag === "Success") { + const optional = (transformed as InternalParser.Success, SchemaIssue.Issue>)[ + InternalParser.args + ] + current = fromOption(optional) + result = next(current, options) + } else { + if (index === 0) { + transformed = Effect.catchCause( + transformed, + (cause) => + Effect.failCauseSync(() => + Cause.map(cause, (issue) => new SchemaIssue.Encoding(ast, issue, input, options)) + ) + ) + } + result = Effect.flatMapEager(transformed, (optional) => { + const value = fromOption(optional) + return InternalParser.materialize(next(value, options), value) + }) + } + } + return InternalParser.materialize(result, current) +} diff --git a/packages/effect/src/internal/schema/unions.ts b/packages/effect/src/internal/schema/unions.ts new file mode 100644 index 00000000000..dde406f1f5e --- /dev/null +++ b/packages/effect/src/internal/schema/unions.ts @@ -0,0 +1,108 @@ +import type * as Arr from "../../Array.ts" +import type * as Cause from "../../Cause.ts" +import * as Effect from "../../Effect.ts" +import * as Exit from "../../Exit.ts" +import type { AST, ParseOptions, Union } from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import type * as SchemaParser from "../../SchemaParser.ts" +import { effectIsExit, iterateEager } from "../effect.ts" +import * as InternalSchemaCause from "./cause.ts" +import * as InternalParser from "./parser.ts" + +/** @internal */ +export function makeUnionParser( + ast: Union, + compile: SchemaParser.Compiler, + isConstructor: boolean +): SchemaParser.Parser { + const parse: SchemaParser.Parser = (input, options) => { + if (input === InternalParser.missing) { + return InternalParser.missingExit + } + const candidates = ast.getCandidates(input, isConstructor) + + if (candidates.length === 0) { + return Effect.fail(new SchemaIssue.AnyOf(ast, [], input, options)) + } + if (candidates.length === 1) { + const result = compile(candidates[0])(input, options) + if ((result as Exit.Exit)._tag === "Success") return result + return effectIsExit(result) + ? failSingleUnionCandidate(ast, (result as Exit.Failure).cause, input, options) + : Effect.catchCause(result, (cause) => failSingleUnionCandidate(ast, cause, input, options)) + } + + const state = { + ast, + compile, + input, + out: undefined, + successes: ast.options?.mode === "oneOf" ? [] : undefined, + issues: undefined as Arr.NonEmptyArray | undefined, + options + } + const eff = parseUnion(state, candidates) + if (!eff) { + if (state.out) return state.out + return Effect.fail(new SchemaIssue.AnyOf(ast, state.issues ?? [], input, options)) + } + return Effect.flatMapEager(eff, (_) => { + if (state.out === InternalParser.unchangedExit) return InternalParser.succeed(input) + if (state.out) return state.out + return Effect.fail(new SchemaIssue.AnyOf(ast, state.issues ?? [], input, options)) + }) + } + return (input, options) => { + try { + return parse(input, options) + } catch (error) { + return Effect.die(error) + } + } +} +function failSingleUnionCandidate( + ast: Union, + cause: Cause.Cause, + input: unknown, + options: ParseOptions +) { + const issue = InternalSchemaCause.getSchemaIssue(cause) + if (!issue) return Exit.failCause(cause) + return Exit.fail(new SchemaIssue.AnyOf(ast, [issue], input, options)) +} + +const parseUnion = iterateEager<{ + readonly compile: (ast: AST) => SchemaParser.Parser + readonly ast: Union + readonly input: unknown + readonly options: ParseOptions + out: Exit.Success | undefined + readonly successes: Array | undefined + issues: Array | undefined +}, AST>()({ + onItem(s, ast) { + const parser = s.compile(ast) + return parser(s.input, s.options) + }, + step(s, candidate, exit) { + if (exit._tag === "Failure") { + const issue = InternalSchemaCause.getSchemaIssue(exit.cause) + if (issue === undefined) { + return exit + } + if (s.issues) s.issues.push(issue) + else s.issues = [issue] + } else { + if (s.out && s.successes) { + s.successes.push(candidate) + return Exit.fail(new SchemaIssue.OneOf(s.ast, s.successes, s.input, s.options)) + } + s.out = exit + if (s.successes) { + s.successes.push(candidate) + } else { + return Exit.void + } + } + } +}) diff --git a/packages/effect/src/unstable/ai/internal/structured-output.ts b/packages/effect/src/unstable/ai/internal/structured-output.ts index 6a3cf8db90d..3d5550e8ead 100644 --- a/packages/effect/src/unstable/ai/internal/structured-output.ts +++ b/packages/effect/src/unstable/ai/internal/structured-output.ts @@ -64,11 +64,11 @@ function transform(root: SchemaAST.AST): SchemaAST.AST { case "Union": { const types = SchemaAST.mapOrSame(ast.types, recur) const checks = prepareChecks(ast.checks) - const mode = ast.mode === "oneOf" ? "anyOf" : ast.mode - if (types === ast.types && checks === ast.checks && mode === ast.mode) return ast + const options = ast.options?.mode === "oneOf" ? undefined : ast.options + if (types === ast.types && checks === ast.checks && options === ast.options) return ast return new SchemaAST.Union( types, - mode, + options, ast.annotations, checks, ast.encoding, @@ -254,7 +254,7 @@ function objectToEntries( function unionOrSingle(types: ReadonlyArray): SchemaAST.AST { if (types.length === 1) return types[0] const unique = Array.from(new Set(types)) - return unique.length === 1 ? unique[0] : new SchemaAST.Union(unique, "anyOf") + return unique.length === 1 ? unique[0] : new SchemaAST.Union(unique) } function combineChecks( @@ -300,7 +300,7 @@ function compilerAnnotations( function optionalToNullable(type: SchemaAST.AST): SchemaAST.AST { return SchemaAST.decodeTo( - new SchemaAST.Union([type, SchemaAST.null], "anyOf"), + new SchemaAST.Union([type, SchemaAST.null]), SchemaAST.optionalKey(type), SchemaTransformation.transformOptional({ decode: Option.filter(Predicate.isNotNull), diff --git a/packages/effect/src/unstable/encoding/SchemaBinary.ts b/packages/effect/src/unstable/encoding/SchemaBinary.ts index 7fe3959cd38..1e9b8095f86 100644 --- a/packages/effect/src/unstable/encoding/SchemaBinary.ts +++ b/packages/effect/src/unstable/encoding/SchemaBinary.ts @@ -23,7 +23,6 @@ import { dual, memoize } from "../../Function.ts" import * as HashMap from "../../HashMap.ts" import * as HashSet from "../../HashSet.ts" import { assignProperty } from "../../internal/record.ts" -import * as InternalParserProtocol from "../../internal/schema/parser.ts" import * as Option from "../../Option.ts" import * as Predicate from "../../Predicate.ts" import * as Pull from "../../Pull.ts" @@ -2112,8 +2111,7 @@ function resolveSuspend(ast: SchemaAST.AST): SchemaAST.AST { function enumsToLiterals(ast: SchemaAST.Enum): SchemaAST.Union { return new SchemaAST.Union( - ast.enums.map((e) => new SchemaAST.Literal(e[1], { title: e[0] })), - "anyOf" + ast.enums.map((e) => new SchemaAST.Literal(e[1], { title: e[0] })) ) } @@ -2625,8 +2623,7 @@ function isExact(root: SchemaAST.AST): boolean { const exact = (ast: SchemaAST.AST): boolean => { if ( ast.encoding !== undefined || ast.checks !== undefined || - (ast as { readonly encodingChecks?: SchemaAST.Checks }).encodingChecks !== undefined || - ast.annotations?.parseOptions !== undefined + (ast as { readonly encodingChecks?: SchemaAST.Checks }).encodingChecks !== undefined ) { return false } @@ -2660,7 +2657,7 @@ function isExact(root: SchemaAST.AST): boolean { signature.parameter._tag === "String" && exact(signature.parameter) && exact(signature.type) ) case "Union": - return ast.mode === "anyOf" && ast.types.every(exact) + return ast.options?.mode !== "oneOf" && ast.types.every(exact) // The layout compiles straight through a suspend, so the binary layer // validates whatever the thunk returns. Only decoding gets to act on // this: encoding a recursive schema still needs the cycle walk, which is @@ -2693,7 +2690,6 @@ function isExitWithExactSuccess(root: SchemaAST.AST): boolean { return root._tag === "Declaration" && root.encoding === undefined && root.checks === undefined && (root as { readonly encodingChecks?: SchemaAST.Checks }).encodingChecks === undefined && - root.annotations?.parseOptions === undefined && representationId(root) === "effect/schema/Exit" && isExact(root.typeParameters[0]) } @@ -5155,13 +5151,11 @@ function bypassPass( ): Schema.Constraint { const type = Schema.make(SchemaAST.toType(target.ast)) const parse = SchemaParser.decodeUnknownEffect(type as Schema.ConstraintDecoder) - // A bypassed input comes back unchanged, which the shared "same value" exit - // says without allocating one per call. const run = ( accept: (input: unknown, options: SchemaAST.ParseOptions) => boolean ): SchemaAST.Declaration["run"] => () => - (input, _ast, options) => accept(input, options) ? InternalParserProtocol.sameExit : parse(input, options) + (input, _ast, options) => accept(input, options) ? Effect.succeed(input) : parse(input, options) return Schema.make( new SchemaAST.Declaration( [type.ast], diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index be8f2186333..a29d821cd6c 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -424,7 +424,7 @@ function makeOpenApi( content.forEach((map, encoding) => { map.forEach((schemas, contentType) => { const asts = Array.from(schemas, SchemaAST.getAST) - const ast = asts.length === 1 ? asts[0] : new SchemaAST.Union(asts, "anyOf") + const ast = asts.length === 1 ? asts[0] : new SchemaAST.Union(asts) pathOps.push({ _tag: "schema", @@ -585,7 +585,7 @@ function makeOpenApi( const content: OpenApiSpecContent = {} for (const [contentType, { encoding, schemas }] of schemasByContentType) { const asts = schemas.map(SchemaAST.getAST) - const ast = asts.length === 1 ? asts[0] : new SchemaAST.Union(asts, "anyOf") + const ast = asts.length === 1 ? asts[0] : new SchemaAST.Union(asts) pathOps.push({ _tag: "schema", ast: toEncodingAST(ast, encoding._tag), diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts new file mode 100644 index 00000000000..aa701521915 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -0,0 +1,205 @@ +/** + * Generates static JavaScript modules that install Schema decoders in the shared + * registry. Generated modules use the same runtime support as the JIT compiler, + * without importing source generation or constructing functions dynamically. + * + * @since 4.0.0 + */ +import * as Codegen from "../../internal/schema/codegen.ts" +import * as SchemaAST from "../../SchemaAST.ts" + +const helper = Codegen.runtimeReference + +const renderOperation = (emitted: Codegen.GeneratedOperation): string => { + return `(function(C,R){${emitted.source}})([${emitted.bindings.map((binding) => binding.reference).join(",")}],R)` +} + +const validate = (ast: SchemaAST.AST): string => renderOperation(Codegen.emitValidate(ast)) + +const is = (ast: SchemaAST.AST): string => renderOperation(Codegen.emitIs(ast)) + +const typeDecoder = (ast: SchemaAST.AST, emitIs: boolean): string => + `${helper("makeTypeDecoder")}(ast,()=>${validate(ast)}${emitIs ? `,()=>${is(ast)}` : ""})` + +const composedObject = (ast: SchemaAST.Objects): string => + `(function(context,R){${Codegen.emitComposedObject(ast)}})(${helper("makeComposedObjectContext")}(ast,${ + helper("resolve") + }),R)` + +const localParser = (ast: SchemaAST.AST): string => { + const selection = Codegen.select(ast, true) + if (selection._tag === "Type") return `${helper("prepareDecode")}(${typeDecoder(ast, selection.outputFree)})` + if (selection._tag === "Object" && selection.ast.propertySignatures.length <= Codegen.maxGeneratedNodes) { + return `${helper("applyChecks")}(ast,${composedObject(selection.ast)})` + } + return `${helper("makeLocalParser")}(ast,${helper("resolve")})` +} + +const decodeSource = (ast: SchemaAST.AST): string | undefined => { + const selection = Codegen.select(ast) + switch (selection._tag) { + case "Fallback": + return undefined + case "Type": + return typeDecoder(ast, selection.outputFree) + case "Encoding": + return `${helper("makeEncodingDecoder")}(ast,${helper("resolve")},()=>${localParser(ast)})` + case "Object": + return `${helper("fromDecode")}(()=>${helper("applyChecks")}(ast,${ + selection.ast.propertySignatures.length <= Codegen.maxGeneratedNodes + ? composedObject(selection.ast) + : `${helper("makeComposedObjectFallback")}(ast,${helper("resolve")})` + }))` + } +} + +const decoder = (ast: SchemaAST.AST): string | undefined => { + const decode = decodeSource(ast) + const selection = Codegen.selectConstructor(ast) + let make: string + switch (selection) { + case "Class": + make = `${helper("makeClassConstructor")}(ast,${helper("resolveEntry")})` + break + case "Leaf": + make = `${helper("makeLeafConstructor")}(ast)` + break + case "Objects": + case "Arrays": + case "Union": { + const name = selection === "Objects" + ? "makeObjectConstructor" + : selection === "Arrays" + ? "makeArrayConstructor" + : "makeUnionConstructor" + make = `${helper("applyChecks")}(ast,${helper(name)}(ast,${helper("resolveEntry")}))` + break + } + case "Object": + make = `${helper("applyChecks")}(ast,(function(context,R){${ + Codegen.emitComposedObject(ast as SchemaAST.Objects) + }})(${helper("makeConstructionContext")}(ast,${helper("resolveEntry")}),R))` + break + case undefined: + return decode + } + return `${helper("withConstructor")}(${ + decode ?? `${helper("interpretedDecoder")}(ast,${helper("resolveEntry")})` + },()=>${make})` +} + +/** + * Generates a JavaScript ES module exporting `install(asts): void` for an + * ordered array of ASTs and their statically reachable parsing and construction dependencies. + * + * **When to use** + * + * Use to prepare decoders at build time for environments that disallow dynamic + * function construction. Save the returned source as a JavaScript module, then + * call its `install` export with the corresponding runtime ASTs in the same + * order before using parsers. Use a one-element array for a single schema. + * + * **Details** + * + * Generation does not install decoders or execute checks and transformations. + * Installation uses the same registry as `SchemaCompiler.set`; normal + * `SchemaParser` functions consume those entries. Generated validators and + * composed Struct decoders are static functions. Detailed diagnostic closures + * and transformation orchestration still initialize lazily in shared runtime + * support. Transformations and middleware are not replayed. + * Repeated ASTs and shared dependencies are installed once by identity. Fast + * paths can still inline dependency code into multiple parent decoders. + * An empty array generates a module whose installation does nothing. + * Construction uses independently lazy `makeEffect` operations. Struct loops + * are emitted as static functions; Array, Record, Union, leaf, and Class + * constructors are specialized lazily in the shared runtime. Constructor defaults + * and Class source schemas are read from the supplied ASTs, not serialized or + * executed during generation. Construction never runs a validation-and-replay pass. + * + * **Gotchas** + * + * Regenerate the module whenever the schema definition or Effect version + * changes. Installation trusts that the runtime array has the same length and + * root order, and its ASTs have the same definitions and sharing as at build + * time. Functions and symbols are read from those ASTs, not serialized. + * Suspend thunks are not evaluated during generation; their contents and other + * unsupported nodes use the interpreter. + * Installation replaces generated entries, but parsers that already captured + * older entries keep them. Type-side and flipped ASTs are separate registry + * keys; generate and install them separately when needed. Importing the + * generated module alone does not install anything. + * In particular, include `SchemaAST.toType(schema.ast)` to prepare construction + * when it differs from the encoded root. Unsupported constructors use the + * interpreter while statically installed children remain available. + * + * @category compilation + * @since 4.0.0 + */ +export const compile = (asts: ReadonlyArray): string => { + const seen = new Map() + const bindings: Array = [] + const factories: Array = [] + const installations: Array = [] + + const visit = (node: SchemaAST.AST, reference: string): void => { + if (seen.has(node)) return + const index = seen.size + const name = `a${index}` + seen.set(node, name) + bindings.push(`const ${name}=${reference};`) + + switch (node._tag) { + case "Declaration": + node.typeParameters.forEach((child, index) => visit(child, `${name}.typeParameters[${index}]`)) + break + case "TemplateLiteral": + node.parts.forEach((child, index) => visit(child, `${name}.parts[${index}]`)) + break + case "Arrays": + node.elements.forEach((child, index) => visit(child, `${name}.elements[${index}]`)) + node.rest.forEach((child, index) => visit(child, `${name}.rest[${index}]`)) + break + case "Objects": + node.propertySignatures.forEach((property, index) => + visit(property.type, `${name}.propertySignatures[${index}].type`) + ) + node.indexSignatures.forEach((signature, index) => { + visit( + SchemaAST.parameterFromPropertyKey(signature.parameter), + `${helper("parameterFromPropertyKey")}(${name}.indexSignatures[${index}].parameter)` + ) + visit(signature.type, `${name}.indexSignatures[${index}].type`) + }) + break + case "Union": + node.types.forEach((child, index) => visit(child, `${name}.types[${index}]`)) + break + } + node.encoding?.forEach((link, index) => visit(link.to, `${name}.encoding[${index}].to`)) + if (node.context?.constructorDefault !== undefined) { + visit(node.context.constructorDefault.to, `${name}.context.constructorDefault.to`) + } + const descriptor = SchemaAST.getConstructorDescriptor(node) + if (descriptor !== undefined) { + visit(descriptor.link.to, `${helper("getConstructorDescriptor")}(${name}).link.to`) + } + + const source = decoder(node) + if (source !== undefined) { + factories.push(`function d${index}(ast){return ${source}}`) + installations.push(`${helper("set")}(${name},d${index}(${name}));`) + } + } + asts.forEach((ast, index) => visit(ast, `asts[${index}]`)) + return [ + "// Generated by SchemaAOTCompiler. Regenerate after schema or Effect changes.", + "import { runtime as R } from \"effect/unstable/schema/SchemaCompiler/runtime\";", + ...factories, + "/** @param {ReadonlyArray} asts */", + "export function install(asts){", + ...bindings, + ...installations, + "}", + "" + ].join("\n") +} diff --git a/packages/effect/src/unstable/schema/SchemaCompiler.ts b/packages/effect/src/unstable/schema/SchemaCompiler.ts new file mode 100644 index 00000000000..fdb1ef908d1 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaCompiler.ts @@ -0,0 +1,169 @@ +/** + * Provides the shared registry used by Schema decoder implementations. A + * decoder installed with {@link set} is consumed transparently by the normal + * `SchemaParser` APIs, allowing runtime and ahead-of-time compilers to use the + * same cache without introducing a compiled Schema type or a second parser API. + * + * The cache associates each exact AST with an entry containing decoder + * operations, never parsing results. The interpreter uses the same registry + * with lazy `decodeEffect` and constructor fallback; JIT, AOT, and manual + * installations may supply `makeEffect` and the optional validation fast paths. + * + * @since 4.0.0 + */ +import type * as Effect from "../../Effect.ts" +import * as CompilerRegistry from "../../internal/schema/compilerRegistry.ts" +import * as InternalParser from "../../internal/schema/parser.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaIssue from "../../SchemaIssue.ts" + +/** + * The result returned by {@link Validate} when validation fails. + * + * @category symbols + * @since 4.0.0 + */ +export const invalid = CompilerRegistry.invalid + +/** + * The sentinel distinguishing an absent input from a present `undefined`. + * Decoders and constructors propagate it as a successful result when no value + * is produced. Parents omit optional fields or report missing required keys; + * public root adapters reject it rather than returning it to callers. + * + * @category symbols + * @since 4.0.0 + */ +export const missing = InternalParser.missing + +/** + * A compiled boolean validator. + * + * **Details** + * + * This optional fast path avoids constructing output. Omit it when validation + * requires reconstructed values, such as a Struct check that must see the + * object after excess properties are removed. Type guards then use `validate`, + * or `decodeEffect` if neither fast path is available. + * It must honor the supplied parse options; public `Schema.is` and + * `SchemaParser.is` use the defaults. + * + * @category models + * @since 4.0.0 + */ +export interface Is { + (input: unknown, options: SchemaAST.ParseOptions): boolean +} + +/** + * A compiled validator that returns the decoded value without constructing + * diagnostic issues. + * + * **Details** + * + * This optional synchronous fast path lets valid inputs return their output + * without the detailed decoding pass. For decoding, the registry follows + * {@link invalid} with `decodeEffect` because the sentinel provides no error details. + * Type guards instead convert it to `false`. Omit this operation + * when the fast path is unsupported or replay would be unsafe, including ASTs + * containing transformations or middleware. + * + * It must honor every supported `ParseOptions` value. Return {@link invalid} + * only for invalid input, never for an unsupported optimization. Do not call + * the detailed decoder and discard its failure: decoding would run `decodeEffect` + * again after `invalid`. User checks may themselves construct issues. + * + * @category models + * @since 4.0.0 + */ +export interface Validate { + (input: unknown, options: SchemaAST.ParseOptions): unknown | typeof invalid +} + +/** + * A compiled decoder that returns detailed Schema issues on failure. + * + * **Details** + * + * This required operation implements complete decoding for its AST, including + * transformations, middleware, and asynchronous work when present. It makes + * every parser API usable without optional fast paths and provides diagnostics + * after `validate` returns `invalid`. The implementation can also be interpreted; + * invoking `decodeEffect` does not imply a switch from compiled to interpreted parsing. + * + * @category models + * @since 4.0.0 + */ +export interface Decode { + (input: unknown, options: SchemaAST.ParseOptions): Effect.Effect +} + +/** + * The operations installed for an AST in the shared Schema parser registry. + * + * **Details** + * + * `decodeEffect` is required for complete decoding and detailed failures. `validate` + * and `is` are optional optimizations, not requirements for an AST to be usable. + * The interpreter supplies only `decodeEffect` in this same format. + * An optional `makeEffect` supplies complete node construction. Otherwise the + * registry prepares and caches the interpreted constructor, never the decoder, + * for that operation. Public makers resolve the schema's exact type-side AST. + * + * The registry wraps these operations in an internal entry. + * Decoding tries `validate` when present, returning its output on success or + * calling `decodeEffect` after `invalid`. Without `validate`, or for the {@link missing} + * sentinel, it calls `decodeEffect` directly. Type guards prefer `is`, then `validate`, + * then ordinary decoding. + * They need no diagnostic replay when a fast path returns `false` or `invalid`. + * Synchronous decoding and encoding share an adapter that returns successful + * `validate` output directly, without wrapping it in an intermediate Effect. + * Each operation is resolved lazily on first use, so unused fast paths need + * not be compiled. + * Construction calls `makeEffect` directly, without `is` or `validate`, so defaults + * and Class constructors are not replayed after a failure. Field/element defaults + * belong to the parent occurrence, not to the root node or a Union member. + * Runtime options apply to construction too; Union candidate selection preserves + * the constructor's conservative handling of absent discriminants. + * + * @category models + * @since 4.0.0 + */ +export interface CompiledDecoder { + readonly is?: Is | undefined + readonly validate?: Validate | undefined + readonly decodeEffect: Decode + /** + * Constructs this node without replay, including Class construction and child + * defaults. Omit it to use the lazy interpreted constructor. This operation + * initializes independently from decoding and never applies its own root default. + * Propagate `missing` as a success when no value is produced; the parent handles + * optional omission or missing-key issues. A present `undefined` is not `missing`. + */ + readonly makeEffect?: Decode | undefined +} + +/** + * Installs a compiled decoder for an exact AST in the shared Schema parser + * registry. + * + * **Details** + * + * A later call for the same AST replaces the previous entry. Parser functions + * that have already resolved and retained an earlier entry are not updated. + * This also applies when subsequent calls use different parse options. + * The decoder is trusted to implement the semantics of the supplied AST. + * Installation does not evaluate operation getters. Each operation, including + * an absent optional operation, is resolved once when first needed. Accessors + * retain the supplied decoder as their receiver. The supplied object is not + * mutated. JIT installation uses these same rules. + * Replacement includes construction: omitting `makeEffect` in the replacement + * selects interpreted construction for new consumers, without merging the old + * operation into the new entry. Already captured constructors keep their entry. + * + * @category registry + * @since 4.0.0 + */ +export const set = (ast: SchemaAST.AST, decoder: CompiledDecoder): void => { + CompilerRegistry.set(ast, decoder) +} diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts new file mode 100644 index 00000000000..fd7a8889eac --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -0,0 +1,629 @@ +/** + * Runtime support for generated Schema decoders. This version-coupled module + * contains no source emitter or dynamic Function construction. + * + * @since 4.0.0 + */ +import * as Effect from "../../../Effect.ts" +import * as Exit from "../../../Exit.ts" +import { effectIsExit } from "../../../internal/effect.ts" +import { assignProperty } from "../../../internal/record.ts" +import * as InternalSchemaCause from "../../../internal/schema/cause.ts" +import { checkOutput, getEncodingChecks } from "../../../internal/schema/checks.ts" +import { + type CompiledDecoder, + constructorResolver, + invalid, + type Is, + makeResolveParser, + type Parser, + prepareDecode, + resolve as resolveEntry, + type ResolveEntry, + type ResolveParser, + resolveParser as resolve, + set, + type Validate +} from "../../../internal/schema/compilerRegistry.ts" +import * as Constructors from "../../../internal/schema/constructors.ts" +import * as Diagnostics from "../../../internal/schema/diagnostics.ts" +import { + applyChecks, + compile as compileInterpreted, + compileConstructor, + makeConstructorParser +} from "../../../internal/schema/interpreter.ts" +import { hasDefaultObjectOptions, type ParsedProperty, resumeProperties } from "../../../internal/schema/objects.ts" +import * as InternalParser from "../../../internal/schema/parser.ts" +import { makeEncoding } from "../../../internal/schema/transformation.ts" +import * as SchemaAST from "../../../SchemaAST.ts" +import * as SchemaIssue from "../../../SchemaIssue.ts" + +/** @internal */ +const die = Effect.die +/** @internal */ +const defaultParseOptions = SchemaAST.defaultParseOptions +/** @internal */ +const getCandidates = SchemaAST.getCandidates +/** @internal */ +const getIndexSignatureKeys = SchemaAST.getIndexSignatureKeys +/** @internal */ +const parameterFromPropertyKey = SchemaAST.parameterFromPropertyKey + +const isOptional = (ast: SchemaAST.AST): boolean => ast.context?.isOptional ?? false + +/** @internal */ +const hasExcessProperties = ( + ast: SchemaAST.Objects, + input: Record, + options: SchemaAST.ParseOptions +): boolean => { + const covered = Diagnostics.getCoveredKeys( + new Set(Diagnostics.getExpectedKeys(ast)), + ast.indexSignatures.map((index) => SchemaAST.getIndexSignatureKeys(input, index.parameter, options)) + ) + return Reflect.ownKeys(input).some((key) => !covered.has(key)) +} + +/** @internal */ +const failsChecks = ( + ast: SchemaAST.AST, + value: unknown, + encoded: boolean, + options: SchemaAST.ParseOptions +): boolean => { + const checks = encoded ? getEncodingChecks(ast) : ast.checks + return !options.disableChecks && checks !== undefined && + SchemaAST.collectIssues(checks, value, undefined, ast, options) !== undefined +} + +/** @internal */ +const matchesTemplateLiteral = ( + ast: SchemaAST.TemplateLiteral, + value: unknown, + options: SchemaAST.ParseOptions +): boolean => typeof value === "string" && ast.matchPart(value, options) !== undefined + +class Failure { + readonly issue: SchemaIssue.Issue + + constructor(issue: SchemaIssue.Issue) { + this.issue = issue + } +} + +type DetailedDecoder = ( + input: unknown, + options: SchemaAST.ParseOptions +) => unknown | Failure + +const fail = (issue: SchemaIssue.Issue): Failure => new Failure(issue) + +const isFailure = (value: unknown): value is Failure => value instanceof Failure + +const invalidType = ( + ast: SchemaAST.AST, + input: unknown, + options: SchemaAST.ParseOptions +): Failure => fail(new SchemaIssue.InvalidType(ast, input, options)) + +const composite = ( + ast: SchemaAST.AST, + issue: SchemaIssue.Issue, + input: unknown, + options: SchemaAST.ParseOptions +): Failure => fail(new SchemaIssue.Composite(ast, [issue], input, options)) + +const pointer = (key: PropertyKey, failure: Failure): SchemaIssue.Pointer => + new SchemaIssue.Pointer([key], failure.issue) + +function compileDetailed(ast: SchemaAST.AST): DetailedDecoder { + const base = compileDetailedBase(ast) + if (getEncodingChecks(ast) === undefined && ast.checks === undefined) return base + return (input, options) => { + const output = base(input, options) + if (isFailure(output)) return output + const issue = checkOutput(ast, input, output, options) + return issue === undefined ? output : fail(issue) + } +} + +function compileDetailedBase(ast: SchemaAST.AST): DetailedDecoder { + switch (ast._tag) { + case "Null": + return (input, options) => + input === InternalParser.missing || input === null ? input : invalidType(ast, input, options) + case "Undefined": + return (input, options) => + input === InternalParser.missing || input === undefined ? input : invalidType(ast, input, options) + case "Void": + return (input) => input === InternalParser.missing ? input : undefined + case "Never": + return (input, options) => input === InternalParser.missing ? input : invalidType(ast, input, options) + case "Any": + case "Unknown": + return (input) => input + case "ObjectKeyword": + return (input, options) => { + if (input === InternalParser.missing) return input + return (input !== null && typeof input === "object") || typeof input === "function" + ? input + : invalidType(ast, input, options) + } + case "Enum": { + const values = new Set(ast.enums.map((entry) => entry[1])) + return (input, options) => { + if (input === InternalParser.missing) return input + return values.has(input) ? input : invalidType(ast, input, options) + } + } + case "UniqueSymbol": + return (input, options) => { + if (input === InternalParser.missing) return input + return input === ast.symbol ? input : invalidType(ast, input, options) + } + case "Literal": + return (input, options) => { + if (input === InternalParser.missing) return input + return input === ast.literal ? input : invalidType(ast, input, options) + } + case "String": + return (input, options) => { + if (input === InternalParser.missing) return input + return typeof input === "string" ? input : invalidType(ast, input, options) + } + case "Number": + return (input, options) => { + if (input === InternalParser.missing) return input + return typeof input === "number" ? input : invalidType(ast, input, options) + } + case "Boolean": + return (input, options) => { + if (input === InternalParser.missing) return input + return typeof input === "boolean" ? input : invalidType(ast, input, options) + } + case "Symbol": + return (input, options) => { + if (input === InternalParser.missing) return input + return typeof input === "symbol" ? input : invalidType(ast, input, options) + } + case "BigInt": + return (input, options) => { + if (input === InternalParser.missing) return input + return typeof input === "bigint" ? input : invalidType(ast, input, options) + } + case "TemplateLiteral": { + const parserAst = ast.asTemplateLiteralParser() + return (input, options) => { + if (input === InternalParser.missing) return input + return matchesTemplateLiteral(ast, input, options) + ? input + : fail( + new SchemaIssue.Composite( + ast, + [ + new SchemaIssue.Encoding( + parserAst, + typeof input === "string" + ? new SchemaIssue.InvalidValue( + { expected: "a string matching template literal parts" }, + input, + options + ) + : new SchemaIssue.InvalidType(SchemaAST.string, input, options), + input, + options + ) + ], + input, + options + ) + ) + } + } + case "Arrays": + return compileDetailedArrays(ast) + case "Objects": + return compileDetailedObjects(ast) + case "Union": + return compileDetailedUnion(ast) + default: + throw new Error(`Unsupported Schema AST: ${ast._tag}`) + } +} + +function compileDetailedArrays(ast: SchemaAST.Arrays): DetailedDecoder { + const elements = ast.elements.map((ast) => ({ ast, decode: compileDetailed(ast) })) + const rest = ast.rest.map((ast) => ({ ast, decode: compileDetailed(ast) })) + const elementLength = elements.length + const tailLength = Math.max(0, rest.length - 1) + return (input, options) => { + if (input === InternalParser.missing) return input + if (!Array.isArray(input)) return invalidType(ast, input, options) + const length = input.length + const output = new Array(length) + let issues: [SchemaIssue.Issue, ...Array] | undefined + const errorsAll = options.errors === "all" + const end = rest.length === 0 ? elementLength : Math.max(length, elementLength + tailLength) + const tailThreshold = Math.max(elementLength, length - tailLength) + for (let index = 0; index < end; index++) { + const element = Diagnostics.getTupleElement(elements, rest, tailThreshold, index) + const value = index < length ? input[index] : InternalParser.missing + const decoded = element.decode(value, options) + if (isFailure(decoded)) { + const issue = pointer(index, decoded) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + } else if (decoded !== InternalParser.missing) { + output[index] = decoded + } else if (!isOptional(element.ast)) { + const issue = Diagnostics.missingKey(index, element.ast) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + } + } + if (rest.length === 0 && length > elementLength) { + for (let index = elementLength; index < length; index++) { + const issue = Diagnostics.unexpectedKey(ast, index, input[index], options) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + } + } + return issues === undefined ? output : fail(new SchemaIssue.Composite(ast, issues, input, options)) + } +} + +function compileDetailedObjects(ast: SchemaAST.Objects): DetailedDecoder { + if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { + return (input, options) => { + if (input === InternalParser.missing) return input + return input !== null && input !== undefined ? input : invalidType(ast, input, options) + } + } + const properties = ast.propertySignatures.map((property) => ({ + ast: property.type, + decode: compileDetailed(property.type), + name: property.name, + optional: isOptional(property.type), + valueFirst: property.name !== "__proto__" && !isOptional(property.type) + })) + const indexes = ast.indexSignatures.map((signature) => ({ + signature, + decodeKey: compileDetailed(SchemaAST.parameterFromPropertyKey(signature.parameter)), + decodeValue: compileDetailed(signature.type) + })) + const expectedKeys = Diagnostics.getExpectedKeys(ast) + const expectedKeysSet = new Set(expectedKeys) + return (input, options) => { + if (input === InternalParser.missing) return input + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return invalidType(ast, input, options) + } + const record = input as Record + const output: Record = {} + const errorsAll = options.errors === "all" + let issues: [SchemaIssue.Issue, ...Array] | undefined + const indexKeys = indexes.length > 0 && options.onExcessProperty === "error" + ? indexes.map((index) => SchemaAST.getIndexSignatureKeys(record, index.signature.parameter, options)) + : undefined + if (options.onExcessProperty === "error") { + const coveredKeys = Diagnostics.getCoveredKeys(expectedKeysSet, indexKeys) + for (const key of Reflect.ownKeys(record)) { + if (coveredKeys.has(key)) continue + const issue = Diagnostics.unexpectedKey(ast, key, record[key], options) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + } + } + for (const property of properties) { + const name = property.name + let value: unknown + if (property.valueFirst) { + value = record[name] + if (value === undefined && !(name in record)) value = InternalParser.missing + } else { + const present = name === "__proto__" ? Object.hasOwn(record, name) : name in record + value = present ? record[name] : InternalParser.missing + } + const decoded = property.decode(value, options) + if (isFailure(decoded)) { + const issue = pointer(name, decoded) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + } else if (decoded !== InternalParser.missing) { + assignProperty(output, name, decoded) + } else if (!property.optional) { + const issue = Diagnostics.missingKey(name, property.ast) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + } + } + for (let i = 0; i < indexes.length; i++) { + const index = indexes[i] + const parameter = index.signature.parameter + const keys = indexKeys?.[i] ?? (parameter === SchemaAST.string + ? Object.keys(record) + : SchemaAST.getIndexSignatureKeys(record, parameter, options)) + for (const key of keys) { + let decodedKey: unknown = key + if (parameter !== SchemaAST.string) { + decodedKey = index.decodeKey(key, options) + if (isFailure(decodedKey)) { + const issue = pointer(key, decodedKey) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + continue + } + } + const inputValue = record[key] + const decodedValue = index.decodeValue(inputValue, options) + if (isFailure(decodedValue)) { + const issue = pointer(key, decodedValue) + if (!errorsAll) return composite(ast, issue, input, options) + if (issues === undefined) issues = [issue] + else issues.push(issue) + continue + } + if (decodedKey === InternalParser.missing || decodedValue === InternalParser.missing) continue + const outputKey = decodedKey as PropertyKey + if (properties.length > 0 && (expectedKeysSet.has(key) || expectedKeysSet.has(outputKey))) continue + assignProperty(output, outputKey, decodedValue) + } + } + if (issues !== undefined) return fail(new SchemaIssue.Composite(ast, issues, input, options)) + return output + } +} + +function compileDetailedUnion(ast: SchemaAST.Union): DetailedDecoder { + const decoders = new Map(ast.types.map((type) => [type, compileDetailed(type)])) + return (input, options) => { + if (input === InternalParser.missing) return input + const candidates = SchemaAST.getCandidates(input, ast.types) + const issues: Array = [] + const successes: Array | undefined = ast.options?.mode === "oneOf" ? [] : undefined + let output: unknown = invalid + for (const candidate of candidates) { + const decoded = decoders.get(candidate)!(input, options) + if (isFailure(decoded)) { + issues.push(decoded.issue) + } else if (successes === undefined) { + return decoded + } else { + successes.push(candidate) + output = decoded + if (successes.length > 1) { + return fail(new SchemaIssue.OneOf(ast, successes, input, options)) + } + } + } + return successes !== undefined && successes.length === 1 + ? output + : fail(new SchemaIssue.AnyOf(ast, issues, input, options)) + } +} + +const makeDetailed = (decode: DetailedDecoder): CompiledDecoder["decodeEffect"] => { + return (input, options) => { + try { + const output = decode(input, options) + if (isFailure(output)) return Effect.fail(output.issue) + if (output === InternalParser.missing) return InternalParser.missingExit + return output === input ? InternalParser.unchangedExit : InternalParser.succeed(output) + } catch (error) { + return Effect.die(error) + } + } +} + +/** @internal */ +const makeComposedObjectFallback = ( + ast: SchemaAST.Objects, + resolve: ResolveParser +): Parser => { + let parser: Parser | undefined + return (input, options) => { + try { + return (parser ??= ast.getParser(resolve))(input, options) + } catch (error) { + return Effect.die(error) + } + } +} + +/** @internal */ +const resumeComposedObject = ( + ast: SchemaAST.Objects, + properties: ReadonlyArray, + input: Record, + output: Record, + index: number, + pending: Effect.Effect, + options: SchemaAST.ParseOptions +): Effect.Effect => + resumeProperties({ ast, input, options, out: output, issues: undefined }, properties, index, pending) + +/** @internal */ +const failComposedObjectProperty = ( + ast: SchemaAST.Objects, + input: Record, + options: SchemaAST.ParseOptions, + key: PropertyKey, + exit: Exit.Failure +): Exit.Exit => + InternalSchemaCause.wrapPropertyKeyIssue( + { input, options, issues: undefined }, + ast, + key, + exit + )! + +/** @internal */ +const failMissingComposedObjectProperty = ( + ast: SchemaAST.Objects, + input: Record, + options: SchemaAST.ParseOptions, + property: ParsedProperty +): Exit.Exit => + Exit.fail( + new SchemaIssue.Composite( + ast, + [Diagnostics.missingKey(property.name, property.type)], + input, + options + ) + ) + +/** @internal */ +const invalidTypeIssue = (ast: SchemaAST.AST, input: unknown, options: SchemaAST.ParseOptions) => + Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + +/** @internal */ +interface ComposedObjectContext { + readonly ast: SchemaAST.Objects + readonly properties: ReadonlyArray + readonly fallback: Parser +} + +/** @internal */ +const makeComposedObjectContext = ( + ast: SchemaAST.Objects, + resolve: ResolveParser +): ComposedObjectContext => ({ + ast, + properties: ast.propertySignatures.map((property): ParsedProperty => { + const out: ParsedProperty = { + type: property.type, + name: property.name, + parser(input, options) { + const parser = resolve(property.type) + out.parser = parser + return parser(input, options) + }, + valueFirst: property.name !== "__proto__" && !isOptional(property.type) + } + return out + }), + fallback: makeComposedObjectFallback(ast, resolve) +}) + +/** @internal */ +const makeConstructionContext = (ast: SchemaAST.Objects, resolve: ResolveEntry): ComposedObjectContext => { + const properties = Constructors.properties(ast, resolve) + return { ast, properties, fallback: Constructors.objects(ast, resolve, properties) } +} + +/** @internal */ +const makeClassConstructor = (ast: SchemaAST.AST, resolve: ResolveEntry): Parser => + applyChecks(ast, makeConstructorParser(SchemaAST.getConstructorDescriptor(ast)!, constructorResolver(resolve))) + +/** @internal */ +const makeLeafConstructor = (ast: SchemaAST.AST): Parser => makeDetailed(compileDetailed(ast)) + +/** @internal */ +const interpretedDecoder = (ast: SchemaAST.AST, resolve: ResolveEntry): CompiledDecoder => + fromDecode(() => compileInterpreted(ast, makeResolveParser(resolve))) + +/** @internal */ +const withConstructor = (decoder: CompiledDecoder, make: () => Parser): CompiledDecoder => + Object.defineProperty(decoder, "makeEffect", { get: make }) + +/** @internal */ +const makeTypeDecoder = ( + ast: SchemaAST.AST, + makeValidate: () => Validate | undefined, + makeIs?: () => Is | undefined +): CompiledDecoder => ({ + get is() { + return makeIs?.() + }, + get validate() { + return makeValidate() + }, + get decodeEffect() { + return makeDetailed(compileDetailed(ast)) + } +}) + +/** @internal */ +const fromDecode = (makeDecode: () => CompiledDecoder["decodeEffect"]): CompiledDecoder => ({ + get decodeEffect() { + return makeDecode() + } +}) + +/** @internal */ +const makeLocalParser = (ast: SchemaAST.AST, resolve: ResolveParser): Parser => applyChecks(ast, ast.getParser(resolve)) + +/** @internal */ +const makeEncodingDecoder = ( + ast: SchemaAST.AST, + resolve: ResolveParser, + makeLocal: () => Parser +): CompiledDecoder => + fromDecode(() => { + const links = ast.encoding! + const parsers = links.map((link) => resolve(link.to)) + const decode = makeEncoding(ast, links, parsers, makeLocal()) + return (input, options) => { + try { + return decode(input, options) + } catch (error) { + return Effect.die(error) + } + } + }) + +/** @internal */ +export const runtime = { + getConstructorDescriptor: SchemaAST.getConstructorDescriptor, + resolveEntry, + makeConstructionContext, + makeClassConstructor, + makeLeafConstructor, + makeObjectConstructor: Constructors.objects, + makeArrayConstructor: Constructors.arrays, + makeUnionConstructor: Constructors.union, + interpretedDecoder, + withConstructor, + compileConstructor, + effectIsExit, + hasDefaultObjectOptions, + invalid, + resolve, + set, + prepareDecode, + args: InternalParser.args, + missing: InternalParser.missing, + missingExit: InternalParser.missingExit, + unchangedExit: InternalParser.unchangedExit, + succeed: InternalParser.succeed, + die, + defaultParseOptions, + getCandidates, + getIndexSignatureKeys, + parameterFromPropertyKey, + hasExcessProperties, + failsChecks, + matchesTemplateLiteral, + assignDecodedProperty: assignProperty, + getExpectedKeys: Diagnostics.getExpectedKeys, + resumeComposedObject, + failComposedObjectProperty, + failMissingComposedObjectProperty, + invalidTypeIssue, + makeComposedObjectContext, + makeComposedObjectFallback, + makeTypeDecoder, + fromDecode, + makeLocalParser, + makeEncodingDecoder, + applyChecks +} diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts new file mode 100644 index 00000000000..7b3c439804a --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -0,0 +1,40 @@ +/** + * Provides selective just-in-time compilation for Schema parsing and construction. Use + * {@link enable} to enable JIT compilation for one exact AST, or import + * `effect/unstable/schema/SchemaJITCompiler/enable` for its side effect to + * enable compilation globally. + * + * @since 4.0.0 + */ +import * as CompilerRegistry from "../../internal/schema/compilerRegistry.ts" +import { compile } from "../../internal/schema/jitCompiler.ts" +import type * as SchemaAST from "../../SchemaAST.ts" + +const compileScoped = CompilerRegistry.makeScopedCompiler(compile) + +/** + * Enables JIT compilation for an exact AST and its parsing and construction dependencies. + * + * **Details** + * + * The AST is installed immediately, while its `is`, `validate`, `decodeEffect`, and `makeEffect` + * operations remain lazy. Existing compiled descendants are preserved and + * lazy boundaries are compiled when first reached. If dynamic function + * generation is unavailable or compilation fails, decoding continues through + * the interpreter. Failed compilation is not retried for that entry. + * Exceptions from executing a parser keep their normal behavior and do not + * trigger fallback. + * Declaration type parameters are prepared on the declaration's first use, + * with their operations still lazy. New ASTs created inside its callback + * follow the normal registry policy. + * Use `SchemaAST.toType(schema.ast)` for construction, and install a distinct + * type-side AST separately from an encoded root. Decoding and construction + * initialize and recover from compilation failures independently. An interpreted + * constructor can still resolve selectively compiled children, including lazy ones. + * + * @category compilation + * @since 4.0.0 + */ +export const enable = (ast: SchemaAST.AST): void => { + compileScoped(ast) +} diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts new file mode 100644 index 00000000000..8e0e513f17a --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts @@ -0,0 +1,16 @@ +/** + * Enables lazy JIT compilation for every Schema AST resolved after this module + * is imported. ASTs already present in the shared registry are left unchanged. + * Import before the first schema use, including construction. Late activation is + * allowed but does not upgrade entries created by an earlier maker or decoder. + * If dynamic function generation is unavailable or compilation fails, the + * affected operation uses the interpreter without retrying compilation. Decoding + * and construction initialize independently. This + * fallback does not catch exceptions from executing the parser. + * + * @since 4.0.0 + */ +import * as CompilerRegistry from "../../../internal/schema/compilerRegistry.ts" +import { compile } from "../../../internal/schema/jitCompiler.ts" + +CompilerRegistry.install(compile) diff --git a/packages/effect/src/unstable/schema/index.ts b/packages/effect/src/unstable/schema/index.ts index af8486da163..6639e3f7d33 100644 --- a/packages/effect/src/unstable/schema/index.ts +++ b/packages/effect/src/unstable/schema/index.ts @@ -9,6 +9,21 @@ */ export * as Model from "./Model.ts" +/** + * @since 4.0.0 + */ +export * as SchemaAOTCompiler from "./SchemaAOTCompiler.ts" + +/** + * @since 4.0.0 + */ +export * as SchemaCompiler from "./SchemaCompiler.ts" + +/** + * @since 4.0.0 + */ +export * as SchemaJITCompiler from "./SchemaJITCompiler.ts" + /** * @since 4.0.0 */ diff --git a/packages/effect/test/Formatter.test.ts b/packages/effect/test/Formatter.test.ts index 340986c6f5b..553d2a5531c 100644 --- a/packages/effect/test/Formatter.test.ts +++ b/packages/effect/test/Formatter.test.ts @@ -570,26 +570,29 @@ describe("Formatter", () => { strictEqual(formatIssue(oneOf.failure), `Expected exactly one member to match the input "a"`) }) - it("respects annotated parse options", () => { - const enabled = Schema.String.annotate({ parseOptions: { reportInput: true } }) - const enabledResult = SchemaParser.decodeUnknownResult(enabled)(1, { reportInput: false }) + it("runtime reportInput overrides preset options and reaches nested schemas", () => { + const enabledResult = SchemaParser.decodeUnknownResult(Schema.String, { reportInput: false })(1, { + reportInput: true + }) assertTrue(Result.isFailure(enabledResult)) strictEqual(enabledResult.failure.input, 1) - const disabled = Schema.String.annotate({ parseOptions: { reportInput: false } }) - const disabledResult = SchemaParser.decodeUnknownResult(disabled)(1, { reportInput: true }) + const disabledResult = SchemaParser.decodeUnknownResult(Schema.String, { reportInput: true })(1, { + reportInput: false + }) assertTrue(Result.isFailure(disabledResult)) assertFalse(SchemaIssue.hasInput(disabledResult.failure)) - const nestedDisabled = Schema.Struct({ value: disabled }) + const nested = Schema.Struct({ value: Schema.String }) const nestedInput = { value: 1 } - const nestedResult = SchemaParser.decodeUnknownResult(nestedDisabled)(nestedInput, { reportInput: true }) + const nestedResult = SchemaParser.decodeUnknownResult(nested)(nestedInput, { reportInput: true }) assertTrue(Result.isFailure(nestedResult)) assertTrue(nestedResult.failure._tag === "Composite") strictEqual(nestedResult.failure.input, nestedInput) const pointer = nestedResult.failure.issues[0] assertTrue(pointer._tag === "Pointer") - assertFalse(SchemaIssue.hasInput(pointer.issue)) + assertTrue(SchemaIssue.hasInput(pointer.issue)) + strictEqual(pointer.issue.input, 1) }) it.effect("distinguishes present undefined from absent input in forbidden", () => diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 4639761fb54..0d04505c366 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" import { BigDecimal, Brand, @@ -127,8 +127,8 @@ describe("Schema", () => { }) }) - describe("parseOptions annotation", () => { - it("Number", async () => { + describe("parse options", () => { + it("does not interpret custom parseOptions metadata", async () => { const schema = Schema.Number.check(Schema.isGreaterThan(0), Schema.isInt()).annotate({ parseOptions: { errors: "all" } }) @@ -137,12 +137,11 @@ describe("Schema", () => { const decoding = asserts.decoding() await decoding.fail( -1.2, - `Expected a value greater than 0 -Expected an integer` + "Expected a value greater than 0" ) }) - it("Struct", async () => { + it("applies errors to nested schemas without annotation overrides", async () => { const schema = Schema.Struct({ a: Schema.String, b: Schema.Struct({ @@ -156,18 +155,19 @@ Expected an integer` await decoding.fail( { a: "a", b: {} }, `Missing key - at ["b"]["c"]` + at ["b"]["c"] +Missing key + at ["b"]["d"]` ) }) - it("should not read parseOptions from encodingChecks", async () => { + it("applies errors with encoding checks", async () => { const schema = Schema.Struct({ a: Schema.String, b: Schema.String }).pipe( Schema.flip, Schema.check(Schema.isMaxProperties(1)), - Schema.annotate({ parseOptions: { errors: "first" } }), Schema.flip ) assertTrue(SchemaAST.isObjects(schema.ast)) @@ -184,9 +184,6 @@ Missing key at ["b"]` ) }) - }) - - describe("parse options", () => { it("decoders can receive options when they are created", () => { const schema = Schema.Struct({ a: Schema.String @@ -196,9 +193,10 @@ Missing key const failure = decode({ a: "a", b: "b" }) assertTrue(Exit.isFailure(failure)) - const success = decode({ a: "a", b: "b" }, { onExcessProperty: "preserve" }) + const success = decode({ a: "a", b: "b" }, { onExcessProperty: "ignore" }) assertTrue(Exit.isSuccess(success)) - deepStrictEqual(success.value, { a: "a", b: "b" }) + deepStrictEqual(success.value, { a: "a" }) + assertTrue(Exit.isFailure(decode({ a: "a", b: "b" }))) }) it("encoders can receive options when they are created", () => { @@ -210,9 +208,10 @@ Missing key const failure = encode({ a: "a", b: "b" }) assertTrue(Exit.isFailure(failure)) - const success = encode({ a: "a", b: "b" }, { onExcessProperty: "preserve" }) + const success = encode({ a: "a", b: "b" }, { onExcessProperty: "ignore" }) assertTrue(Exit.isSuccess(success)) - deepStrictEqual(success.value, { a: "a", b: "b" }) + deepStrictEqual(success.value, { a: "a" }) + assertTrue(Exit.isFailure(encode({ a: "a", b: "b" }))) }) }) @@ -586,39 +585,6 @@ Missing key ) }) - describe("propertyOrder", () => { - it("all required fields", () => { - const schema = Schema.Struct({ - a: Schema.String, - b: Schema.String - }) - - const input = { c: "c", b: "b", a: "a", d: "d" } - const output = Schema.decodeUnknownSync(schema)(input, { - propertyOrder: "original", - onExcessProperty: "preserve" - }) - deepStrictEqual(Object.keys(output), ["c", "b", "a", "d"]) - }) - - it("optional field with default", () => { - const schema = Schema.Struct({ - a: Schema.String.pipe(Schema.encode({ - decode: SchemaGetter.withDefault(Effect.succeed("default-a")), - encode: SchemaGetter.passthrough() - })), - b: Schema.String - }) - - const input = { c: "c", b: "b", d: "d" } - const output = Schema.decodeUnknownSync(schema)(input, { - propertyOrder: "original", - onExcessProperty: "preserve" - }) - deepStrictEqual(Object.keys(output), ["c", "b", "d", "a"]) - }) - }) - describe("onExcessProperty", () => { it("error", async () => { const schema = Schema.Struct({ @@ -648,16 +614,17 @@ Expected no excess property ) }) - it("preserve", async () => { + it("ignore strips undeclared string and symbol properties", async () => { const schema = Schema.Struct({ a: Schema.String }) const asserts = new TestSchema.Asserts(schema) - const decoding = asserts.decoding({ parseOptions: { onExcessProperty: "preserve" } }) + const decoding = asserts.decoding({ parseOptions: { onExcessProperty: "ignore" } }) const sym = Symbol("sym") await decoding.succeed( - { a: "a", b: "b", c: "c", [sym]: "sym" } + { a: "a", b: "b", c: "c", [sym]: "sym" }, + { a: "a" } ) }) }) @@ -4457,42 +4424,51 @@ Expected a value between -2147483648 and 2147483647` strictEqual(evaluations, 0) }) - it.effect("preserves member order with concurrent decoding", () => + it.effect("does not start later members after an asynchronous success", () => Effect.gen(function*() { + const firstStarted = yield* Deferred.make() const firstLatch = yield* Deferred.make() - const secondCompleted = yield* Deferred.make() + let secondCalls = 0 const first = Schema.String.pipe( Schema.decode({ - decode: SchemaGetter.transformOrFail(() => Deferred.await(firstLatch).pipe(Effect.as("first"))), + decode: SchemaGetter.transformOrFail(() => + Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstLatch)), + Effect.as("first") + ) + ), encode: SchemaGetter.passthrough() }) ) const second = Schema.String.pipe( Schema.decode({ - decode: SchemaGetter.transformOrFail(() => - Deferred.succeed(secondCompleted, undefined).pipe(Effect.as("second")) - ), + decode: SchemaGetter.transform(() => { + secondCalls++ + return "second" + }), encode: SchemaGetter.passthrough() }) ) - const fiber = yield* Schema.decodeUnknownEffect(Schema.Union([first, second]))("value", { - concurrency: 2 - }).pipe(Effect.forkChild) + const fiber = yield* Schema.decodeUnknownEffect(Schema.Union([first, second]))("value").pipe(Effect.forkChild) - yield* Deferred.await(secondCompleted) + yield* Deferred.await(firstStarted) yield* Effect.yieldNow + strictEqual(secondCalls, 0) yield* Deferred.succeed(firstLatch, undefined) strictEqual(yield* Fiber.join(fiber), "first") + strictEqual(secondCalls, 0) })) - it.effect("uses a buffered concurrent success after earlier candidates fail", () => + it.effect("starts the next member only after an asynchronous failure", () => Effect.gen(function*() { + const firstStarted = yield* Deferred.make() const firstLatch = yield* Deferred.make() - const secondCompleted = yield* Deferred.make() + let secondCalls = 0 const first = Schema.String.pipe( Schema.decode({ decode: SchemaGetter.transformOrFail(() => - Deferred.await(firstLatch).pipe( + Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstLatch)), Effect.andThen(Effect.fail(new SchemaIssue.Forbidden({ message: "first failed" }))) ) ), @@ -4501,49 +4477,58 @@ Expected a value between -2147483648 and 2147483647` ) const second = Schema.String.pipe( Schema.decode({ - decode: SchemaGetter.transformOrFail(() => - Deferred.succeed(secondCompleted, undefined).pipe(Effect.as("second")) - ), + decode: SchemaGetter.transform(() => { + secondCalls++ + return "second" + }), encode: SchemaGetter.passthrough() }) ) - const fiber = yield* Schema.decodeUnknownEffect(Schema.Union([first, second]))("value", { - concurrency: 2 - }).pipe(Effect.forkChild) + const fiber = yield* Schema.decodeUnknownEffect(Schema.Union([first, second]))("value").pipe(Effect.forkChild) - yield* Deferred.await(secondCompleted) + yield* Deferred.await(firstStarted) yield* Effect.yieldNow + strictEqual(secondCalls, 0) yield* Deferred.succeed(firstLatch, undefined) strictEqual(yield* Fiber.join(fiber), "second") + strictEqual(secondCalls, 1) })) - it.effect(`mode: "oneOf" detects concurrent successes in member order`, () => + it.effect(`mode: "oneOf" detects asynchronous successes in member order`, () => Effect.gen(function*() { + const firstStarted = yield* Deferred.make() const firstLatch = yield* Deferred.make() - const secondCompleted = yield* Deferred.make() + let secondCalls = 0 const first = Schema.String.pipe( Schema.decode({ - decode: SchemaGetter.transformOrFail(() => Deferred.await(firstLatch).pipe(Effect.as("first"))), + decode: SchemaGetter.transformOrFail(() => + Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstLatch)), + Effect.as("first") + ) + ), encode: SchemaGetter.passthrough() }) ) const second = Schema.String.pipe( Schema.decode({ - decode: SchemaGetter.transformOrFail(() => - Deferred.succeed(secondCompleted, undefined).pipe(Effect.as("second")) - ), + decode: SchemaGetter.transform(() => { + secondCalls++ + return "second" + }), encode: SchemaGetter.passthrough() }) ) const fiber = yield* Schema.decodeUnknownEffect(Schema.Union([first, second], { mode: "oneOf" }))( - "value", - { concurrency: 2 } + "value" ).pipe(Effect.exit, Effect.forkChild) - yield* Deferred.await(secondCompleted) + yield* Deferred.await(firstStarted) yield* Effect.yieldNow + strictEqual(secondCalls, 0) yield* Deferred.succeed(firstLatch, undefined) const exit = yield* Fiber.join(fiber) + strictEqual(secondCalls, 1) strictEqual(exit._tag, "Failure") if (exit._tag === "Failure") { const reason = exit.cause.reasons[0] @@ -4560,45 +4545,6 @@ Expected a value between -2147483648 and 2147483647` } })) - it.effect("interrupts pending concurrent members after anyOf succeeds", () => - Effect.gen(function*() { - const firstStarted = yield* Deferred.make() - const firstLatch = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const secondInterrupted = yield* Deferred.make() - const first = Schema.String.pipe( - Schema.decode({ - decode: SchemaGetter.transformOrFail(() => - Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Deferred.await(firstLatch)), - Effect.as("first") - ) - ), - encode: SchemaGetter.passthrough() - }) - ) - const second = Schema.String.pipe( - Schema.decode({ - decode: SchemaGetter.transformOrFail(() => - Deferred.succeed(secondStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined).pipe(Effect.asVoid)) - ) - ), - encode: SchemaGetter.passthrough() - }) - ) - const fiber = yield* Schema.decodeUnknownEffect(Schema.Union([first, second]))("value", { - concurrency: 2 - }).pipe(Effect.forkChild) - - yield* Deferred.await(firstStarted) - yield* Deferred.await(secondStarted) - yield* Deferred.succeed(firstLatch, undefined) - strictEqual(yield* Fiber.join(fiber), "first") - yield* Deferred.await(secondInterrupted) - })) - it(`mode: "oneOf" with Void`, async () => { const schema = Schema.Union([Schema.Void, Schema.String], { mode: "oneOf" }) const asserts = new TestSchema.Asserts(schema) @@ -6608,6 +6554,50 @@ Expected a value between -2147483648 and 2147483647` }) describe("Class", () => { + it.effect("make preserves existing instances like the other constructor adapters", () => + Effect.gen(function*() { + let constructions = 0 + class A extends Schema.Class("A")({ a: Schema.String }) { + readonly construction = ++constructions + } + const instance = new A({ a: "a" }) + + assert.strictEqual(A.make(instance), instance) + assert.strictEqual(SchemaParser.make(A)(instance), instance) + assert.strictEqual(Option.getOrThrow(A.makeOption(instance)), instance) + assert.strictEqual(yield* A.makeEffect(instance), instance) + assert.strictEqual(constructions, 1) + + assert.notStrictEqual(new A(instance), instance) + assert.strictEqual(constructions, 2) + })) + + it("make applies defaults, source checks, and the constructor once", () => { + let defaults = 0 + let checks = 0 + let constructions = 0 + const struct = Schema.Struct({ + a: Schema.String.pipe(Schema.withConstructorDefault(Effect.sync(() => { + defaults++ + return "default" + }))) + }).check(Schema.makeFilter(() => { + checks++ + return true + })) + class A extends Schema.Class("A")(struct) { + readonly construction = ++constructions + } + + const instance = A.make({}) + + assert.instanceOf(instance, A) + assert.strictEqual(instance.a, "default") + assert.strictEqual(defaults, 1) + assert.strictEqual(checks, 1) + assert.strictEqual(constructions, 1) + }) + it("make with void input", () => { class A extends Schema.Class("A")({}) {} deepStrictEqual(A.make(), new A()) @@ -6968,17 +6958,17 @@ Expected a value between -2147483648 and 2147483647` assertFalse("extra" in instance) }) - it("constructor preserves excess properties when requested", () => { + it("constructor strips excess properties with explicit ignore", () => { class A extends Schema.Class("A")({ a: Schema.String }) {} const instance = new A({ a: "a", extra: "extra" } as any, { - parseOptions: { onExcessProperty: "preserve" } + parseOptions: { onExcessProperty: "ignore" } }) strictEqual(instance.a, "a") - strictEqual((instance as any).extra, "extra") + assertFalse("extra" in instance) }) it("constructor rejects excess properties when requested", () => { @@ -7084,7 +7074,7 @@ Expected a value between -2147483648 and 2147483647` assertFalse("extra" in instance) }) - it("constructor preserves subclass fields and excess properties when requested", () => { + it("constructor keeps subclass fields and strips excess properties with explicit ignore", () => { class A extends Schema.Class("A")({ a: Schema.String }) {} @@ -7093,12 +7083,12 @@ Expected a value between -2147483648 and 2147483647` }) {} const instance = new B({ a: "a", b: 2, extra: "extra" } as any, { - parseOptions: { onExcessProperty: "preserve" } + parseOptions: { onExcessProperty: "ignore" } }) strictEqual(instance.a, "a") strictEqual(instance.b, 2) - strictEqual((instance as any).extra, "extra") + assertFalse("extra" in instance) }) it("constructor does not treat subclass fields as excess properties", () => { @@ -7305,19 +7295,19 @@ Expected a value between -2147483648 and 2147483647` assertFalse("extra" in err) }) - it("constructor preserves excess properties when requested", () => { + it("constructor strips excess properties with explicit ignore", () => { class E extends Schema.Error("E")({ message: Schema.String, code: Schema.Number }) {} const err = new E({ message: "boom", code: 1, extra: "extra" } as any, { - parseOptions: { onExcessProperty: "preserve" } + parseOptions: { onExcessProperty: "ignore" } }) strictEqual(err.message, "boom") strictEqual(err.code, 1) - strictEqual((err as any).extra, "extra") + assertFalse("extra" in err) }) it("Struct argument", async () => { @@ -7418,6 +7408,13 @@ Expected a value between -2147483648 and 2147483647` }) describe("TaggedError", () => { + it("make preserves existing instances", () => { + class E extends Schema.TaggedError()("E", { message: Schema.String }) {} + const instance = new E({ message: "failure" }) + + assert.strictEqual(E.make(instance), instance) + }) + it("make with void input", () => { class E extends Schema.TaggedError()("E", {}) {} deepStrictEqual(E.make(), new E()) diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts new file mode 100644 index 00000000000..bcb46da7a1e --- /dev/null +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" +import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { roots, schemas, suspendEvaluations } from "./fixtures/aot.ts" + +describe("SchemaAOTCompiler", { concurrent: false }, () => { + it("emits deterministic modules without installing a decoder", () => { + let checks = 0 + const schema = Schema.Struct({ + value: Schema.String.check(Schema.makeFilter(() => { + checks++ + return true + })) + }) + const before = CompilerRegistry.resolve(schema.ast) + const source = SchemaAOTCompiler.compile([schema.ast]) + assert.strictEqual(SchemaAOTCompiler.compile([schema.ast]), source) + assert.strictEqual(CompilerRegistry.resolve(schema.ast), before) + assert.strictEqual(checks, 0) + assert.include(source, "effect/unstable/schema/SchemaCompiler/runtime") + assert.notInclude(source, "new Function") + assert.notInclude(source, "SchemaJITCompiler") + }) + + it("deduplicates repeated roots and dependencies shared across roots", () => { + const child = Schema.Struct({ value: Schema.String }) + const first = Schema.Struct({ child }) + const second = Schema.Array(child) + const source = SchemaAOTCompiler.compile([first.ast, second.ast]) + assert.strictEqual( + SchemaAOTCompiler.compile([first.ast, second.ast, child.ast, first.ast, second.ast]), + source + ) + }) + + it("runs generated decoders without dynamic code generation", () => { + const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-test-", import.meta.url))) + try { + for (const [name, schema] of Object.entries(schemas)) { + writeFileSync(join(directory, `${name}.mjs`), SchemaAOTCompiler.compile([schema.ast])) + } + writeFileSync(join(directory, "all.mjs"), SchemaAOTCompiler.compile(roots)) + writeFileSync(join(directory, "empty.mjs"), SchemaAOTCompiler.compile([])) + assert.strictEqual(suspendEvaluations, 0) + for (const mode of ["single", "multiple"]) { + const output = execFileSync(process.execPath, [ + "--disallow-code-generation-from-strings", + "--import", + fileURLToPath(new URL("./fixtures/aot-import-guard.ts", import.meta.url)), + fileURLToPath(new URL("./fixtures/aot-runner.ts", import.meta.url)), + directory, + mode + ], { encoding: "utf8" }) + assert.include(output, "AOT integration passed") + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }, 20_000) +}) diff --git a/packages/effect/test/schema/SchemaCompilerApi.test.ts b/packages/effect/test/schema/SchemaCompilerApi.test.ts new file mode 100644 index 00000000000..5601151f293 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerApi.test.ts @@ -0,0 +1,304 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Schema, SchemaAST, SchemaParser } from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +describe("SchemaCompiler", () => { + it("installs a decoder in the shared registry", () => { + const schema = Schema.Struct({ value: Schema.String }) + const early = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(early({ value: "interpreted" }), { value: "interpreted" }) + + let decodes = 0 + SchemaCompiler.set(schema.ast, { + is: () => true, + validate: (_input, options) => + options.reportInput === true + ? { value: "compiled" } + : SchemaCompiler.invalid, + decodeEffect: () => { + decodes++ + return Effect.succeed({ value: "detailed" }) + } + }) + + const late = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(late({ value: 1 }, { reportInput: true }), { value: "compiled" }) + deepStrictEqual(late({ value: 1 }), { value: "detailed" }) + strictEqual(decodes, 1) + + // Parsers that resolved the old entry before set keep using it. + deepStrictEqual(early({ value: "interpreted" }), { value: "interpreted" }) + }) + + it("uses is only for type guards", () => { + const schema = Schema.Struct({ value: Schema.String }) + let validations = 0 + SchemaCompiler.set(schema.ast, { + is: (input, options) => { + strictEqual(options, SchemaAST.defaultParseOptions) + return (input as { readonly value?: unknown }).value === "accepted" + }, + validate: (input) => { + validations++ + return input + }, + decodeEffect: Effect.succeed + }) + + strictEqual(SchemaParser.is(schema)({ value: "accepted" }), true) + strictEqual(SchemaParser.is(schema)({ value: "rejected" }), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: "decoded" }), { value: "decoded" }) + strictEqual(validations, 1) + }) + + it("retains one resolved entry across sync option paths", () => { + for (const direction of ["decode", "encode"]) { + for (const firstOptions of [undefined, { reportInput: true }]) { + const schema = Schema.Struct({ value: Schema.String }) + const makeSync = () => + direction === "decode" + ? SchemaParser.decodeUnknownSync(schema) + : SchemaParser.encodeUnknownSync(schema) + const decode = makeSync() + const input = { value: "original" } + deepStrictEqual(decode(input, firstOptions), input) + + SchemaCompiler.set(schema.ast, { + validate: () => ({ value: "replacement" }), + decodeEffect: () => Effect.succeed({ value: "replacement" }) + }) + + deepStrictEqual(decode(input), input) + deepStrictEqual(decode(input, { reportInput: true }), input) + deepStrictEqual(makeSync()(input), { value: "replacement" }) + } + } + }) + + it("does not resolve child operations until the child is parsed", () => { + const child = Schema.String.annotate({ title: "lazy child" }) + let reads = 0 + SchemaCompiler.set(child.ast, { + get validate() { + reads++ + return undefined + }, + get decodeEffect() { + reads++ + return Effect.succeed + } + }) + const schema = Schema.Struct({ first: Schema.Number, child }) + const decode = SchemaParser.decodeUnknownSync(schema) + throws(() => decode({ first: "invalid", child: "unreached" })) + strictEqual(reads, 0) + deepStrictEqual(decode({ first: 1, child: "reached" }), { first: 1, child: "reached" }) + strictEqual(reads, 2) + deepStrictEqual(decode({ first: 2, child: "cached" }), { first: 2, child: "cached" }) + strictEqual(reads, 2) + }) + + it("uses an installed child decoder from an interpreted Array", () => { + const child = Schema.String.annotate({ title: "installed array child" }) + SchemaCompiler.set(child.ast, { + validate: (input) => typeof input === "string" ? `${input}!` : SchemaCompiler.invalid, + decodeEffect: (input) => Effect.succeed(`${input}!`) + }) + + deepStrictEqual( + SchemaParser.decodeUnknownSync(Schema.Array(child))(["a"]), + ["a!"] + ) + }) + + it("exposes the canonical missing value to installed decoders", () => { + const schema = Schema.Struct({ value: Schema.optionalKey(Schema.String) }) + assert(schema.ast._tag === "Objects") + const value = schema.ast.propertySignatures[0].type + let sawMissing = false + SchemaCompiler.set(value, { + validate: (input) => typeof input === "string" ? input : SchemaCompiler.invalid, + decodeEffect: (input) => { + sawMissing = input === SchemaCompiler.missing + return Effect.succeed(input) + } + }) + + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({}), {}) + strictEqual(sawMissing, true) + }) + + it("installs encoders on the flipped AST", () => { + const schema = Schema.FiniteFromString + SchemaCompiler.set(SchemaAST.flip(schema.ast), { + validate: () => "aot", + decodeEffect: () => Effect.succeed("detailed") + }) + + strictEqual(SchemaParser.encodeUnknownSync(schema)(1), "aot") + }) +}) + +describe("SchemaJITCompiler", () => { + it("reuses option-independent generated functions for explicit options", () => { + const schema = Schema.Struct({ nested: Schema.Struct({ value: Schema.String }) }) + const input = { nested: { value: "valid" } } + const Function = globalThis.Function + let constructions = 0 + try { + globalThis.Function = ((...args: ReadonlyArray) => { + constructions++ + return Function(...args) + }) as FunctionConstructor + + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(input), input) + strictEqual(SchemaParser.is(schema)(input), true) + const initialized = constructions + assert(initialized > 1) + for (const options of [{}, { reportInput: true }, { errors: "all" }, { disableChecks: true }] as const) { + deepStrictEqual(decode(input, options), input) + strictEqual(SchemaParser.is(schema)(input), true) + strictEqual(constructions, initialized) + } + } finally { + globalThis.Function = Function + } + }) + + it("keeps generated operations lazy", () => { + const schema = Schema.Struct({ value: Schema.String }) + const Function = globalThis.Function + let constructions = 0 + try { + globalThis.Function = ((...args: ReadonlyArray) => { + constructions++ + return Function(...args) + }) as FunctionConstructor + + SchemaJITCompiler.enable(schema.ast) + strictEqual(constructions, 1) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: "valid" }), { value: "valid" }) + assert(constructions > 1) + } finally { + globalThis.Function = Function + } + }) + + it("replaces only the selected AST and leaves resolved parsers intact", () => { + const selected = Schema.Struct({ value: Schema.String }) + const untouched = Schema.Struct({ value: Schema.String }) + const early = SchemaParser.decodeUnknownSync(selected) + deepStrictEqual(early({ value: "valid" }), { value: "valid" }) + + SchemaJITCompiler.enable(selected.ast) + + let earlyReads = 0 + throws(() => + early({ + get value() { + earlyReads++ + return 1 + } + }) + ) + strictEqual(earlyReads, 1) + + let selectedReads = 0 + throws(() => + SchemaParser.decodeUnknownSync(selected)({ + get value() { + selectedReads++ + return 1 + } + }) + ) + strictEqual(selectedReads, 2) + + let untouchedReads = 0 + throws(() => + SchemaParser.decodeUnknownSync(untouched)({ + get value() { + untouchedReads++ + return 1 + } + }) + ) + strictEqual(untouchedReads, 1) + }) + + it("compiles descendants through an unsupported lazy root", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.suspend(() => child) + SchemaJITCompiler.enable(schema.ast) + + let reads = 0 + throws(() => + SchemaParser.decodeUnknownSync(schema)({ + get value() { + reads++ + return 1 + } + }) + ) + strictEqual(reads, 2) + }) + + it("prepares declaration type parameters with the selective compiler on first use", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.ReadonlySet(child) + Object.defineProperty(child.ast, "getParser", { + value() { + throw new Error("The declaration element must use its compiled decoder") + } + }) + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(new Set([{ value: "a", extra: true }])), new Set([{ value: "a" }])) + throws(() => decode(new Set([{ value: 1 }]))) + }) + + it("does not initialize unused declaration type parameter operations", () => { + const child = Schema.Struct({ value: Schema.String }) + let reads = 0 + SchemaCompiler.set(child.ast, { + get validate() { + reads++ + return undefined + }, + get decodeEffect() { + reads++ + return Effect.succeed + } + }) + const schema = Schema.ReadonlySet(child) + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + strictEqual(reads, 0) + deepStrictEqual(decode(new Set()), new Set()) + strictEqual(reads, 0) + deepStrictEqual(decode(new Set([{ value: "a" }])), new Set([{ value: "a" }])) + strictEqual(reads, 2) + }) + + it("preserves an installed decoder when dynamic code generation is unavailable", () => { + const schema = Schema.Struct({ value: Schema.String }) + SchemaCompiler.set(schema.ast, { + validate: () => ({ value: "installed" }), + decodeEffect: () => Effect.succeed({ value: "installed" }) + }) + const Function = globalThis.Function + try { + globalThis.Function = (() => { + throw new Error("dynamic function generation unavailable") + }) as any + SchemaJITCompiler.enable(schema.ast) + } finally { + globalThis.Function = Function + } + + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: 1 }), { value: "installed" }) + }) +}) diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts new file mode 100644 index 00000000000..fee2c73506e --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -0,0 +1,296 @@ +import { assert, describe, it, vi } from "@effect/vitest" +import { Effect, Schema, SchemaAST, SchemaParser } from "effect" +import * as Codegen from "effect/internal/schema/codegen" +import * as Registry from "effect/internal/schema/compilerRegistry" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { constructionCases, constructionEvents, constructionOptions } from "./fixtures/construction.ts" + +describe("Schema compiler construction", { concurrent: false }, () => { + it.effect("matches interpreted construction, effects and options", () => + Effect.gen(function*() { + const fixtures = Object.entries(constructionCases) + const snapshot = Effect.fnUntraced(function*(fixture: typeof fixtures[number][1]) { + const out = [] + const make = SchemaParser.makeEffect(fixture.schema) + for (const parseOptions of constructionOptions) { + for (const input of fixture.inputs) { + constructionEvents.length = 0 + const result = yield* Effect.result(make(input as never, { parseOptions })) + out.push({ result, events: [...constructionEvents] }) + } + } + return out + }) + // Capture every interpreted result before installing shared child ASTs. + const interpreted = yield* Effect.forEach(fixtures, ([, fixture]) => snapshot(fixture)) + for (const [index, [name, fixture]] of fixtures.entries()) { + SchemaJITCompiler.enable(SchemaAST.toType(fixture.schema.ast)) + assert.deepStrictEqual(yield* snapshot(fixture), interpreted[index], name) + } + })) + + it("keeps selective compilation below an interpreted Suspend", () => { + let forced = 0 + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.suspend(() => { + forced++ + return child + }) + SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) + assert.strictEqual(forced, 0) + Object.defineProperty(child.ast, "getParser", { + value() { + throw new Error("interpreted child") + } + }) + assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) + assert.strictEqual(forced, 1) + }) + + it("does not compile validators when only construction is used", () => { + const schema = Schema.Struct({ a: Schema.String }) + const emit = vi.spyOn(Codegen, "emitValidate") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.length, 0) + } finally { + emit.mockRestore() + } + }) + + it("does not compile construction when only decoding is used", () => { + const schema = Schema.Struct({ a: Schema.String }) + const emit = vi.spyOn(Codegen, "emitComposedObject") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.length, 0) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.length, 1) + } finally { + emit.mockRestore() + } + }) + + for (const compiled of [false, true]) { + it(`initializes only reached constructor children, compiled=${compiled}`, () => { + const child = Schema.String.annotate({ title: "lazy constructor child" }) + let reads = 0 + const decoder: SchemaCompiler.CompiledDecoder = Object.freeze({ + decodeEffect: Effect.succeed, + get makeEffect() { + assert.strictEqual(this, decoder) + reads++ + return Effect.succeed + } + }) + SchemaCompiler.set(child.ast, decoder) + const schema = Schema.Struct({ first: Schema.Number, child }) + if (compiled) SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + assert.throws(() => make({ first: "invalid", child: "unreached" } as never)) + assert.strictEqual(reads, 0) + assert.deepStrictEqual(make({ first: 1, child: "a" }), { first: 1, child: "a" }) + assert.deepStrictEqual(make({ first: 2, child: "b" }), { first: 2, child: "b" }) + assert.strictEqual(reads, 1) + }) + } + + it("prepares selective Declaration parameters for public decoders in the callback", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.ReadonlySet(child) + SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) + Object.defineProperty(child.ast, "getParser", { + value() { + throw new Error("interpreted Declaration parameter") + } + }) + assert.deepStrictEqual(SchemaParser.make(schema)(new Set([{ value: "a" }])), new Set([{ value: "a" }])) + assert.strictEqual(Registry.resolve(child.ast).origin, "installed") + }) + + it("does not restart a parent if compilation fails after a default", () => { + let defaults = 0 + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.Struct({ + child: child.pipe(Schema.withConstructorDefault(Effect.sync(() => { + defaults++ + return { value: "default" } + }))) + }) + const childAST = schema.fields.child.ast + const emit = Codegen.emitComposedObject + const failure = vi.spyOn(Codegen, "emitComposedObject").mockImplementation((ast) => { + if (ast === childAST) { + assert.strictEqual(defaults, 1) + throw new Error("child compile failed") + } + return emit(ast) + }) + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)({}), { child: { value: "default" } }) + assert.strictEqual(defaults, 1) + } finally { + failure.mockRestore() + } + }) + + for (const operation of ["make", "decode"] as const) { + it(`keeps the other operation compiled after ${operation} compilation fails`, () => { + const schema = Schema.Struct({ a: Schema.String }) + SchemaJITCompiler.enable(schema.ast) + const failed = vi.spyOn(Codegen, operation === "make" ? "emitComposedObject" : "emitValidate") + .mockImplementationOnce(() => { + throw new Error("compile failed") + }) + try { + const first = operation === "make" ? SchemaParser.make(schema) : SchemaParser.decodeUnknownSync(schema) + assert.deepStrictEqual(first({ a: "a" }), { a: "a" }) + Object.defineProperty(schema.ast, "getParser", { + value() { + throw new Error("other operation fell back") + } + }) + const second = operation === "make" ? SchemaParser.decodeUnknownSync(schema) : SchemaParser.make(schema) + assert.deepStrictEqual(second({ a: "a" }), { a: "a" }) + } finally { + failed.mockRestore() + } + }) + } + it("resolves installed construction lazily and independently from decoding", () => { + const schema = Schema.Struct({ a: Schema.String }) + let reads = 0 + let calls = 0 + SchemaCompiler.set(schema.ast, { + get decodeEffect(): SchemaCompiler.Decode { + throw new Error("unused decoder") + }, + get validate(): SchemaCompiler.Validate { + throw new Error("unused validator") + }, + get is(): SchemaCompiler.Is { + throw new Error("unused guard") + }, + get makeEffect() { + reads++ + return (input: unknown) => { + calls++ + return Effect.succeed(input) + } + } + }) + const make = SchemaParser.make(schema) + assert.strictEqual(reads, 0) + assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "b" }), { a: "b" }) + assert.strictEqual(reads, 1) + assert.strictEqual(calls, 2) + }) + + it("caches interpreted construction when an installed bundle omits it", () => { + const schema = Schema.Struct({ a: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }) + SchemaCompiler.set(schema.ast, { + decodeEffect: () => { + throw new Error("not a constructor") + } + }) + const entry = Registry.resolve(schema.ast) + const make = entry.makeEffect + assert.strictEqual(entry.makeEffect, make) + assert.deepStrictEqual(SchemaParser.make(schema)({}), { a: 1 }) + }) + + it("keeps previously captured constructors after whole-entry replacement", () => { + const schema = Schema.Struct({ a: Schema.String }) + const make = SchemaParser.make(schema) + assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + makeEffect: () => Effect.succeed({ a: "installed" }) + }) + assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "installed" }) + SchemaCompiler.set(schema.ast, { decodeEffect: Effect.succeed }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + }) + + it("uses type-side identity without applying root defaults", () => { + const schema = Schema.FiniteFromString.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + const ast = SchemaAST.toType(schema.ast) + SchemaCompiler.set(ast, { decodeEffect: Effect.succeed, makeEffect: Effect.succeed }) + assert.strictEqual(SchemaParser.make(schema)(2), 2) + const number = Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + SchemaJITCompiler.enable(number.ast) + assert.throws(() => SchemaParser.make(number)(undefined as never)) + assert.deepStrictEqual(SchemaParser.make(Schema.Struct({ number }))({}), { number: 1 }) + }) + + it("preserves missing separately from undefined for installed children", () => { + const child = Schema.optionalKey(Schema.Undefined) + const inputs: Array = [] + SchemaCompiler.set(child.ast, { + decodeEffect: Effect.succeed, + makeEffect: (input) => { + inputs.push(input) + return Effect.succeed(input) + } + }) + const schema = Schema.Struct({ child }) + assert.deepStrictEqual(SchemaParser.make(schema)({}), {}) + assert.deepStrictEqual(SchemaParser.make(schema)({ child: undefined }), { child: undefined }) + assert.deepStrictEqual(inputs, [SchemaCompiler.missing, undefined]) + }) + + it("lets parents and public roots handle missing constructor outputs", () => { + const required = Schema.String.annotate({ title: "Required construction output" }) + const optional = Schema.optionalKey(required) + for (const schema of [required, optional]) { + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + makeEffect: () => Effect.succeed(SchemaCompiler.missing) + }) + } + const schema = Schema.Struct({ required, optional }) + SchemaJITCompiler.enable(schema.ast) + assert.throws(() => SchemaParser.make(schema)({ required: "a" }), /Schema validation failed/) + assert.deepStrictEqual(SchemaParser.make(Schema.Struct({ optional }))({ optional: "a" }), {}) + assert.throws(() => SchemaParser.make(required)("a"), /Schema validation failed/) + }) + + it("runs generated Struct construction without preparing an interpreter", () => { + const schema = Schema.Struct({ + a: Schema.String, + b: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + }) + SchemaJITCompiler.enable(schema.ast) + Object.defineProperty(schema.ast, "getParser", { + value() { + throw new Error("interpreted Struct") + } + }) + const make = SchemaParser.make(schema) + assert.deepStrictEqual(make({ a: "a" }), { a: "a", b: 1 }) + assert.throws(() => make({ a: 1 } as never), /Schema validation failed/) + }) + + it.effect("executes async defaults once, including on later failure", () => + Effect.gen(function*() { + let defaults = 0 + const schema = Schema.Struct({ + a: Schema.String.pipe(Schema.withConstructorDefault(Effect.gen(function*() { + yield* Effect.yieldNow + defaults++ + return "default" + }))), + b: Schema.Number + }) + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.makeEffect(schema) + assert.deepStrictEqual(yield* make({ b: 1 }), { a: "default", b: 1 }) + assert.strictEqual((yield* Effect.exit(make({ b: "bad" } as never)))._tag, "Failure") + assert.strictEqual(defaults, 2) + })) +}) diff --git a/packages/effect/test/schema/SchemaCompilerRegression.test.ts b/packages/effect/test/schema/SchemaCompilerRegression.test.ts new file mode 100644 index 00000000000..b351aac8a80 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerRegression.test.ts @@ -0,0 +1,457 @@ +import { assert, describe, it } from "@effect/vitest" +import { + Effect, + Exit, + Option, + Result, + Schema, + SchemaAST, + SchemaGetter, + SchemaParser, + SchemaTransformation +} from "effect" +import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { deepStrictEqual, strictEqual } from "../utils/assert.ts" + +describe("compiler regression contracts", () => { + it("preserves template literal issues after compilation", () => { + const schema = Schema.TemplateLiteral(["count:", Schema.Int.check(Schema.isGreaterThan(0))]) + const inputs = ["count:1", "count:0", "count:1.5", "invalid", null] + const snapshot = () => { + const decode = SchemaParser.decodeUnknownResult(schema) + // Diagnostic ASTs contain freshly constructed transformation functions. + return inputs.map((input) => Result.mapError(decode(input), (issue) => JSON.stringify(issue))) + } + const interpreted = snapshot() + SchemaJITCompiler.enable(schema.ast) + deepStrictEqual(snapshot(), interpreted) + }) + + it.effect("preserves missing and present undefined through eager and suspended transformations", () => + Effect.gen(function*() { + for (const suspended of [false, true]) { + const seen: Array> = [] + const schema = Schema.Struct({ + value: Schema.Unknown.pipe( + Schema.decode({ + decode: new SchemaGetter.Getter((input) => { + seen.push(input) + const output = Option.isNone(input) || input.value === "omit" ? Option.none() : Option.some(undefined) + return suspended ? Effect.sync(() => output) : Effect.succeed(output) + }), + encode: SchemaGetter.passthrough() + }), + Schema.optionalKey + ) + }) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + seen.length = 0 + const decode = SchemaParser.decodeUnknownEffect(schema) + deepStrictEqual(yield* decode({}), {}) + deepStrictEqual(yield* decode({ value: "omit" }), {}) + deepStrictEqual(yield* decode({ value: "present" }), { value: undefined }) + deepStrictEqual(seen, [Option.none(), Option.some("omit"), Option.some("present")]) + } + } + })) + + it.effect("continues encoding checkpoints after middleware recovery without replaying transformations", () => + Effect.gen(function*() { + for (const suspended of [false, true]) { + const events: Array = [] + const schema = Schema.String.pipe( + Schema.decodeTo( + Schema.Number.check(Schema.isGreaterThan(0)), + SchemaTransformation.transform({ + decode: (input) => { + events.push("first") + return Number(input) + }, + encode: String + }) + ), + Schema.middlewareDecoding((effect) => + Effect.catchEager(effect, () => { + events.push("recover") + return suspended ? Effect.sync(() => Option.some(1)) : Effect.succeed(Option.some(1)) + }) + ), + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (input) => { + events.push("last") + return String(input) + }, + encode: Number + }) + ) + ) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownEffect(schema) + events.length = 0 + strictEqual(yield* decode("2"), "2") + deepStrictEqual(events, ["first", "last"]) + events.length = 0 + strictEqual(yield* decode("-1"), "1") + deepStrictEqual(events, ["first", "recover", "last"]) + events.length = 0 + strictEqual(yield* decode(false), "1") + deepStrictEqual(events, ["recover", "last"]) + } + } + })) + + it.effect("resolved parsers return Effects containing their actual output", () => + Effect.gen(function*() { + const object = { value: "a" } + const cases: ReadonlyArray = [ + [Schema.String, "a", "a"], + [Schema.Number, -0, -0], + [Schema.Literal(0), -0, -0], + [Schema.Undefined, undefined, undefined], + [Schema.ObjectKeyword, object, object], + [Schema.Json, object, object], + [Schema.Struct({}), 1, 1], + [Schema.TemplateLiteral(["a"]), "a", "a"], + [Schema.FiniteFromString, "1", 1] + ] + for (const [schema, input, expected] of cases) { + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const parser = CompilerRegistry.resolve(schema.ast).parseEffect + const effect = parser(input, SchemaAST.defaultParseOptions) + strictEqual(Effect.isEffect(effect), true) + const output = yield* Effect.map(effect, (value) => value) + strictEqual(Object.is(output, expected), true) + const publicEffect = SchemaParser.decodeUnknownEffect(schema)(input) + strictEqual(Effect.isEffect(publicEffect), true) + strictEqual(Object.is(yield* publicEffect, expected), true) + } + } + })) + + it.effect("retains public success values across subsequent and reentrant parser calls", () => + Effect.gen(function*() { + let reenter: (input: unknown) => string + const schema = Schema.String.check( + Schema.makeFilter((value) => value !== "first" || reenter("nested") === "nested") + ) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownEffect(schema) + const decodeSync = SchemaParser.decodeUnknownSync(schema) + reenter = decodeSync + const first = decode("first") + const second = decode("second") + strictEqual( + yield* Effect.map(first, (value) => { + strictEqual(decodeSync("nested"), "nested") + return value + }), + "first" + ) + strictEqual(yield* second, "second") + strictEqual(yield* first, "first") + } + })) + + it.effect("preserves unchanged fields before and after asynchronous transformations", () => + Effect.gen(function*() { + const number = Schema.String.pipe(Schema.decodeTo(Schema.Number, { + decode: new SchemaGetter.Getter((input) => Effect.yieldNow.pipe(Effect.as(Option.map(input, Number)))), + encode: SchemaGetter.transform(String) + })) + const tuple = Schema.Tuple([Schema.String, number, Schema.Undefined]).check( + Schema.makeFilter((value) => value[0] === "before" && value[1] === 42 && value[2] === undefined) + ) + const struct = Schema.Struct({ before: Schema.String, middle: number, after: Schema.Undefined }).check( + Schema.makeFilter((value) => value.before === "before" && value.middle === 42 && value.after === undefined) + ) + for (const compiled of [false, true]) { + if (compiled) { + SchemaJITCompiler.enable(tuple.ast) + SchemaJITCompiler.enable(struct.ast) + } + deepStrictEqual( + yield* SchemaParser.decodeUnknownEffect(tuple)(["before", "42", undefined]), + ["before", 42, undefined] + ) + deepStrictEqual( + yield* SchemaParser.decodeUnknownEffect(struct)({ before: "before", middle: "42", after: undefined }), + { before: "before", middle: 42, after: undefined } + ) + } + })) + + it("calls installed operations with options without inspecting extra function properties", () => { + const schema = Schema.Struct({ value: Schema.String }) + const seen: Array = [] + const is: SchemaCompiler.Is = (_input, options) => { + seen.push(options) + return true + } + const validate: SchemaCompiler.Validate = (input, options) => { + seen.push(options) + return input + } + for (const operation of [is, validate]) { + Object.defineProperty(operation, "default", { + get() { + throw new Error("Not part of the compiled decoder contract") + } + }) + } + SchemaCompiler.set(schema.ast, { is, validate, decodeEffect: Effect.succeed }) + const input = { value: "a" } + strictEqual(SchemaParser.is(schema)(input), true) + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + const options = { reportInput: true } + strictEqual(SchemaParser.decodeUnknownSync(schema, options)(input), input) + deepStrictEqual(seen, [SchemaAST.defaultParseOptions, SchemaAST.defaultParseOptions, options]) + }) + + it("bounds inlining of shared subgraphs", () => { + let schema: Schema.Codec = Schema.Struct({ value: Schema.optionalKey(Schema.String) }) + let valid: unknown = { value: "value" } + let invalid: unknown = { value: 1 } + for (let i = 0; i < 16; i++) { + schema = Schema.Struct({ + left: Schema.optionalKey(schema), + right: Schema.optionalKey(schema) + }) + valid = { left: valid } + invalid = { left: invalid } + } + const cases = [ + { schema, valid, invalid }, + { schema, valid: { left: { right: {} } }, invalid: { left: { right: 1 } } }, + { schema: Schema.Array(schema), valid: [{}], invalid: [1] }, + { schema: Schema.Union([schema, Schema.String]), valid: {}, invalid: 1 } + ] + for (const { schema, valid, invalid } of cases) { + const expected = SchemaParser.decodeUnknownResult(schema)(invalid) + assert(Result.isFailure(expected)) + SchemaJITCompiler.enable(schema.ast) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(valid), valid) + strictEqual(SchemaParser.is(schema)(valid), true) + strictEqual(SchemaParser.is(schema)(invalid), false) + deepStrictEqual(SchemaParser.decodeUnknownResult(schema)(invalid), expected) + } + }) + + it("bounds generated composed parsers for wide objects", () => { + const property = Schema.optionalKey(Schema.String) + const schema = Schema.Struct(Object.fromEntries( + Array.from({ length: 4096 }, (_, i) => [`key${i}`, property]) + )) + SchemaJITCompiler.enable(schema.ast) + const input = { key4095: "last" } + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + strictEqual(SchemaParser.is(schema)(input), true) + strictEqual(SchemaParser.is(schema)({ key4095: 1 }), false) + }) + + it("stops oneOf after its second successful candidate", () => { + const schema = Schema.Union([ + Schema.String.check(Schema.isMinLength(1)), + Schema.String.check(Schema.isMaxLength(10)), + Schema.String.check(Schema.makeFilter(() => { + throw new Error("The third candidate must not be evaluated") + })) + ], { mode: "oneOf" }) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + strictEqual(SchemaParser.is(schema)("hello"), false) + for (const options of [undefined, { errors: "all" }] as const) { + const result = SchemaParser.decodeUnknownResult(schema, options)("hello") + assert(Result.isFailure(result)) + strictEqual(result.failure._tag, "OneOf") + } + } + }) + + it.effect("accepts both zero signs and preserves the input across parser adapters", () => + Effect.gen(function*() { + const options: Array = [ + undefined, + { errors: "all" }, + { reportInput: true } + ] + for (const literal of [0, -0]) { + const schemas = [ + Schema.Literal(literal), + Schema.Union([Schema.Literal(literal), Schema.Literal(1)]), + Schema.Literal(literal).check(Schema.makeFilter((n) => Object.is(n, -0))) + ] + for (const schema of schemas) { + const nested = Schema.Struct({ values: Schema.Array(schema) }) + for (const compiled of [false, true]) { + if (compiled) { + SchemaJITCompiler.enable(schema.ast) + SchemaJITCompiler.enable(nested.ast) + } + for (const input of [0, -0]) { + if (schema.ast.checks && !Object.is(input, -0)) { + strictEqual(SchemaParser.is(schema)(input), false) + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)(input))) + continue + } + strictEqual(SchemaParser.is(schema)(input), true) + for (const option of options) { + strictEqual(Object.is(SchemaParser.decodeUnknownSync(schema, option)(input), input), true) + strictEqual(Object.is(SchemaParser.encodeUnknownSync(schema, option)(input), input), true) + const result = SchemaParser.decodeUnknownResult(schema, option)(input) + assert(Result.isSuccess(result)) + strictEqual(Object.is(result.success, input), true) + const exit = SchemaParser.decodeUnknownExit(schema, option)(input) + assert(Exit.isSuccess(exit)) + strictEqual(Object.is(exit.value, input), true) + const optional = SchemaParser.decodeUnknownOption(schema, option)(input) + assert(Option.isSome(optional)) + strictEqual(Object.is(optional.value, input), true) + const output = yield* SchemaParser.decodeUnknownEffect(schema, option)(input) + strictEqual(Object.is(output, input), true) + const decoded = SchemaParser.decodeUnknownSync(nested, option)({ values: [input] }) + strictEqual(Object.is(decoded.values[0], input), true) + } + } + } + } + } + })) + + it("retains the original encoding AST in local checks", () => { + const schema = Schema.NumberFromString.check( + Schema.makeFilter((_value, ast) => ast === schema.ast && ast.encoding !== undefined) + ) + strictEqual(SchemaParser.decodeUnknownSync(schema)("1"), 1) + SchemaJITCompiler.enable(schema.ast) + strictEqual(SchemaParser.decodeUnknownSync(schema)("1"), 1) + }) + + it("retains the original encoding AST in structural issues", () => { + const schema = Schema.String.pipe(Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ decode: () => "invalid" as any, encode: String }) + )) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const result = SchemaParser.decodeUnknownResult(schema)("input") + assert(Result.isFailure(result)) + assert(result.failure._tag === "InvalidType") + strictEqual(result.failure.ast, schema.ast) + } + }) + + it("installs accessors without evaluating them and reads only the selected operation once", () => { + const schema = Schema.Struct({ value: Schema.String }) + const reads: Array = [] + const decoder = { + get is() { + strictEqual(this, decoder) + reads.push("is") + return (_input: unknown) => true + }, + get validate() { + strictEqual(this, decoder) + reads.push("validate") + return (input: unknown) => input + }, + get decodeEffect() { + strictEqual(this, decoder) + reads.push("decode") + return Effect.succeed + } + } + SchemaCompiler.set(schema.ast, decoder) + deepStrictEqual(reads, []) + strictEqual(SchemaParser.is(schema)({ value: "a" }), true) + strictEqual(SchemaParser.is(schema)({ value: "b" }), true) + deepStrictEqual(reads, ["is"]) + const input = { value: "a" } + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + deepStrictEqual(reads, ["is", "validate"]) + }) + + it("memoizes an absent optional operation", () => { + const schema = Schema.Struct({ value: Schema.String }) + let reads = 0 + SchemaCompiler.set(schema.ast, { + get is() { + reads++ + return undefined + }, + validate: (input) => input, + decodeEffect: Effect.succeed + }) + strictEqual(SchemaParser.is(schema)({ value: "a" }), true) + strictEqual(SchemaParser.is(schema)({ value: "b" }), true) + strictEqual(reads, 1) + }) + + it("shares lazy detailed decoding across public adapters without mutating the supplied decoder", () => { + const schema = Schema.Struct({ value: Schema.String }) + const reads: Array = [] + const decoder = Object.freeze({ + get validate() { + strictEqual(this, decoder) + reads.push("validate") + return () => SchemaCompiler.invalid + }, + get decodeEffect() { + strictEqual(this, decoder) + reads.push("decode") + return Effect.succeed + } + }) + SchemaCompiler.set(schema.ast, decoder) + const input = { value: "a" } + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + deepStrictEqual(SchemaParser.decodeUnknownResult(schema)(input), Result.succeed(input)) + strictEqual(SchemaParser.decodeUnknownSync(schema, { reportInput: true })(input), input) + deepStrictEqual(reads, ["validate", "decode"]) + }) + + it("does not restart validation inside the detailed decoder", () => { + const schema = Schema.Struct({ values: Schema.Array(Schema.Struct({ value: Schema.String })) }) + SchemaJITCompiler.enable(schema.ast) + let reads = 0 + const result = SchemaParser.decodeUnknownResult(schema)({ + values: [{ + get value() { + reads++ + return 1 + } + }] + }) + assert(Result.isFailure(result)) + strictEqual(reads, 2) + }) + + it("uses the interpreter after selective JIT generation fails", () => { + const schema = Schema.Struct({ value: Schema.String }) + const original = globalThis.Function + const defect = new SyntaxError("generated source defect") + let attempts = 0 + try { + globalThis.Function = ((...parameters: Array) => { + if (parameters.length === 1 && parameters[0] === "return true") return original(...parameters) + attempts++ + throw defect + }) as FunctionConstructor + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: "a", extra: true }), { value: "a" }) + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)({ value: 1 }))) + deepStrictEqual(decode({ value: "b" }), { value: "b" }) + strictEqual(attempts, 1) + } finally { + globalThis.Function = original + } + }) +}) diff --git a/packages/effect/test/schema/SchemaCompilerStartup.test.ts b/packages/effect/test/schema/SchemaCompilerStartup.test.ts new file mode 100644 index 00000000000..5534a983c0f --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerStartup.test.ts @@ -0,0 +1,19 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaParser } from "effect" +import * as Registry from "effect/internal/schema/compilerRegistry" + +describe("Schema compiler startup", () => { + it("allows late global activation without replacing a maker's interpreted entry", async () => { + const schema = Schema.Struct({ a: Schema.String }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + const before = Registry.resolve(schema.ast) + assert.strictEqual(before.origin, "interpreted") + const unused = Schema.Struct({ a: Schema.Number }) + const make = SchemaParser.make(unused) + await import("effect/unstable/schema/SchemaJITCompiler/enable") + assert.strictEqual(Registry.resolve(schema.ast), before) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) + assert.deepStrictEqual(make({ a: 1 }), { a: 1 }) + assert.strictEqual(Registry.resolve(unused.ast).origin, "installed") + }) +}) diff --git a/packages/effect/test/schema/SchemaJITCompiler.test.ts b/packages/effect/test/schema/SchemaJITCompiler.test.ts new file mode 100644 index 00000000000..0e69e7d0624 --- /dev/null +++ b/packages/effect/test/schema/SchemaJITCompiler.test.ts @@ -0,0 +1,798 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Effect, Result, Schema, SchemaGetter, SchemaIssue, SchemaParser, SchemaTransformation } from "effect" +import { SchemaCompiler } from "effect/unstable/schema" +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import { assertSchemaIssueError, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +const schema = Schema.Struct({ + name: Schema.String, + count: Schema.Number, + active: Schema.Boolean, + nested: Schema.Struct({ value: Schema.String }) +}) + +const decode = SchemaParser.decodeUnknownSync(schema) +const is = SchemaParser.is(schema) + +describe("SchemaJITCompiler", () => { + it("compiles a decoder lazily after import", () => { + const input = { + name: "a", + count: 1, + active: true, + nested: { value: "b", extra: true }, + extra: true + } + const output = decode(input) + + deepStrictEqual(output, { + name: "a", + count: 1, + active: true, + nested: { value: "b" } + }) + assert.notStrictEqual(output, input) + assert.notStrictEqual(output.nested, input.nested) + }) + + it("compiles type guards", () => { + strictEqual( + is({ + name: "a", + count: 1, + active: true, + nested: { value: "b" } + }), + true + ) + strictEqual( + is({ + name: 1, + count: 1, + active: true, + nested: { value: "b" } + }), + false + ) + + const defect = new Error("boom") + let reads = 0 + throws(() => + is({ + get name(): string { + reads++ + throw defect + }, + count: 1, + active: true, + nested: { value: "b" } + }), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Type guard adapter can only return false for schema issues") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + strictEqual(reads, 1) + }) + + it("does not confuse a valid value with the invalid sentinel", () => { + const schemas = [ + Schema.Union([Schema.Symbol, Schema.String]), + Schema.Union([Schema.UniqueSymbol(SchemaCompiler.invalid), Schema.Literal("valid")]) + ] + for (const schema of schemas) { + strictEqual(SchemaParser.is(schema)(SchemaCompiler.invalid), true) + strictEqual(SchemaParser.decodeUnknownSync(schema)(SchemaCompiler.invalid), SchemaCompiler.invalid) + } + }) + + it("uses default options in compiled type guards", () => { + const structural = Schema.Struct({ value: Schema.String }) + const is = SchemaParser.is(structural) + strictEqual(is({ value: "a" }), true) + strictEqual(is({ value: "a", extra: true }), true) + strictEqual(is({ value: 1 }), false) + + const checked = Schema.Struct({ a: Schema.String, b: Schema.String }).check( + Schema.makeFilter((value, _ast, options) => options.reportInput === true && !Object.hasOwn(value, "extra")) + ) + const input = { b: "b", a: "a", extra: true } + strictEqual(SchemaParser.is(checked)(input), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(input, { reportInput: true }), { a: "a", b: "b" }) + deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(input, { disableChecks: true }), { a: "a", b: "b" }) + strictEqual(SchemaParser.is(checked)(input), false) + }) + + it("keeps runtime options for nested checks, template parts and record keys", () => { + const checked = Schema.Struct({ + nested: Schema.Struct({ value: Schema.String }).check( + Schema.makeFilter((_value, _ast, options) => options.reportInput === true) + ) + }) + const value = { nested: { value: "valid" } } + strictEqual(SchemaParser.is(checked)(value), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(value, { reportInput: true }), value) + + const template = Schema.Struct({ + value: Schema.TemplateLiteral(["prefix-", Schema.String.check(Schema.isMinLength(2))]) + }) + strictEqual(SchemaParser.is(template)({ value: "prefix-a" }), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(template)({ value: "prefix-a" }, { disableChecks: true }), { + value: "prefix-a" + }) + strictEqual(SchemaParser.is(template)({ value: "prefix-a" }), false) + + const record = Schema.Record(Schema.String.check(Schema.isStartsWith("x")), Schema.Number) + const decode = SchemaParser.decodeUnknownSync(record) + deepStrictEqual(decode({ x: 1, y: 2 }), { x: 1 }) + deepStrictEqual(decode({ x: 1, y: 2 }, { disableChecks: true }), { x: 1, y: 2 }) + }) + + it("runs the diagnostic phase after fast validation fails", () => { + let reads = 0 + const input = { + get name() { + reads++ + return 1 + }, + count: 1, + active: true, + nested: { value: "b" } + } + + throws(() => decode(input), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["name"]`) + }) + strictEqual(reads, 2) + }) + + it("runs one diagnostic pass for a nested failure", () => { + let checks = 0 + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + nested: Schema.Struct({ + value: Schema.String.check(Schema.makeFilter(() => { + checks++ + return false + })) + }) + })) + + throws(() => decode({ nested: { value: "invalid" } }), (error) => { + assertSchemaIssueError( + error, + `Expected + at ["nested"]["value"]` + ) + }) + strictEqual(checks, 2) + }) + + it("does not construct the interpreter for a compiled parser", () => { + const schema = Schema.Struct({ value: Schema.String }) + Object.defineProperty(schema.ast, "getParser", { + configurable: true, + value() { + throw new Error("interpreted parser constructed") + } + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({ value: "a", extra: true }), { value: "a" }) + throws(() => decode({ value: 1 }), (error) => { + assertSchemaIssueError( + error, + `Expected string + at ["value"]` + ) + }) + throws(() => decode({ value: "a", extra: true }, { onExcessProperty: "error" }), (error) => { + assertSchemaIssueError( + error, + `Expected no excess property + at ["extra"]` + ) + }) + }) + + it("does not construct the interpreter for transformations or middleware", () => { + let transformations = 0 + const transformed = Schema.String.pipe( + Schema.decodeTo( + Schema.String.check(Schema.isMinLength(2)), + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return value.trim() + }, + encode: (value) => value + }) + ) + ) + Object.defineProperty(transformed.ast, "getParser", { + configurable: true, + value() { + throw new Error("interpreted transformation parser constructed") + } + }) + const decodeTransformed = SchemaParser.decodeUnknownSync(transformed) + + strictEqual(decodeTransformed(" valid "), "valid") + strictEqual(transformations, 1) + throws(() => decodeTransformed(" x ")) + strictEqual(transformations, 2) + + let middlewareRuns = 0 + const middleware = Schema.Struct({ value: Schema.String }).pipe( + Schema.middlewareDecoding((effect) => { + middlewareRuns++ + return effect + }) + ) + Object.defineProperty(middleware.ast, "getParser", { + configurable: true, + value() { + throw new Error("interpreted middleware parser constructed") + } + }) + + deepStrictEqual(SchemaParser.decodeUnknownSync(middleware)({ value: "valid" }), { value: "valid" }) + strictEqual(middlewareRuns, 1) + }) + + it("does not construct the interpreter for Structs with transformed properties", () => { + const schema = Schema.Struct({ + first: Schema.FiniteFromString, + second: Schema.FiniteFromString + }) + Object.defineProperty(schema.ast, "getParser", { + configurable: true, + value() { + throw new Error("interpreted Struct parser constructed") + } + }) + + const decode = SchemaParser.decodeUnknownSync(schema) + for ( + const options of [ + undefined, + {}, + { errors: "first" }, + { onExcessProperty: "ignore" }, + { reportInput: true }, + { disableChecks: true } + ] as const + ) { + deepStrictEqual(decode({ first: "1", second: "2" }, options), { first: 1, second: 2 }) + } + }) + + it("applies Struct output and encoding checks after compiled fields without replay", () => { + let transformations = 0 + let checks = 0 + const schema = Schema.Struct({ + value: Schema.String.pipe(Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return Number(value) + }, + encode: String + }) + )) + }).check(Schema.makeFilter((output) => { + checks++ + deepStrictEqual(Object.keys(output), ["value"]) + return output.value > 0 + })).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => input.value !== "01")), + Schema.flip + ) + let interpreted = 0 + const getParser = schema.ast.getParser.bind(schema.ast) + Object.defineProperty(schema.ast, "getParser", { + value(...args: Parameters) { + interpreted++ + return getParser(...args) + } + }) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: "1", extra: true }), { value: 1 }) + throws(() => decode({ value: "-1", extra: true })) + strictEqual(transformations, 2) + strictEqual(checks, 2) + strictEqual(interpreted, 0) + + deepStrictEqual(decode({ value: "-1" }, { disableChecks: true }), { value: -1 }) + strictEqual(checks, 2) + throws(() => decode({ value: "-1" }, { errors: "all" })) + strictEqual(transformations, 4) + strictEqual(checks, 3) + strictEqual(interpreted, 1) + throws(() => decode({ value: "01" })) + strictEqual(transformations, 5) + strictEqual(checks, 3) + }) + + it("constructs the diagnostic phase lazily and once", () => { + const schema = Schema.TemplateLiteral(["a"]) + const ast = schema.ast + if (ast._tag !== "TemplateLiteral") throw new Error("Expected TemplateLiteral") + const asTemplateLiteralParser = ast.asTemplateLiteralParser.bind(ast) + let compilations = 0 + Object.defineProperty(ast, "asTemplateLiteralParser", { + configurable: true, + value() { + compilations++ + return asTemplateLiteralParser() + } + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + strictEqual(decode("a"), "a") + strictEqual(compilations, 0) + throws(() => decode("b")) + strictEqual(compilations, 1) + throws(() => decode("b")) + strictEqual(compilations, 1) + }) + + it("replaces the parser used by the other decoding adapters", () => { + let checks = 0 + const schema = Schema.String.check(Schema.makeFilter(() => { + checks++ + return false + })) + + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)("value"))) + strictEqual(checks, 2) + }) + + it("replaces the parser used by encoding adapters", () => { + let checks = 0 + const schema = Schema.String.check(Schema.makeFilter(() => { + checks++ + return false + })) + + assert(Result.isFailure(SchemaParser.encodeUnknownResult(schema)("value"))) + strictEqual(checks, 2) + }) + + it("does not replay defects", () => { + const defect = new Error("boom") + let reads = 0 + const input = { + get name(): string { + reads++ + throw defect + }, + count: 1, + active: true, + nested: { value: "b" } + } + + throws(() => decode(input), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Sync adapter can only throw schema issues") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + strictEqual(reads, 1) + }) + + it("honors explicit ParseOptions in the compiled diagnostic phase", () => { + throws(() => + decode({ + name: "a", + count: 1, + active: true, + nested: { value: "b", nestedExtra: true }, + extra: true + }, { onExcessProperty: "error", errors: "all" }), (error) => { + assertSchemaIssueError( + error, + `Expected no excess property + at ["extra"] +Expected no excess property + at ["nested"]["nestedExtra"]` + ) + }) + }) + + it("compiles symbol-keyed Struct properties", () => { + const key = Symbol("key") + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + text: Schema.String, + [key]: Schema.Number + })) + const output = decode({ text: "value", [key]: 1, extra: true }) + + strictEqual(output.text, "value") + strictEqual(output[key], 1) + deepStrictEqual(Reflect.ownKeys(output), ["text", key]) + }) + + it("uses the interpreter for unsupported schemas", () => { + const date = new Date(0) + const decode = SchemaParser.decodeUnknownSync(Schema.instanceOf(Date)) + strictEqual(decode(date), date) + }) + + it("uses the interpreter when dynamic function generation is unavailable", () => { + const Function = globalThis.Function + try { + globalThis.Function = (() => { + throw new Error("dynamic function generation unavailable") + }) as any + const schema = Schema.Struct({ value: Schema.String }) + const getParser = schema.ast.getParser.bind(schema.ast) + let interpreterConstructions = 0 + Object.defineProperty(schema.ast, "getParser", { + configurable: true, + value(...args: Parameters) { + interpreterConstructions++ + return getParser(...args) + } + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({ value: "a" }), { value: "a" }) + strictEqual(interpreterConstructions, 1) + throws(() => decode({ value: 1 }), (error) => { + assertSchemaIssueError( + error, + `Expected string + at ["value"]` + ) + }) + } finally { + globalThis.Function = Function + } + }) + + it("compiles primitive leaves without confusing undefined with a missing key", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + undefined: Schema.Undefined, + unknown: Schema.Unknown, + bigint: Schema.BigInt, + symbol: Schema.Symbol, + literal: Schema.Literal("a") + })) + const symbol = globalThis.Symbol("a") + + deepStrictEqual( + decode({ undefined, unknown: undefined, bigint: 1n, symbol, literal: "a", extra: true }), + { undefined, unknown: undefined, bigint: 1n, symbol, literal: "a" } + ) + throws(() => decode({ unknown: undefined, bigint: 1n, symbol, literal: "a" }), (error) => { + assertSchemaIssueError(error, `Missing key\n at ["undefined"]`) + }) + }) + + it("compiles arrays and tuples with rest and tail elements", () => { + const decodeArray = SchemaParser.decodeUnknownSync(Schema.Array(Schema.String)) + deepStrictEqual(decodeArray(["a", "b"]), ["a", "b"]) + + const decodeTuple = SchemaParser.decodeUnknownSync( + Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) + ) + deepStrictEqual(decodeTuple(["a", 1, 2, true]), ["a", 1, 2, true]) + throws(() => decodeTuple(["a", 1, 2]), (error) => { + assertSchemaIssueError(error, `Expected boolean\n at [2]`) + }) + }) + + it("compiles optional tuple elements", () => { + const schema = Schema.Tuple([Schema.optionalKey(Schema.String)]) + const decode = SchemaParser.decodeUnknownSync(schema) + const is = SchemaParser.is(schema) + + deepStrictEqual(decode([]), []) + deepStrictEqual(decode(["a"]), ["a"]) + strictEqual(is([]), true) + strictEqual(is(["a"]), true) + strictEqual(is([1]), false) + }) + + it("preserves the input sign for signed-zero literals", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + negative: Schema.Literal(-0), + positive: Schema.Union([Schema.Literal(0), Schema.Literal(1)]), + union: Schema.Union([Schema.Literal(-0), Schema.Literal(1)]) + })) + const output = decode({ negative: 0, positive: -0, union: 0 }) + + strictEqual(Object.is(output.negative, 0), true) + strictEqual(Object.is(output.positive, -0), true) + strictEqual(Object.is(output.union, 0), true) + }) + + it("compiles primitive anyOf and oneOf unions", () => { + const decodeAnyOf = SchemaParser.decodeUnknownSync( + Schema.Union([Schema.Literal("a"), Schema.Literal("b"), Schema.Number]) + ) + strictEqual(decodeAnyOf("b"), "b") + strictEqual(decodeAnyOf(1), 1) + throws(() => decodeAnyOf(true), (error) => { + assertSchemaIssueError(error, "Expected \"a\" | \"b\" | number") + }) + + const decodeOneOf = SchemaParser.decodeUnknownSync( + Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }) + ) + strictEqual(decodeOneOf("b"), "b") + throws(() => decodeOneOf("a"), (error) => { + assertSchemaIssueError(error, "Expected exactly one member to match") + }) + }) + + it("compiles structural unions through canonical candidate selection", () => { + const decodeTagged = SchemaParser.decodeUnknownSync(Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("b"), value: Schema.Number }) + ])) + deepStrictEqual( + decodeTagged({ kind: "b", value: 1, extra: true }), + { kind: "b", value: 1 } + ) + + const decodeUntagged = SchemaParser.decodeUnknownSync(Schema.Union([ + Schema.Struct({ first: Schema.String }), + Schema.Struct({ second: Schema.String }) + ])) + deepStrictEqual( + decodeUntagged({ first: "a", second: "b" }), + { first: "a" } + ) + + const decodeOneOf = SchemaParser.decodeUnknownSync(Schema.Union([ + Schema.Struct({ first: Schema.String }), + Schema.Struct({ second: Schema.String }) + ], { mode: "oneOf" })) + throws(() => decodeOneOf({ first: "a", second: "b" }), (error) => { + assertSchemaIssueError(error, "Expected exactly one member to match") + }) + }) + + it("compiles optional object properties", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + required: Schema.String, + optional: Schema.optionalKey(Schema.Number), + undefined: Schema.optionalKey(Schema.Undefined) + })) + + deepStrictEqual(decode({ required: "a" }), { required: "a" }) + deepStrictEqual( + decode({ required: "a", optional: 1, undefined, extra: true }), + { required: "a", optional: 1, undefined } + ) + throws(() => decode({ required: "a", optional: "invalid" }), (error) => { + assertSchemaIssueError(error, `Expected number\n at ["optional"]`) + }) + }) + + it("compiles string records", () => { + const decode = SchemaParser.decodeUnknownSync( + Schema.Record(Schema.String, Schema.Struct({ count: Schema.Number })) + ) + deepStrictEqual( + decode({ a: { count: 1 }, b: { count: 2 }, extra: { count: 3, ignored: true } }), + { a: { count: 1 }, b: { count: 2 }, extra: { count: 3 } } + ) + throws(() => decode({ a: { count: "invalid" } }), (error) => { + assertSchemaIssueError(error, `Expected number\n at ["a"]["count"]`) + }) + }) + + it("compiles symbol and template-literal records", () => { + const symbol = Symbol("key") + const decodeSymbols = SchemaParser.decodeUnknownSync(Schema.Record(Schema.Symbol, Schema.Number)) + const symbolOutput = decodeSymbols({ text: "ignored", [symbol]: 1 }) + strictEqual(symbolOutput[symbol], 1) + deepStrictEqual(Reflect.ownKeys(symbolOutput), [symbol]) + + const decodeTemplates = SchemaParser.decodeUnknownSync( + Schema.Record(Schema.TemplateLiteral(["data-", Schema.String]), Schema.Number) + ) + deepStrictEqual( + decodeTemplates({ "data-a": 1, ignored: 2, "data-b": 3 }), + { "data-a": 1, "data-b": 3 } + ) + }) + + it("compiles fixed properties with index signatures", () => { + const decode = SchemaParser.decodeUnknownSync( + Schema.StructWithRest( + Schema.Struct({ fixed: Schema.Trim }), + [Schema.Record(Schema.String, Schema.String)] + ) + ) + deepStrictEqual(decode({ fixed: " value ", other: "other" }), { fixed: "value", other: "other" }) + }) + + it("compiles decoded index keys", () => { + const decodeNumbers = SchemaParser.decodeUnknownSync(Schema.Record(Schema.Number, Schema.Number)) + deepStrictEqual(decodeNumbers({ 1: 1, other: "ignored" }), { 1: 1 }) + + const decodeCamelCase = SchemaParser.decodeUnknownSync( + Schema.Record( + Schema.String.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.toUpperCase())), + Schema.Number + ) + ) + deepStrictEqual(decodeCamelCase({ a: 1, b: 2 }), { A: 1, B: 2 }) + }) + + it("compiles encoding checks", () => { + const checked = Schema.Struct({ value: Schema.String }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => input.value.length > 1)), + Schema.flip + ) + const decode = SchemaParser.decodeUnknownSync(checked) + deepStrictEqual(decode({ value: "valid" }), { value: "valid" }) + throws(() => decode({ value: "" })) + }) + + it("runs checks against decoded output", () => { + const decodeString = SchemaParser.decodeUnknownSync( + Schema.String.check(Schema.isMinLength(2)) + ) + strictEqual(decodeString("ab"), "ab") + throws(() => decodeString("a"), (error) => { + assertSchemaIssueError(error, "Expected a value with a length of at least 2") + }) + + const decodeObject = SchemaParser.decodeUnknownSync( + Schema.Struct({ value: Schema.String }).check(Schema.isMaxProperties(1)) + ) + deepStrictEqual(decodeObject({ value: "a", extra: true }), { value: "a" }) + }) + + it("runs compiled type guard checks against decoded output", () => { + const checked = Schema.Struct({ value: Schema.String }).check(Schema.isMaxProperties(1)) + const isRoot = SchemaParser.is(checked) + const isNested = SchemaParser.is(Schema.Struct({ nested: checked })) + + strictEqual(isRoot({ value: "a", extra: true }), true) + strictEqual(isNested({ nested: { value: "a", extra: true }, extra: true }), true) + strictEqual(isNested({ nested: { value: 1 } }), false) + }) + + it("uses compiled checkpoints around interpreted declarations and transformations", () => { + const date = new Date(0) + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + date: Schema.instanceOf(Date), + count: Schema.FiniteFromString + })) + + const output = decode({ date, count: "1", extra: true }) + strictEqual(output.date, date) + strictEqual(output.count, 1) + deepStrictEqual(Reflect.ownKeys(output), ["date", "count"]) + }) + + it("uses compiled checkpoints inside root encoding chains", () => { + const decodeNumber = SchemaParser.decodeUnknownSync(Schema.FiniteFromString) + strictEqual(decodeNumber("1"), 1) + throws(() => decodeNumber("invalid"), (error) => { + assertSchemaIssueError(error, "Expected a finite number") + }) + + const decodeJson = SchemaParser.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ value: Schema.Number })) + ) + deepStrictEqual(decodeJson("{\"value\":1,\"extra\":true}"), { value: 1 }) + }) + + it("does not replay transformations when a compiled checkpoint fails", () => { + let sourceChecks = 0 + let firstTransformations = 0 + let secondTransformations = 0 + let checks = 0 + const schema = Schema.Struct({ + value: Schema.String.check(Schema.makeFilter((value) => { + sourceChecks++ + return value !== "blocked" + })).pipe( + Schema.decodeTo( + Schema.Number.check(Schema.makeFilter((value) => { + checks++ + return value > 0 + })), + SchemaTransformation.transform({ + decode: (value) => { + firstTransformations++ + return Number(value) + }, + encode: String + }) + ), + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (value) => { + secondTransformations++ + return String(value) + }, + encode: Number + }) + ) + ) + }) + + throws(() => SchemaParser.decodeUnknownSync(schema)({ value: "blocked" })) + strictEqual(sourceChecks, 2) + strictEqual(firstTransformations, 0) + strictEqual(secondTransformations, 0) + + sourceChecks = 0 + throws(() => SchemaParser.decodeUnknownSync(schema)({ value: "-1" })) + strictEqual(sourceChecks, 1) + strictEqual(firstTransformations, 1) + strictEqual(secondTransformations, 0) + strictEqual(checks, 2) + }) + + it("preserves mixed causes from interpreted encoding chains", () => { + const cause = Cause.combine( + Cause.fail(new SchemaIssue.InvalidValue({ message: "schema issue" })), + Cause.die(new Error("defect")) + ) + const schema = Schema.String.pipe(Schema.decode({ + decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + encode: SchemaGetter.passthrough() + })) + + throws(() => SchemaParser.decodeUnknownSync(schema)("value"), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Sync adapter can only throw schema issues") + const issue = Cause.findError(error.cause as Cause.Cause) + assert(Result.isSuccess(issue)) + strictEqual(issue.success._tag, "Encoding") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + }) + + it("keeps Suspend parsers lazy", () => { + interface Category { + readonly value: string + readonly children: ReadonlyArray + } + let evaluations = 0 + const schema: Schema.Codec = Schema.Struct({ + value: Schema.String, + children: Schema.Array(Schema.suspend((): Schema.Codec => { + evaluations++ + return schema + })) + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({ value: "root", children: [] }), { value: "root", children: [] }) + strictEqual(evaluations, 0) + deepStrictEqual( + decode({ value: "root", children: [{ value: "child", children: [] }] }), + { value: "root", children: [{ value: "child", children: [] }] } + ) + strictEqual(evaluations, 1) + }) + + it("does not replay declaration defects", () => { + const defect = new Error("declaration defect") + let runs = 0 + const declaration = Schema.declareConstructor()([], () => () => { + runs++ + return Effect.die(defect) + }) + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ declaration })) + + throws(() => decode({ declaration: "value" }), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Sync adapter can only throw schema issues") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + strictEqual(runs, 1) + }) +}) diff --git a/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts b/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts new file mode 100644 index 00000000000..90ac0040216 --- /dev/null +++ b/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts @@ -0,0 +1,176 @@ +import { assert, describe, it, vi } from "@effect/vitest" +import { Cause, Schema, SchemaParser, SchemaTransformation } from "effect" +import * as Codegen from "effect/internal/schema/codegen" +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import { assertSchemaIssueError, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +describe("Schema JIT compilation fallback", () => { + for (const phase of ["construction", "factory"] as const) { + for (const firstOperation of ["decode", "is"] as const) { + it(`recovers ${phase} failure during ${firstOperation} without retrying`, () => { + const schema = Schema.Struct({ value: Schema.String }) + const getParser = vi.spyOn(schema.ast, "getParser") + const Function = globalThis.Function + let attempts = 0 + try { + globalThis.Function = ((...args: Array) => { + if (args.length === 1 && args[0] === "return true") return Function(...args) + attempts++ + if (phase === "construction") throw new SyntaxError("invalid generated source") + return () => { + throw new Error("generated factory failed") + } + }) as FunctionConstructor + + const decode = SchemaParser.decodeUnknownSync(schema) + const is = SchemaParser.is(schema) + const input = { value: "valid", extra: true } + if (firstOperation === "is") strictEqual(is(input), true) + deepStrictEqual(decode(input), { value: "valid" }) + strictEqual(is(input), true) + strictEqual(is({ value: 1 }), false) + throws(() => decode({ value: 1 }), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["value"]`) + }) + throws(() => decode(input, { onExcessProperty: "error" }), (error) => { + assertSchemaIssueError(error, `Expected no excess property\n at ["extra"]`) + }) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), { value: "valid" }) + strictEqual(attempts, 1) + strictEqual(getParser.mock.calls.length, 1) + } finally { + globalThis.Function = Function + getParser.mockRestore() + } + }) + } + } + + for (const phase of ["select", "emitValidate"] as const) { + it(`recovers errors in ${phase} without disabling other schemas`, () => { + const failure = vi.spyOn(Codegen, phase).mockImplementationOnce(() => { + throw new Error("compiler failed") + }) + const schema = Schema.Struct({ value: Schema.String }) + const getParser = vi.spyOn(schema.ast, "getParser") + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: "valid" }), { value: "valid" }) + strictEqual(getParser.mock.calls.length, 1) + const attempts = failure.mock.calls.length + deepStrictEqual(decode({ value: "again" }), { value: "again" }) + strictEqual(failure.mock.calls.length, attempts) + + const other = Schema.Struct({ value: Schema.String }) + const otherParser = vi.spyOn(other.ast, "getParser") + try { + deepStrictEqual(SchemaParser.decodeUnknownSync(other)({ value: "compiled" }), { value: "compiled" }) + strictEqual(otherParser.mock.calls.length, 0) + } finally { + otherParser.mockRestore() + } + } finally { + failure.mockRestore() + getParser.mockRestore() + } + }) + } + + it("recovers composed decoder generation without repeating transformations", () => { + let transformations = 0 + const field = Schema.String.pipe(Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return value.trim() + }, + encode: (value) => value + }) + )) + const schema = Schema.Struct({ value: field }) + const failure = vi.spyOn(Codegen, "emitComposedObject").mockImplementation(() => { + throw new Error("composed decoder generation failed") + }) + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: " a " }), { value: "a" }) + deepStrictEqual(decode({ value: " b " }), { value: "b" }) + strictEqual(transformations, 2) + strictEqual(failure.mock.calls.length, 1) + } finally { + failure.mockRestore() + } + }) + + it("recovers a lazy local checkpoint without replaying its encoding or middleware", () => { + let transformations = 0 + let middlewareRuns = 0 + const schema = Schema.String.pipe( + Schema.decodeTo( + Schema.Struct({ value: Schema.String.check(Schema.isMinLength(2)) }), + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return { value: value.trim() } + }, + encode: (value) => value.value + }) + ), + Schema.middlewareDecoding((effect) => { + middlewareRuns++ + return effect + }) + ) + const emit = Codegen.emitValidate + let attempts = 0 + let transformationsAtFailure = 0 + const failure = vi.spyOn(Codegen, "emitValidate").mockImplementation((ast) => { + if (ast === schema.ast) { + attempts++ + transformationsAtFailure = transformations + throw new Error("local checkpoint generation failed") + } + return emit(ast) + }) + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(" valid "), { value: "valid" }) + strictEqual(transformationsAtFailure, 1) + strictEqual(transformations, 1) + strictEqual(middlewareRuns, 1) + throws(() => decode(" x ")) + strictEqual(transformations, 2) + strictEqual(middlewareRuns, 2) + strictEqual(attempts, 1) + } finally { + failure.mockRestore() + } + }) + + it("propagates errors from executing generated code without interpreting the input", () => { + const schema = Schema.Struct({ value: Schema.String }) + const getParser = vi.spyOn(schema.ast, "getParser") + const Function = globalThis.Function + let executions = 0 + const defect = new Error("generated parser failed") + try { + globalThis.Function = ((...args: Array) => { + if (args.length === 1 && args[0] === "return true") return Function(...args) + return () => () => { + executions++ + throw defect + } + }) as FunctionConstructor + const result = SchemaParser.decodeUnknownExit(schema)({ value: "valid" }) + assert(result._tag === "Failure") + assert(Cause.hasDies(result.cause)) + strictEqual(executions, 1) + strictEqual(getParser.mock.calls.length, 0) + } finally { + globalThis.Function = Function + getParser.mockRestore() + } + }) +}) diff --git a/packages/effect/test/schema/SchemaOptionsRegression.test.ts b/packages/effect/test/schema/SchemaOptionsRegression.test.ts new file mode 100644 index 00000000000..0d095ffff7b --- /dev/null +++ b/packages/effect/test/schema/SchemaOptionsRegression.test.ts @@ -0,0 +1,197 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Result, Schema, SchemaParser, SchemaRepresentation } from "effect" +import { SchemaJITCompiler } from "effect/unstable/schema" +import { deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +for (const compiled of [false, true]) { + describe(compiled ? "compiled object options" : "interpreted object options", () => { + const prepare = (schema: S): S => { + if (compiled) SchemaJITCompiler.enable(schema.ast) + return schema + } + + it("combines fixed, string-pattern and symbol index coverage", () => { + const schema = prepare(Schema.StructWithRest(Schema.Struct({ fixed: Schema.Boolean }), [ + Schema.Record(Schema.TemplateLiteral(["s-", Schema.String]), Schema.String), + Schema.Record(Schema.Symbol, Schema.Number) + ])) + const symbol = Symbol() + const value = { fixed: true, "s-a": "a", [symbol]: 1 } + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(value, { onExcessProperty: "error" }), value) + strictEqual(SchemaParser.is(schema)(value), true) + strictEqual(SchemaParser.is(schema)({ ...value, extra: 1 }), true) + assert(Result.isFailure( + SchemaParser.decodeUnknownResult(schema)({ ...value, extra: 1 }, { + onExcessProperty: "error" + }) + )) + const result = SchemaParser.decodeUnknownResult(schema)({ fixed: true, "s-a": 1 }, { + onExcessProperty: "error", + errors: "all", + reportInput: true + }) + assert(Result.isFailure(result)) + assert(result.failure._tag === "Composite") + const issue = result.failure.issues[0] + assert(issue._tag === "Pointer") + strictEqual(issue.issue._tag, "InvalidType") + }) + + it("validates all overlapping indexes", () => { + const schema = prepare(Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String, Schema.Number), + Schema.Record(Schema.TemplateLiteral(["n-", Schema.String]), Schema.Number.check(Schema.isGreaterThan(0))) + ])) + strictEqual(SchemaParser.is(schema)({ "n-a": -1 }), false) + const decode = SchemaParser.decodeUnknownResult(schema, { onExcessProperty: "error" }) + assert(Result.isFailure(decode({ "n-a": -1 }))) + assert(Result.isSuccess(decode({ "n-a": -1 }, { disableChecks: true }))) + assert(Result.isSuccess(decode({ other: 1 }))) + }) + + it("recognizes numeric fixed keys with and without index signatures", () => { + const symbol = Symbol() + for (const key of [Schema.Literal(1), Schema.Union([Schema.Literal(1), Schema.Symbol])]) { + const schema = prepare(Schema.Record(key, Schema.String)) + const options = { onExcessProperty: "error", errors: "all" } as const + const input = { 1: "one" } + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input, options), input) + deepStrictEqual(SchemaParser.encodeUnknownSync(schema)(input, options), input) + strictEqual(SchemaParser.is(schema)(input), true) + + const result = SchemaParser.decodeUnknownResult(schema)({ 1: 1 }, options) + assert(Result.isFailure(result)) + assert(result.failure._tag === "Composite") + strictEqual(result.failure.issues.length, 1) + const issue = result.failure.issues[0] + assert(issue._tag === "Pointer") + deepStrictEqual(issue.path, [1]) + strictEqual(issue.issue._tag, "InvalidType") + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)({ ...input, extra: "extra" }, options))) + } + const schema = prepare(Schema.Record(Schema.Union([Schema.Literal(1), Schema.Symbol]), Schema.String)) + const input = { 1: "one", [symbol]: "symbol" } + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input, { onExcessProperty: "error" }), input) + }) + + it("keeps numeric fixed-field output when an index signature also selects the key", () => { + const schema = prepare(Schema.StructWithRest( + Schema.Record(Schema.Literal(1), Schema.Struct({ a: Schema.String })), + [Schema.Record(Schema.String, Schema.Struct({ a: Schema.String, b: Schema.Number }))] + )) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ 1: { a: "a", b: 1 } }), { 1: { a: "a" } }) + }) + + it("passes stripped nested objects to checks", () => { + const child = Schema.Struct({ a: Schema.String, b: Schema.String }) + .check(Schema.makeFilter((value) => !Object.hasOwn(value, "extra"))) + const schema = prepare(Schema.Struct({ child, a: Schema.String })) + const input = { a: "a", child: { b: "b", a: "a", extra: true } } + const output = SchemaParser.decodeUnknownSync(schema)(input) + deepStrictEqual(output, { a: "a", child: { a: "a", b: "b" } }) + strictEqual(SchemaParser.is(schema)(input), true) + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)(input, { onExcessProperty: "error" }))) + deepStrictEqual(SchemaParser.encodeUnknownSync(schema)(output), output) + }) + + it("honors per-call excess-property overrides without changing the cached decoder", () => { + const schema = prepare(Schema.Struct({ a: Schema.String, b: Schema.String })) + const input = { b: "b", a: "a", extra: true } + const decode = SchemaParser.decodeUnknownSync(schema, { onExcessProperty: "error" }) + throws(() => decode(input)) + deepStrictEqual(decode(input, { onExcessProperty: "ignore" }), { a: "a", b: "b" }) + throws(() => decode(input)) + const defaultDecode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(defaultDecode(input), { a: "a", b: "b" }) + throws(() => defaultDecode(input, { onExcessProperty: "error" })) + deepStrictEqual(defaultDecode(input), { a: "a", b: "b" }) + }) + + it("decodes and encodes transformed fields inside arrays and unions", () => { + const child = Schema.Struct({ a: Schema.NumberFromString, b: Schema.String }) + const schema = prepare(Schema.Array(Schema.Union([child, Schema.Boolean]))) + const input = [{ b: "b", a: "1" }] + const output = SchemaParser.decodeUnknownSync(schema)(input) + deepStrictEqual(output, [{ b: "b", a: 1 }]) + const encoded = SchemaParser.encodeUnknownSync(schema)(output) + deepStrictEqual(encoded, input) + }) + + it("retains transformed record keys", () => { + const schema = prepare(Schema.Record(Schema.Trim, Schema.NumberFromString)) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ " a ": "1" }), { a: 1 }) + }) + + it("keeps the empty struct's non-nullish contract", () => { + const schema = prepare(Schema.Struct({})) + for (const input of [1, "a", false, [], {}]) { + strictEqual( + SchemaParser.decodeUnknownSync(schema)(input, { + onExcessProperty: "error" + }), + input + ) + } + }) + + it.effect("materializes unchanged results after an async transformation", () => + Effect.gen(function*() { + const schema = prepare( + Schema.String.pipe(Schema.middlewareDecoding((decode) => Effect.flatMap(Effect.yieldNow, () => decode))) + ) + strictEqual(yield* SchemaParser.decodeUnknownEffect(schema)("a"), "a") + const array = prepare(Schema.Array(schema)) + deepStrictEqual(yield* SchemaParser.decodeUnknownEffect(array)(["a", "b"]), ["a", "b"]) + })) + + it.effect("retains decoded fields after an asynchronous property", () => + Effect.gen(function*() { + const value = Schema.String.pipe( + Schema.middlewareDecoding((decode) => Effect.flatMap(Effect.yieldNow, () => decode)) + ) + const schema = prepare(Schema.Struct({ a: value, b: Schema.String })) + const output = yield* SchemaParser.decodeUnknownEffect(schema)({ b: "b", a: "a" }) + deepStrictEqual(output, { a: "a", b: "b" }) + })) + }) +} + +it("does not build detailed failures inside validate", () => { + const schema = Schema.Struct({ value: Schema.String }) + SchemaJITCompiler.enable(schema.ast) + let reads = 0 + const input = { + value: "a", + get extra() { + reads++ + return 1 + } + } + const result = SchemaParser.decodeUnknownResult(schema)(input, { onExcessProperty: "error", reportInput: true }) + assert(Result.isFailure(result)) + strictEqual(reads, 1) +}) + +it("round-trips options through the structural representation", () => { + const schema = Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.Number }) + ], { mode: "oneOf" }) + const document = SchemaRepresentation.toRepresentation(schema.ast) + const rebuilt = SchemaRepresentation.fromRepresentation(document, { revivers: [] }) + deepStrictEqual(SchemaRepresentation.toRepresentation(rebuilt.ast), document) +}) + +it("omits object order configuration and retains oneOf in generated schema source", () => { + const schemas = [ + Schema.Struct({ a: Schema.String }), + Schema.Record(Schema.String, Schema.Number), + Schema.Union([Schema.Literal("a"), Schema.Literal("a")], { mode: "oneOf" }) + ] + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.toRepresentations([schemas[0].ast, schemas[1].ast, schemas[2].ast]) + ) + strictEqual(document.codes[0].runtime, "Schema.Struct({ \"a\": Schema.String })") + strictEqual(document.codes[1].runtime, "Schema.Record(Schema.String, Schema.Number)") + strictEqual(document.codes[2].runtime.includes("mode: \"oneOf\""), true) +}) diff --git a/packages/effect/test/schema/SchemaParser.test.ts b/packages/effect/test/schema/SchemaParser.test.ts index 990a0649949..5ce5d73348b 100644 --- a/packages/effect/test/schema/SchemaParser.test.ts +++ b/packages/effect/test/schema/SchemaParser.test.ts @@ -1,8 +1,47 @@ import { describe, it } from "@effect/vitest" -import { Cause, Effect, Exit, Option, Result, Schema, SchemaGetter, SchemaIssue, SchemaParser } from "effect" -import { assertSchemaIssueError, assertTrue, strictEqual, throws } from "../utils/assert.ts" +import { Cause, Effect, Exit, Option, Result, Schema, SchemaAST, SchemaGetter, SchemaIssue, SchemaParser } from "effect" +import { assertSchemaIssueError, assertTrue, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" describe("SchemaParser", () => { + describe("sequential parsing", () => { + const cases = [ + { + name: "Struct", + make: (item: Schema.Codec) => Schema.Struct({ a: item, b: item }), + input: { a: "a", b: "b" } + }, + { name: "Tuple", make: (item: Schema.Codec) => Schema.Tuple([item, item]), input: ["a", "b"] }, + { name: "Array", make: (item: Schema.Codec) => Schema.Array(item), input: ["a", "b"] }, + { + name: "Record", + make: (item: Schema.Codec) => Schema.Record(Schema.String, item), + input: { a: "a", b: "b" } + } + ] + for (const { input, make, name } of cases) { + for (const errors of ["first", "all"] as const) { + it.effect(`${name} decodes and encodes asynchronous children sequentially (${errors})`, () => + Effect.gen(function*() { + const calls: Array = [] + const getter = SchemaGetter.transformOrFail((value) => { + calls.push(`start ${value}`) + return Effect.gen(function*() { + yield* Effect.yieldNow + calls.push(`end ${value}`) + return value + }) + }) + const schema = make(Schema.String.pipe(Schema.decode({ decode: getter, encode: getter }))) + deepStrictEqual(yield* SchemaParser.decodeUnknownEffect(schema)(input, { errors }), input) + deepStrictEqual(calls, ["start a", "end a", "start b", "end b"]) + calls.length = 0 + deepStrictEqual(yield* SchemaParser.encodeUnknownEffect(schema)(input, { errors }), input) + deepStrictEqual(calls, ["start a", "end a", "start b", "end b"]) + })) + } + } + }) + const makeMixedCause = () => Cause.combine( Cause.fail(new SchemaIssue.InvalidValue({ message: "schema issue" })), @@ -56,6 +95,100 @@ describe("SchemaParser", () => { }) describe("decodeUnknownSync / encodeUnknownSync", () => { + it("decodes inherited struct properties", () => { + const schema = Schema.Struct({ + required: Schema.String, + optional: Schema.optionalKey(Schema.String), + defaulted: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("default"))), + own: Schema.String + }) + const input = Object.assign( + Object.create({ + required: "required", + optional: "optional", + defaulted: "inherited" + }), + { own: "own" } + ) + + const output = SchemaParser.decodeUnknownSync(schema)(input) + + deepStrictEqual(output, { + required: "required", + optional: "optional", + defaulted: "inherited", + own: "own" + }) + for (const key of ["required", "optional", "defaulted", "own"]) { + assertTrue(Object.hasOwn(output, key)) + } + + const encoded = SchemaParser.encodeUnknownSync(Schema.Struct({ required: Schema.String }))( + Object.create({ required: "required" }) + ) + deepStrictEqual(encoded, { required: "required" }) + assertTrue(Object.hasOwn(encoded, "required")) + + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ required: "required", own: "own" }), { + required: "required", + defaulted: "default", + own: "own" + }) + }) + + it("distinguishes an inherited undefined property from a missing property", () => { + const schema = Schema.Struct({ + value: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("default"))) + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({}), { value: "default" }) + throws(() => decode(Object.create({ value: undefined })), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["value"]`) + }) + }) + + it("distinguishes required properties containing undefined from missing properties", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ value: Schema.String })) + + throws(() => decode({}), (error) => { + assertSchemaIssueError(error, `Missing key\n at ["value"]`) + }) + throws(() => decode({ value: undefined }), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["value"]`) + }) + throws(() => decode(Object.create({ value: undefined })), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["value"]`) + }) + }) + + it("selects union members using inherited discriminants", () => { + const schema = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("A"), value: Schema.String }), + Schema.Struct({ _tag: Schema.Literal("B"), value: Schema.Number }) + ]) + const input = Object.assign(Object.create({ _tag: "A" }), { value: "value" }) + + const output = SchemaParser.decodeUnknownSync(schema)(input) + + deepStrictEqual(output, { _tag: "A", value: "value" }) + assertTrue(Object.hasOwn(output, "_tag")) + }) + + it("decodes inherited Object.prototype properties", () => { + const output = SchemaParser.decodeUnknownSync(Schema.Struct({ toString: Schema.Unknown }))({}) + + strictEqual(output.toString, Object.prototype.toString) + assertTrue(Object.hasOwn(output, "toString")) + }) + + it("keeps dynamic record properties own-only", () => { + const input = Object.assign(Object.create({ inherited: 1 }), { own: 2 }) + const output = SchemaParser.decodeUnknownSync(Schema.Record(Schema.String, Schema.Number))(input) + + deepStrictEqual(output, { own: 2 }) + }) + it("should throw an error when the input is invalid", () => { const schema = Schema.String throws(() => SchemaParser.decodeUnknownSync(schema)(null), (e) => { @@ -185,6 +318,41 @@ describe("SchemaParser", () => { strictEqual(is(null), false) }) + it("should ignore excess properties", () => { + const schema = Schema.Struct({ value: Schema.String }) + const is = SchemaParser.is(schema) + + strictEqual(is({ value: "a" }), true) + strictEqual(is({ value: "a", extra: true }), true) + }) + + it("should pass default ParseOptions to checks", () => { + const schema = Schema.String.check( + Schema.makeFilter((_input, _ast, options) => { + strictEqual(options, SchemaAST.defaultParseOptions) + return true + }) + ) + + strictEqual(SchemaParser.is(schema)("a"), true) + }) + + it("should always run checks", () => { + const schema = Schema.String.check(Schema.isMinLength(2)) + + strictEqual(SchemaParser.is(schema)("a"), false) + strictEqual(SchemaParser.is(schema)("ab"), true) + }) + + it("should pass decoded output to object checks", () => { + const schema = Schema.Struct({ a: Schema.String, b: Schema.String }).check( + Schema.makeFilter((value) => !Object.hasOwn(value, "extra")) + ) + const input = { b: "b", a: "a", extra: true } + + strictEqual(SchemaParser.is(schema)(input), true) + }) + it("should throw an error when the cause is not an Issue", () => { const schema = Schema.declareConstructor()( [], @@ -488,7 +656,7 @@ describe("SchemaParser", () => { strictEqual(error.success._tag, "AnyOf") })) - it.effect("resolves an unchanged concurrent union candidate", () => + it.effect("resolves an unchanged union candidate after an asynchronous failure", () => Effect.gen(function*() { const delayedFailure = Schema.String.pipe(Schema.decode({ decode: new SchemaGetter.Getter(() => @@ -501,7 +669,7 @@ describe("SchemaParser", () => { const schema = Schema.Union([delayedFailure, Schema.String]) strictEqual( - yield* SchemaParser.decodeUnknownEffect(schema)("value", { concurrency: 2 }), + yield* SchemaParser.decodeUnknownEffect(schema)("value"), "value" ) })) @@ -528,6 +696,24 @@ describe("SchemaParser", () => { strictEqual(calls.join(","), "a,b,c") }) + it("does not replay eager elements after encountering a suspended transformation", () => { + const calls: Array = [] + const element = (name: string, suspended = false) => + Schema.String.pipe(Schema.decode({ + decode: new SchemaGetter.Getter((input) => { + calls.push(name) + return suspended ? Effect.suspend(() => Effect.succeed(input)) : Effect.succeed(input) + }), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Tuple([element("a"), element("b", true), element("c")]) + + const exit = SchemaParser.decodeUnknownExit(schema)(["a", "b", "c"]) + + assertTrue(Exit.isSuccess(exit)) + strictEqual(calls.join(","), "a,b,c") + }) + it("converts synchronous property parser throws into defects", () => { const field = Schema.declareConstructor()([], () => () => { throw new Error("property defect") @@ -716,5 +902,23 @@ describe("SchemaParser", () => { assertTrue(SchemaIssue.isIssue(error.success)) } }) + + it("captures synchronous defects during Union candidate selection", () => { + const schema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("b"), value: Schema.Number }) + ]) + const defect = new Error("sentinel defect") + const input = { + get kind(): string { + throw defect + }, + value: "value" + } + + const exit = SchemaParser.decodeUnknownExit(schema)(input) + assertTrue(Exit.isFailure(exit)) + assertTrue(Exit.hasDies(exit)) + }) }) }) diff --git a/packages/effect/test/schema/fixtures/aot-import-guard.ts b/packages/effect/test/schema/fixtures/aot-import-guard.ts new file mode 100644 index 00000000000..35365952321 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-import-guard.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import { registerHooks } from "node:module" + +registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context) + assert.doesNotMatch( + resolved.url, + /\/(?:internal\/schema\/(?:codegen|jitCompiler)|unstable\/schema\/Schema(?:AOT|JIT)Compiler)(?:\.|\/)/, + "Generated modules must not load source generation or the JIT compiler" + ) + return resolved + } +}) diff --git a/packages/effect/test/schema/fixtures/aot-runner.ts b/packages/effect/test/schema/fixtures/aot-runner.ts new file mode 100644 index 00000000000..b2e214eee45 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-runner.ts @@ -0,0 +1,177 @@ +import { Effect, Result, type SchemaAST, SchemaParser } from "effect" +import type * as CompilerRegistryModule from "effect/internal/schema/compilerRegistry" +import assert from "node:assert/strict" +import { join } from "node:path" +import { pathToFileURL } from "node:url" +import { asyncFixture, events, lazy, proof, roots, schemas, suspendEvaluations, synchronous } from "./aot.ts" +import { + Constructed, + constructionCases, + constructionEvents, + constructionOptions, + constructionSchemas +} from "./construction.ts" + +const CompilerRegistry: typeof CompilerRegistryModule = await import( + new URL("../../../src/internal/schema/compilerRegistry.ts", import.meta.url).href +) + +const options: ReadonlyArray = [ + undefined, + { errors: "all", reportInput: true }, + { onExcessProperty: "error" }, + { disableChecks: true } +] + +const snapshot = () => + Object.fromEntries( + Object.entries(synchronous).map(([name, { inputs, schema }]) => { + const is = SchemaParser.is(schema) + const results = options.map((option) => { + const decode = SchemaParser.decodeUnknownResult(schema, option) + return inputs.map((input) => { + events.length = 0 + const result = decode(input) + const calls = [...events] + return { + // Template diagnostics construct internal ASTs with fresh functions. + result: name === "templateLiteral" || name === "templateLiteralParser" + ? Result.mapError(result, (issue) => JSON.stringify(issue)) + : result, + calls, + is: is(input) + } + }) + }) + return [name, results] + }) + ) + +const snapshotAsync = async () => { + const results = [] + const decode = SchemaParser.decodeUnknownEffect(asyncFixture.schema) + for (const input of asyncFixture.inputs) { + events.length = 0 + const result = await Effect.runPromise(Effect.result(decode(input))) + results.push({ result, calls: [...events] }) + } + return results +} + +assert.throws(() => new Function("return true"), EvalError) +const interpreted = snapshot() +const interpretedAsync = await snapshotAsync() +const snapshotConstruction = async () => { + const out = [] + for (const fixture of Object.values(constructionCases)) { + const make = SchemaParser.makeEffect(fixture.schema) + for (const parseOptions of constructionOptions) { + for (const input of fixture.inputs) { + constructionEvents.length = 0 + const result = await Effect.runPromise(Effect.result(make(input as never, { parseOptions }))) + out.push({ result, events: [...constructionEvents] }) + } + } + } + return out +} +const interpretedConstruction = await snapshotConstruction() +assert.equal(suspendEvaluations, 0) + +Object.defineProperty(proof.ast, "getParser", { + value() { + throw new Error("The generated decoder must not construct the root interpreter") + } +}) + +const before = CompilerRegistry.resolve(schemas.struct.ast) +const empty = await import(pathToFileURL(join(process.argv[2], "empty.mjs")).href) +assert.equal(empty.install([]), undefined) +assert.equal(CompilerRegistry.resolve(schemas.struct.ast), before) + +if (process.argv[3] === "multiple") { + const generated = await import(pathToFileURL(join(process.argv[2], "all.mjs")).href) + assert.equal(CompilerRegistry.resolve(schemas.struct.ast), before) + assert.equal(generated.install(roots), undefined) +} else { + for (const [name, schema] of Object.entries(schemas)) { + const before = name === "struct" ? CompilerRegistry.resolve(schema.ast) : undefined + const generated = await import(pathToFileURL(join(process.argv[2], `${name}.mjs`)).href) + if (before !== undefined) assert.equal(CompilerRegistry.resolve(schema.ast), before) + assert.equal(generated.install([schema.ast]), undefined) + } +} +assert.equal(suspendEvaluations, 0) + +for ( + const name of [ + "struct", + "array", + "tuple", + "tagged", + "sentinel", + "sentinelLookup", + "record", + "transformed", + "transformedStruct", + "checkedTransformedStruct", + "encodingCheckedTransformedStruct", + "asynchronous", + "middleware" + ] +) { + assert.equal(CompilerRegistry.resolve(schemas[name].ast).origin, "installed", name) +} + +assert.deepEqual(snapshot(), interpreted) +assert.deepEqual(await snapshotAsync(), interpretedAsync) +for (const [name, schema] of Object.entries(constructionSchemas)) { + if (name === "construct-declaration") continue + assert.equal(CompilerRegistry.resolve(schema.ast).origin, "installed", name) + Object.defineProperty(schema.ast, "getParser", { + configurable: true, + value() { + throw new Error(`Interpreted construction: ${name}`) + } + }) +} +assert.deepEqual(await snapshotConstruction(), interpretedConstruction) +const instance = Constructed.make({}) +constructionEvents.length = 0 +assert.equal(Constructed.make(instance), instance) +assert.equal(await Effect.runPromise(Constructed.makeEffect(instance)), instance) +assert.deepEqual(constructionEvents, []) + +const transform = SchemaParser.decodeUnknownResult(schemas.transformed) +events.length = 0 +assert.deepEqual(transform("2"), Result.succeed(2)) +assert.deepEqual(events, ["transform"]) +events.length = 0 +assert.ok(Result.isFailure(transform("-1"))) +assert.deepEqual(events, ["transform"]) + +events.length = 0 +assert.deepEqual(SchemaParser.decodeUnknownResult(schemas.middleware)("-1"), Result.succeed(1)) +assert.deepEqual(events, ["transform", "middleware", "recover"]) + +assert.deepEqual(SchemaParser.decodeUnknownSync(proof)({ value: "a", extra: true }), { value: "a" }) +assert.deepEqual(SchemaParser.make(proof)({ value: "constructed" }), { value: "constructed" }) +assert.ok(Result.isFailure(SchemaParser.decodeUnknownResult(proof)({ value: 1 }))) +assert.equal(SchemaParser.is(proof)({ value: "a" }), true) +assert.equal(SchemaParser.is(proof)({ value: 1 }), false) +assert.deepEqual(SchemaParser.decodeUnknownSync(schemas.proofArray)([{ value: "a", extra: true }]), [{ value: "a" }]) +assert.ok(Result.isFailure(SchemaParser.decodeUnknownResult(schemas.proofArray)([{ value: 1 }]))) +let invalidReads = 0 +assert.ok(Result.isFailure( + SchemaParser.decodeUnknownResult(schemas.proofArray)([{ + get value() { + invalidReads++ + return 1 + } + }]) +)) +assert.equal(invalidReads, 2) + +assert.deepEqual(SchemaParser.decodeUnknownSync(lazy)({ value: "lazy" }), { value: "lazy" }) +assert.equal(suspendEvaluations, 1) +process.stdout.write("AOT integration passed\n") diff --git a/packages/effect/test/schema/fixtures/aot.ts b/packages/effect/test/schema/fixtures/aot.ts new file mode 100644 index 00000000000..d2255030600 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot.ts @@ -0,0 +1,195 @@ +import { Effect, Option, Schema, SchemaGetter, SchemaTransformation } from "effect" +import { invalid } from "effect/unstable/schema/SchemaCompiler" +import { constructionSchemas } from "./construction.ts" + +export const key = Symbol("key") +export const token = Symbol("token") +export const events: Array = [] + +const transformed = Schema.String.pipe( + Schema.decodeTo( + Schema.Number.check(Schema.isGreaterThan(0)), + SchemaTransformation.transform({ + decode: (input) => { + events.push("transform") + return Number(input) + }, + encode: String + }) + ) +) + +const middleware = transformed.pipe( + Schema.middlewareDecoding((effect) => { + events.push("middleware") + return Effect.catchEager(effect, () => { + events.push("recover") + return Effect.succeed(Option.some(1)) + }) + }) +) + +const asynchronous = Schema.String.pipe( + Schema.decodeTo(Schema.Number.check(Schema.isGreaterThan(0)), { + decode: new SchemaGetter.Getter((input) => { + events.push("async") + return Effect.yieldNow.pipe(Effect.as(Option.map(input, Number))) + }), + encode: SchemaGetter.transform(String) + }) +) + +interface Fixture { + readonly schema: Schema.ConstraintDecoder + readonly inputs: ReadonlyArray +} + +export const synchronous = { + struct: { + schema: Schema.Struct({ + name: Schema.String, + nested: Schema.Struct({ count: Schema.Number.check(Schema.isGreaterThan(0)) }), + optional: Schema.optionalKey(Schema.String) + }).check(Schema.makeFilter((input) => Object.keys(input).length <= 3)), + inputs: [ + { name: "a", nested: { count: 1, ignored: true }, extra: true }, + { name: "a", nested: { count: -1 } }, + { name: 1, nested: { count: "invalid" } }, + { name: "a" } + ] + }, + array: { + schema: Schema.Array(Schema.Struct({ value: Schema.String })), + inputs: [[{ value: "a", extra: true }], [{ value: 1 }, {}], null] + }, + tuple: { + schema: Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]), + inputs: [["a", 1, 2, true], ["a", true], ["a", "invalid", false], ["a"]] + }, + tagged: { + schema: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("b"), value: Schema.Number }) + ]), + inputs: [{ kind: "b", value: 1 }, { kind: "b", value: "invalid" }, { kind: "c" }] + }, + literals: { + schema: Schema.Literals(["a", "b", 0, 1, 2, 3, 4, 5, 6, 7, 8]), + inputs: ["a", -0, 8, 9, null] + }, + oneOf: { + schema: Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }), + inputs: ["b", "a", false] + }, + sentinel: { + schema: Schema.Union([Schema.Symbol, Schema.String]), + inputs: [invalid] + }, + sentinelLookup: { + schema: Schema.Union([Schema.UniqueSymbol(invalid), Schema.Literal("valid")]), + inputs: [invalid] + }, + symbols: { + schema: Schema.Struct({ [key]: Schema.UniqueSymbol(token) }), + inputs: [{ [key]: token }, { [key]: key }, {}] + }, + enumeration: { + schema: Schema.Enum({ a: "a", b: "b", c: 0, d: 1, e: 2, f: 3, g: 4, h: 5, i: 6 }), + inputs: ["a", -0, 6, "invalid"] + }, + record: { + schema: Schema.Record(Schema.String, Schema.Struct({ count: Schema.Number })), + inputs: [{ a: { count: 1 } }, { a: { count: "invalid" }, b: {} }, {}] + }, + mixedRecord: { + schema: Schema.StructWithRest( + Schema.Struct({ fixed: Schema.Number }), + [ + Schema.Record(Schema.TemplateLiteral(["data-", Schema.String]), Schema.Number), + Schema.Record(Schema.Symbol, Schema.Number) + ] + ), + inputs: [ + { fixed: 1, "data-a": 2, [key]: 3 }, + { fixed: 1, ignored: true }, + { fixed: 1, [key]: "invalid" } + ] + }, + numericRecord: { + schema: Schema.Record(Schema.Union([Schema.Literal(1), Schema.Symbol]), Schema.String), + inputs: [{ 1: "one", [key]: "symbol" }, { 1: "one", extra: true }, { 1: 1 }] + }, + templateLiteral: { + schema: Schema.TemplateLiteral(["count:", Schema.Int.check(Schema.isGreaterThan(0))]), + inputs: ["count:1", "count:0", "count:1.5", "invalid", null] + }, + templateLiteralParser: { + schema: Schema.TemplateLiteralParser(["bit:", Schema.BooleanFromBit]), + inputs: ["bit:1", "bit:0", "bit:true", null] + }, + transformed: { schema: transformed, inputs: ["2", "-1", false] }, + checkedTransformedStruct: { + schema: Schema.Struct({ value: transformed }).check(Schema.makeFilter((output) => { + events.push("struct check") + return output.value < 10 && Object.keys(output).length === 1 + })), + inputs: [{ value: "2", extra: true }, { value: "12" }, { value: "-1" }, {}] + }, + encodingCheckedTransformedStruct: { + schema: Schema.Struct({ value: transformed }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => { + events.push("encoding check") + return input.value !== "02" + })), + Schema.flip + ), + inputs: [{ value: "2" }, { value: "02" }, { value: "-1" }] + }, + transformedStruct: { + schema: Schema.Struct({ before: Schema.String, value: transformed, after: Schema.Boolean }), + inputs: [ + { before: "a", value: "2", after: true }, + { before: "a", value: "-1", after: true }, + { before: "a", value: "2", after: "invalid" } + ] + }, + middleware: { + schema: middleware, + inputs: ["2", "-1", false] + } +} satisfies Record + +export const asyncFixture = { + schema: Schema.Struct({ before: Schema.String, value: asynchronous, after: Schema.Boolean }).check( + Schema.makeFilter((output) => { + events.push("async struct check") + return output.value < 10 + }) + ), + inputs: [ + { before: "a", value: "2", after: true }, + { before: "a", value: "12", after: true }, + { before: "a", value: "-1", after: true }, + { before: "a", value: "2", after: "invalid" } + ] +} + +export let suspendEvaluations = 0 +export const lazy = Schema.suspend(() => { + suspendEvaluations++ + return Schema.Struct({ value: Schema.String }) +}) + +export const proof = Schema.Struct({ value: Schema.String }) + +export const schemas: Readonly>> = { + ...constructionSchemas, + ...Object.fromEntries(Object.entries(synchronous).map(([name, fixture]) => [name, fixture.schema])), + asynchronous: asyncFixture.schema, + lazy, + proofArray: Schema.Array(proof), + proof +} + +export const roots = [...Object.values(schemas).map((schema) => schema.ast), proof.ast] diff --git a/packages/effect/test/schema/fixtures/construction.ts b/packages/effect/test/schema/fixtures/construction.ts new file mode 100644 index 00000000000..395b30a0e7b --- /dev/null +++ b/packages/effect/test/schema/fixtures/construction.ts @@ -0,0 +1,70 @@ +import { Effect, Schema, SchemaAST } from "effect" + +export const constructionEvents: Array = [] +const value = Schema.Number.pipe(Schema.withConstructorDefault(Effect.sync(() => { + constructionEvents.push("default") + return 1 +}))) +export class Constructed extends Schema.TaggedClass()("Constructed", { + value +}) { + readonly initialized = constructionEvents.push("class") > 0 +} + +export const constructionCases = { + struct: { + schema: Schema.Struct({ value, optional: Schema.optionalKey(Schema.String) }) + .check(Schema.makeFilter((input) => { + constructionEvents.push("check") + return input.value > 0 + })), + inputs: [{}, { value: undefined }, { value: -1 }, { value: "bad", optional: 1 }, { value: 2, extra: true }] + }, + array: { schema: Schema.Array(value), inputs: [[undefined, 2], ["bad", 3], null] }, + tuple: { + schema: Schema.TupleWithRest(Schema.Tuple([value]), [Schema.String, Schema.Boolean]), + inputs: [[], [undefined, "a", true], ["bad", 1, false], [1, true]] + }, + optionalTuple: { + schema: Schema.Tuple([Schema.optionalKey(Schema.Undefined)]), + inputs: [[], [undefined], [1], [undefined, 2]] + }, + record: { schema: Schema.Record(Schema.String, value), inputs: [{ a: undefined }, { a: "bad", b: "bad" }, {}, null] }, + mixedRecord: { + schema: Schema.StructWithRest(Schema.Struct({ value }), [ + Schema.Record(Schema.TemplateLiteral(["x-", Schema.String]), Schema.Number) + ]), + inputs: [{}, { value: 1, "x-a": 2 }, { value: "bad", "x-a": "bad", extra: true }] + }, + union: { + schema: Schema.Union([ + Schema.Struct({ _tag: Schema.tag("A"), value }), + Schema.Struct({ _tag: Schema.tag("B"), text: Schema.String }) + ]), + inputs: [{}, { text: "a" }, { _tag: "A", value: "bad" }, { _tag: "C" }] + }, + oneOf: { schema: Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }), inputs: ["a", "b", 1] }, + class: { + schema: Constructed, + inputs: [{}, { value: undefined }, { value: -1 }, { value: "bad" }, { _tag: "wrong" }] + }, + transformed: { + schema: Schema.Struct({ value: Schema.FiniteFromString.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }), + inputs: [{}, { value: "1" }, { value: 2 }] + }, + declaration: { schema: Schema.ReadonlySet(Schema.Number), inputs: [new Set([1]), new Set(["bad"]), null] }, + empty: { schema: Schema.Struct({}), inputs: [{}, 1, [], null] } +} satisfies Record }> + +export const constructionSchemas = Object.fromEntries( + Object.entries(constructionCases).map(( + [name, test] + ) => [`construct-${name}`, Schema.make(SchemaAST.toType(test.schema.ast))]) +) + +export const constructionOptions: ReadonlyArray = [ + undefined, + { errors: "all", reportInput: true }, + { onExcessProperty: "error" }, + { disableChecks: true } +] diff --git a/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts b/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts index a57717b2f29..69229bb27a0 100644 --- a/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts +++ b/packages/effect/test/schema/jsonSchemaRoundTrip.test.ts @@ -226,10 +226,10 @@ describe("JSON Schema round-trip laws", () => { ) }) - it("preserves pattern indexes", () => { + it("preserves pattern indexes for matching keys", () => { assertRepresentationRoundTrip( Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite), - [{}, { A: 1 }, { A: "a" }, { a: 1 }, { a: "a" }, []] + [{}, { A: 1 }, { A: "a" }, []] ) }) diff --git a/packages/effect/test/schema/representation/fromJson.test.ts b/packages/effect/test/schema/representation/fromJson.test.ts index 4cdfbbb8e7e..0f6385219a5 100644 --- a/packages/effect/test/schema/representation/fromJson.test.ts +++ b/packages/effect/test/schema/representation/fromJson.test.ts @@ -173,7 +173,7 @@ describe("SchemaRepresentation.fromJson", () => { representation: { _tag: "Union", types: [{ _tag: "String", checks: [] }, { _tag: "Number", checks: [] }], - mode: "oneOf", + options: { mode: "oneOf" }, checks: [] }, references: {} diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index 40a82e90647..d3e3ae6fc8d 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -293,8 +293,7 @@ describe("fromJsonSchemaDocument", () => { "value": 1 } } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -325,8 +324,7 @@ describe("fromJsonSchemaDocument", () => { "value": 1 } } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -353,8 +351,7 @@ describe("fromJsonSchemaDocument", () => { "_tag": "Null", "checks": [] } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -398,11 +395,9 @@ describe("fromJsonSchemaDocument", () => { "value": 2 } } - ], - "mode": "anyOf" + ] } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -542,8 +537,7 @@ describe("fromJsonSchemaDocument", () => { } ] } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -586,11 +580,10 @@ describe("fromJsonSchemaDocument", () => { "value": 2 } } - ], - "mode": "anyOf" + ] } ], - "mode": "oneOf" + "options": { "mode": "oneOf" } }, "references": {} } @@ -731,7 +724,7 @@ describe("fromJsonSchemaDocument", () => { ] } ], - "mode": "oneOf" + "options": { "mode": "oneOf" } }, "references": {} } @@ -2416,8 +2409,7 @@ describe("fromJsonSchemaDocument", () => { "_tag": "Null", "checks": [] } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -2445,8 +2437,7 @@ describe("fromJsonSchemaDocument", () => { "_tag": "Null", "checks": [] } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -4031,8 +4022,7 @@ describe("fromJsonSchemaDocument", () => { "value": "b" } } - ], - "mode": "anyOf" + ] }, "references": {} } @@ -4509,8 +4499,7 @@ describe("fromJsonSchemaDocument", () => { "value": 2 } } - ], - "mode": "anyOf" + ] }, "references": {} } diff --git a/packages/effect/test/schema/representation/fromRepresentation.test.ts b/packages/effect/test/schema/representation/fromRepresentation.test.ts index 18f46c18849..9365acfd918 100644 --- a/packages/effect/test/schema/representation/fromRepresentation.test.ts +++ b/packages/effect/test/schema/representation/fromRepresentation.test.ts @@ -230,7 +230,7 @@ describe("SchemaRepresentation.fromRepresentation", () => { it("revives an empty Union as Never", () => { const schema = SchemaRepresentation.fromRepresentation({ - representation: { _tag: "Union", types: [], mode: "anyOf", checks: [] }, + representation: { _tag: "Union", types: [], options: { mode: "anyOf" }, checks: [] }, references: {} }, { revivers: [] }) assert.isFalse(Schema.is(schema)(undefined)) diff --git a/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts b/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts index e1c2e96b137..8331349db42 100644 --- a/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts +++ b/packages/effect/test/schema/representation/toCodeDocument.annotations.test.ts @@ -26,7 +26,7 @@ const NumberRepresentation: SchemaRepresentation.Representation = { const EmptyUnionRepresentation: SchemaRepresentation.Representation = { _tag: "Union", types: [], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] } @@ -455,7 +455,7 @@ describe("SchemaRepresentation.toCodeDocument annotations", () => { checks: [{ _tag: "FilterGroup", checks: [filter] }] }, E: { _tag: "TemplateLiteral", parts: [reference("D")], checks: [] }, - F: { _tag: "Union", types: [reference("E"), reference("A")], mode: "anyOf", checks: [] }, + F: { _tag: "Union", types: [reference("E"), reference("A")], options: { mode: "anyOf" }, checks: [] }, G: { _tag: "Arrays", elements: [{ type: reference("F"), isOptional: false }], @@ -532,7 +532,7 @@ describe("SchemaRepresentation.toCodeDocument annotations", () => { representations: [{ _tag: "Union", types: [{ _tag: "Literal", literal: "a", checks: [] }], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] }], references: {} diff --git a/packages/effect/test/schema/representation/toJson.test.ts b/packages/effect/test/schema/representation/toJson.test.ts index 1d14cc7c7dc..67c5ecfe760 100644 --- a/packages/effect/test/schema/representation/toJson.test.ts +++ b/packages/effect/test/schema/representation/toJson.test.ts @@ -181,7 +181,7 @@ describe("SchemaRepresentation.toJson", () => { }, checks: [] }], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] }, { @@ -191,7 +191,7 @@ describe("SchemaRepresentation.toJson", () => { annotations: { title: "nested" }, checks: [] }], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] } ) diff --git a/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts index 91954abbd46..190804c292c 100644 --- a/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/toJsonSchemaDocument.test.ts @@ -26,7 +26,7 @@ const NumberRepresentation: SchemaRepresentation.Representation = { const EmptyUnionRepresentation: SchemaRepresentation.Representation = { _tag: "Union", types: [], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] } @@ -293,7 +293,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { { _tag: "Literal", literal: "a", checks: [] }, { _tag: "Literal", literal: "b", checks: [] } ], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] }), { type: "string", enum: ["a", "b"] } @@ -308,7 +308,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { { _tag: "Literal", literal: "a", checks: [] }, { _tag: "Literal", literal: 1, checks: [] } ], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] }), { @@ -485,7 +485,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { compile({ _tag: "Union", types: [StringRepresentation], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] }), { anyOf: [{ type: "string" }] } @@ -661,7 +661,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { parameter: { _tag: "Union", types: [template, pattern], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] }, type: StringRepresentation @@ -724,7 +724,7 @@ describe("SchemaRepresentation.toJsonSchemaDocument", () => { { _tag: "Literal", literal: "a", checks: [] }, { _tag: "Literal", literal: "b", checks: [] } ], - mode: "anyOf", + options: { mode: "anyOf" }, checks: [] } ], diff --git a/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts index a8e5ca7c276..88613b5569f 100644 --- a/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts +++ b/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts @@ -541,7 +541,7 @@ describe("SchemaRepresentation.toJsonSchemaMultiDocument", () => { { _tag: "Union", types: [StringRepresentation, { _tag: "Boolean", checks: [] }], - mode: "oneOf", + options: { mode: "oneOf" }, checks: [] } ], diff --git a/packages/effect/test/schema/representation/toRepresentation.test.ts b/packages/effect/test/schema/representation/toRepresentation.test.ts index 16b959a7269..319df42ab5a 100644 --- a/packages/effect/test/schema/representation/toRepresentation.test.ts +++ b/packages/effect/test/schema/representation/toRepresentation.test.ts @@ -145,7 +145,6 @@ describe("SchemaRepresentation.toRepresentation", () => { { _tag: "String", checks: [] }, { _tag: "BigInt", checks: [] } ], - mode: "anyOf", checks: [] }, references: {} diff --git a/packages/effect/test/unstable/encoding/SchemaBinary.test.ts b/packages/effect/test/unstable/encoding/SchemaBinary.test.ts index e443d7d112b..206a2394c7d 100644 --- a/packages/effect/test/unstable/encoding/SchemaBinary.test.ts +++ b/packages/effect/test/unstable/encoding/SchemaBinary.test.ts @@ -1680,7 +1680,7 @@ describe("SchemaBinary", () => { assert.strictEqual(error.message.match(/Missing key/g)?.length, 2) }) - it("ignores excess-property and property-order options at the binary boundary", () => { + it("ignores excess-property options at the binary boundary", () => { const Writer = Schema.Struct({ extra: Schema.String, known: Schema.Number }) const Reader = Schema.Struct({ known: Schema.Number }) const bytes = encode(Writer, { extra: "drop", known: 1 }) @@ -1690,7 +1690,7 @@ describe("SchemaBinary", () => { { known: 1 } ) assert.deepStrictEqual( - SchemaBinary.parser(Reader, { onExcessProperty: "preserve", propertyOrder: "original" }).feedSync(bytes), + SchemaBinary.parser(Reader, { onExcessProperty: "error" }).feedSync(bytes), [{ known: 1 }] ) }) diff --git a/packages/effect/test/unstable/http/HttpServerRequest.test.ts b/packages/effect/test/unstable/http/HttpServerRequest.test.ts index 2f0191b86a5..c707a3c4bcb 100644 --- a/packages/effect/test/unstable/http/HttpServerRequest.test.ts +++ b/packages/effect/test/unstable/http/HttpServerRequest.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { assertNone, assertSome, deepStrictEqual, strictEqual } from "@effect/vitest/utils" -import { Effect, Schema, Stream } from "effect" +import { Effect, Result, Schema, Stream } from "effect" import * as Option from "effect/Option" import { HttpBody, HttpClientRequest, HttpServerRequest } from "effect/unstable/http" @@ -199,17 +199,29 @@ describe("HttpServerRequest", () => { }) const decoded = yield* HttpServerRequest.schemaBodyJson(schema, { - onExcessProperty: "preserve", + onExcessProperty: "ignore", reviver: (key, value) => key === "status" ? "revived" : value }).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, request) ) - const decodedRecord = decoded as Record - assert.strictEqual(decoded.status, "revived") assert.strictEqual(decoded.name, "svc") - assert.strictEqual(decodedRecord.sha, "abc") - assert.strictEqual(decodedRecord.version, "1.0.0") + assert.isFalse("sha" in decoded) + assert.isFalse("version" in decoded) + + const rejected = yield* HttpServerRequest.schemaBodyJson(schema, { + onExcessProperty: "error" + }).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.result + ) + assert(Result.isFailure(rejected)) + assert(Schema.isSchemaError(rejected.failure)) + assert.strictEqual( + rejected.failure.message, + `Expected no excess property + at ["sha"]` + ) })) it("remoteAddress defaults to none for web requests", () => { diff --git a/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts new file mode 100644 index 00000000000..613675606ec --- /dev/null +++ b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts @@ -0,0 +1,14 @@ +import { Schema, SchemaAST } from "effect" +import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" +import { describe, expect, it } from "tstyche" + +describe("SchemaAOTCompiler", () => { + it("compiles ASTs to module source", () => { + expect(SchemaAOTCompiler.compile([Schema.String.ast])).type.toBe() + expect(SchemaAOTCompiler.compile).type.toBeCallableWith([SchemaAST.string, SchemaAST.number] as const) + expect(SchemaAOTCompiler.compile).type.toBeCallableWith([]) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith(SchemaAST.string) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith(Schema.String) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith([Schema.String]) + }) +}) diff --git a/packages/effect/typetest/schema/SchemaCompiler.tst.ts b/packages/effect/typetest/schema/SchemaCompiler.tst.ts new file mode 100644 index 00000000000..e271b4790ba --- /dev/null +++ b/packages/effect/typetest/schema/SchemaCompiler.tst.ts @@ -0,0 +1,23 @@ +import { Effect, Schema } from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { describe, expect, it } from "tstyche" + +describe("SchemaCompiler", () => { + it("set", () => { + const decoder = { + is: (input, _options) => typeof input === "string", + validate: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, + decodeEffect: (input, _options) => Effect.succeed(input), + makeEffect: (input, _options) => Effect.succeed(input) + } satisfies SchemaCompiler.CompiledDecoder + + expect(SchemaCompiler.set(Schema.String.ast, decoder)).type.toBe() + expect(SchemaCompiler.set).type.toBeCallableWith(Schema.String.ast, { decodeEffect: Effect.succeed }) + expect(SchemaCompiler.set).type.not.toBeCallableWith(Schema.String.ast, { makeEffect: Effect.succeed }) + }) + + it("enable", () => { + expect(SchemaJITCompiler.enable(Schema.String.ast)).type.toBe() + expect(SchemaJITCompiler.enable).type.not.toBeCallableWith(Schema.String) + }) +}) diff --git a/packages/effect/typetest/schema/api.tst.ts b/packages/effect/typetest/schema/api.tst.ts index f215f2a0f81..7f958426c50 100644 --- a/packages/effect/typetest/schema/api.tst.ts +++ b/packages/effect/typetest/schema/api.tst.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "tstyche" describe("decoding / encoding API", () => { it("is", () => { const is = Schema.is(Schema.String) + expect(Schema.is).type.not.toBeCallableWith(Schema.String, { onExcessProperty: "error" }) const u = hole() if (is(u)) { expect(u).type.toBe() diff --git a/packages/effect/typetest/schema/options.tst.ts b/packages/effect/typetest/schema/options.tst.ts new file mode 100644 index 00000000000..6c0915e3381 --- /dev/null +++ b/packages/effect/typetest/schema/options.tst.ts @@ -0,0 +1,26 @@ +import { Schema, SchemaParser } from "effect" +import type { SchemaAST } from "effect" +import { describe, expect, it } from "tstyche" + +describe("runtime and AST options", () => { + it("keeps excess handling at runtime without order or concurrency options", () => { + expect().type.toBe<"ignore" | "error" | undefined>() + expect>().type.toBe() + const schema = Schema.Struct({ a: Schema.String }) + expect>().type.toBe() + expect(SchemaParser.decodeUnknownSync(schema, { onExcessProperty: "error" })({ a: "a" })) + .type.toBe<{ readonly a: string }>() + expect(SchemaParser.is(schema)).type.toBe< + (input: I) => input is I & { readonly a: string } + >() + expect(SchemaParser.is).type.not.toBeCallableWith(schema, { onExcessProperty: "error" }) + expect(Schema.Record(Schema.String, Schema.Number).Type) + .type.toBe<{ readonly [x: string]: number }>() + }) + + it("stores mode in the Union options bag", () => { + const schema = Schema.Union([Schema.String, Schema.Number], { mode: "oneOf" }) + expect(schema.ast.options).type.toBe() + expect>().type.toBe() + }) +}) diff --git a/packages/tools/bundle/fixtures/schema-compiler-off.ts b/packages/tools/bundle/fixtures/schema-compiler-off.ts new file mode 100644 index 00000000000..c9e802b159f --- /dev/null +++ b/packages/tools/bundle/fixtures/schema-compiler-off.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" + +const schema = Schema.Struct({ + first: Schema.FiniteFromString, + second: Schema.FiniteFromString +}) + +console.log(SchemaParser.decodeUnknownSync(schema)({ first: "1", second: "2" })) diff --git a/packages/tools/bundle/fixtures/schema-compiler.ts b/packages/tools/bundle/fixtures/schema-compiler.ts new file mode 100644 index 00000000000..a27369e93af --- /dev/null +++ b/packages/tools/bundle/fixtures/schema-compiler.ts @@ -0,0 +1,11 @@ +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import * as Schema from "effect/Schema" +import * as SchemaParser from "effect/SchemaParser" + +const schema = Schema.Struct({ + first: Schema.FiniteFromString, + second: Schema.FiniteFromString +}) + +console.log(SchemaParser.decodeUnknownSync(schema)({ first: "1", second: "2" }))