diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md new file mode 100644 index 00000000000..a7292af247d --- /dev/null +++ b/.changeset/add-schema-compilers.md @@ -0,0 +1,42 @@ +--- +"effect": patch +--- + +Add experimental JIT and AOT schema compilers that work through the existing +`SchemaParser` APIs and share a decoder registry. Enable JIT globally with +`effect/unstable/schema/SchemaJITCompiler/enable`, selectively with +`SchemaJITCompiler.enable(ast)`, or install generated AOT decoders without +requiring dynamic function construction. The new +`effect/unstable/schema/SchemaAOTCompiler/Build` entrypoint discovers direct +Schema exports from explicit module loaders and writes a self-installing AOT +module through Effect's `FileSystem` and `Path` services. Compiled decoders can +provide optional synchronous `decode` and `make` operations; normal +`SchemaParser` calls consume them transparently and retain `decodeEffect` and +`makeEffect` as the detailed fallbacks. AOT targets declare the operations to +prepare, so generated modules contain only those operation families and use +the interpreter if an omitted operation is later called. + +### Breaking changes + +`SchemaGetter.Getter` is now a tagged union that distinguishes synchronous, +optional, and effectful transformations. Getter values now expose only `pipe`. +Use the dual `SchemaGetter.map`, `SchemaGetter.compose`, and `SchemaGetter.run` +functions instead of the former methods. Keeping these operations standalone +lets bundlers remove composition code when an application does not use it. + +The public `Getter` constructor is removed. Use +`SchemaGetter.transformOptionalEffect` instead of `new SchemaGetter.Getter`. +`SchemaGetter.onSome` and `SchemaGetter.onNone` are also removed. Use +`transformEffect` for an effectful transformation of present values and +`transformOptionalEffect` when the transformation handles missing values. + +`SchemaTransformation.compose` is now a dual standalone function. Replace +`first.compose(second)` with `SchemaTransformation.compose(first, second)` or +`SchemaTransformation.compose(second)(first)`. + +`SchemaAST.Context.constructorDefault` now stores the constructor-default +`Effect` directly instead of wrapping it in a `SchemaAST.Link`. Constructor +defaults apply only during construction, so the direct representation avoids +giving them encoding semantics and lets construction reuse already-completed +synchronous Effects. Code that constructs or inspects `SchemaAST.Context` +should pass or read the default `Effect` directly. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 2a25c9b9f48..0f1112453b5 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -51,24 +51,144 @@ 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** | — | — | +| Scenario | Effect Schema | Valibot 1.5.0 | Zod 4.6.2 | +| ------------------------------------- | ------------: | ------------: | --------: | +| Create a schema | 60.1297 | 1.0926 | 83.7496 | +| Validate valid data | 4.1473 | 3.5389 | — | +| Validate invalid data | 0.2571 | 0.1823 | — | +| Parse valid data and collect errors | 5.0261 | 3.5393 | 7.0073 | +| Parse invalid data and collect errors | 7.4838 | 5.7206 | 19.3064 | +| Parse valid data and stop early | 4.1559 | 3.5941 | — | +| Parse invalid data and stop early | 0.2525 | 0.1917 | — | +| Standard Schema, valid data | **5.3408** | 3.5565 | 3.5755 | +| Standard Schema, invalid data | 11.7723 | 5.4422 | 15.9974 | +| Encode with a typed codec | 0.0827 | — | 0.0441 | +| Decode with a typed codec | 0.1063 | — | 0.0427 | + +## Experimental schema compilers + +Enable JIT compilation at application startup with a side-effect import: + +```ts +import "effect/unstable/schema/SchemaJITCompiler/enable" +``` + +Alternatively, `SchemaJITCompiler.enable(schema.ast)` enables one AST and the +dependencies reached while parsing it. Importing `SchemaJITCompiler` or the +`unstable/schema` barrel alone does not enable compilation. Operations are +prepared on first use. If dynamic function construction is blocked or compilation +fails, the interpreter remains available. Exceptions from executing a parser are +not treated as compilation failures and do not trigger a retry. + +JIT and AOT use the same source generator. To generate an AOT module at build +time, call `SchemaAOTCompiler.compile(targets)` with an ordered array of ASTs +and the operations to prepare: + +```ts +SchemaAOTCompiler.compile([ + { ast: User.ast, operations: ["decode"] }, + { ast: SchemaAST.toType(User.ast), operations: ["is", "make"] } +]) +``` + +The module exports `install(asts)`. Call it with the target ASTs in the same +order before using normal `SchemaParser` functions. Generated modules contain +only the requested operation families and their dependencies. Operations that +were not requested use the interpreter if they are called. Generated modules +do not import the generator and work where `new Function` is forbidden. + +The low-level installation trusts the supplied root order and AST definitions. +Target `SchemaAST.toType(schema.ast)` with `is` or `make` for guards and +construction. Target `SchemaAST.flip(schema.ast)` with `decode` for encoding. + +`effect/unstable/schema/SchemaAOTCompiler/Build` provides the higher-level +workflow. Its `build` function loads direct Schema exports, writes a +self-installing module through `FileSystem`, and prepares decoding by default: + +```ts +import * as SchemaAOTCompilerBuild from "effect/unstable/schema/SchemaAOTCompiler/Build" + +SchemaAOTCompilerBuild.build({ + modules: { + "./schemas/User.js": () => import("./schemas/User.js"), + "./schemas/Order.js": () => import("./schemas/Order.js") + }, + baseUrl: import.meta.url, + outFile: "./generated/schema-aot.js" +}) +``` + +Import the generated file at application startup. Module keys identify imports +relative to `baseUrl`; each loader must return that same module during the +build. The lazy record produced by `import.meta.glob` can be passed directly. +Request `encode`, `is`, or `make` explicitly when those directions also need +AOT roots. Loading executes the selected application modules during the build. +Run the returned Effect with the platform's `FileSystem` and `Path` services, +and ensure the bundler retains the generated side-effect import. + +Regenerate AOT modules when schema definitions or the Effect version change. +Callbacks and symbols are read from runtime ASTs, not serialized. + +### One registry for all implementations + +A single `WeakMap` associates each exact AST with its decoder entry. The cache +stores functions, never parsing results. The interpreter, JIT, AOT and +`SchemaCompiler.set(ast, decoder)` all use it. + +| Operation | Result | Purpose | +| -------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `decodeEffect` | `Effect` with output or detailed issues | Required complete decoding, including asynchronous work and transformations. | +| `decode` | Output or `SchemaCompiler.invalid` | Optional synchronous decoding fast path without detailed diagnostics. | +| `is` | Boolean | Optional validation without constructing output. | +| `make` | Output or `SchemaCompiler.invalid` | Optional synchronous construction fast path without detailed diagnostics. | +| `makeEffect` | `Effect` with a constructed value or issues | Optional specialized construction. The registry caches the interpreted constructor when absent. | + +Decoding tries `decode` when available. Success provides the output directly; +failure calls `decodeEffect` for diagnostics. The diagnostic traversal uses child +decoders directly, without restarting their validation fast paths. A boolean +guard prefers `is`, otherwise it uses ordinary decoding (including `decode` +when available). An `invalid` result needs that diagnostic fallback because the +marker is also a possible input value. Composite checks +can require stripped, reconstructed values, so `is` is omitted when it cannot +avoid constructing those values safely. + +Each operation initializes independently. Synchronous construction tries `make` +when available and falls back to `makeEffect` for detailed issues. Compilers omit +`make` whenever replay could repeat defaults, Class constructors, +transformations, middleware, or other effects. `makeEffect` itself never uses +validation replay. Field defaults belong to the parent occurrence, not to +construction of the root. Runtime parse options, including product concurrency, +retain the interpreter's semantics. + +Installing a decoder replaces the entry for that AST. Existing consumers that +already captured an entry retain it. Late installation is allowed, but startup +installation is needed to optimize every consumer. Custom decoders supplied to +`set` are trusted to implement the AST's semantics. + +### What is specialized + +Encoding-free graphs of supported primitives, Objects, Arrays, tuples, Unions +and template literals can use generated validators. Struct and homogeneous Array +decoding and construction also have generated loops. Pure fixed Struct and +homogeneous Array constructors can additionally use the synchronous `make` fast +path; composite children are resolved through the same registry. These loops +share the interpreter's diagnostic and asynchronous continuation helpers. +Other detailed traversals and constructors use the existing interpreter with +registry-resolved children; there is no separate diagnostic interpreter in the +compiler. + +Transformations and middleware never participate in validation replay. Their +orchestration uses the same implementation as interpreted parsing, and pure +child checkpoints can still use generated validators. Suspend is resolved lazily +by JIT. AOT does not evaluate Suspend thunks at build time, so dynamically reached +schemas fall back to the interpreter unless installed separately. Declaration +callbacks remain runtime code; their type parameters can be compiled. + +Checks and property getters in replayable validation must be deterministic and +free of side effects. Proxy inputs and modifications to built-in object behavior +are not supported by the optimization contract. Large or unsupported graphs +retain interpreted paths. AOT removes dynamic source generation, not all parser +initialization or the need for runtime schema objects. # Defining Elementary Schemas @@ -3080,57 +3200,65 @@ Transformation - `RD`: the context used while decoding - `RE`: the context used while encoding -A `Transformation` consists of two `Getter` functions: +A `Transformation` consists of two `Getter` values: - `decode: Getter` — transforms a value during decoding - `encode: Getter` — transforms a value during encoding -Each `Getter` receives an input and an optional context and returns either a value or an error. Getters can be composed to build more complex logic. +Each `Getter` is a tagged description of one operation: + +- `Transform` transforms a present value synchronously. +- `TransformOptional` transforms an `Option` synchronously and can handle a missing value. +- `TransformEffect` and `TransformOptionalEffect` are the corresponding effectful forms. +- `Passthrough` returns its input unchanged. + +Getter values expose `pipe`. Use the dual standalone functions `SchemaGetter.map` and `SchemaGetter.compose` to build +larger transformations. `SchemaGetter.run` executes a getter directly and always returns an `Effect`; schemas execute +their getters through `SchemaParser` instead. **Example** (Implementation of `Transformation.trim`) ```ts +import { SchemaGetter, SchemaTransformation } from "effect" + /** * @category String transformations * @since 4.0.0 */ -export function trim(): Transformation { - return new Transformation(Getter.trim(), Getter.passthrough()) +export function trim(): SchemaTransformation.Transformation { + return new SchemaTransformation.Transformation(SchemaGetter.trim(), SchemaGetter.passthrough()) } ``` In this case: -- The `decode` process uses `Getter.trim()` to remove leading and trailing whitespace. -- The `encode` process uses `Getter.passthrough()`, which returns the input as is. +- The `decode` process uses `SchemaGetter.trim()` to remove leading and trailing whitespace. +- The `encode` process uses `SchemaGetter.passthrough()`, which returns the input as is. ## Composing Transformations -You can combine transformations using the `.compose` method. The resulting transformation applies the `decode` and `encode` logic of both transformations in sequence. +You can combine transformations using `SchemaTransformation.compose`. The resulting transformation applies the `decode` and `encode` logic of both transformations in sequence. **Example** (Trim and lowercase a string) ```ts -import { Option, SchemaTransformation } from "effect" +import { Schema, SchemaTransformation } from "effect" // Compose two transformations: trim followed by toLowerCase -const trimToLowerCase = SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase()) +const trimToLowerCase = SchemaTransformation.compose( + SchemaTransformation.trim(), + SchemaTransformation.toLowerCase() +) +const schema = Schema.String.pipe(Schema.decode(trimToLowerCase)) -// Run the decode logic manually to inspect the result -console.log(trimToLowerCase.decode.run(Option.some(" Abc"), {})) -/* -{ - _id: 'Exit', - _tag: 'Success', - value: { _id: 'Option', _tag: 'Some', value: 'abc' } -} -*/ +Schema.decodeUnknownSync(schema)(" Abc") +// "abc" ``` In this example: -- The `decode` logic applies `Getter.trim()` followed by `Getter.toLowerCase()`, producing a string that is trimmed and lowercased. -- The `encode` logic is `Getter.passthrough()`, which simply returns the input as-is. +- The `decode` logic applies `SchemaGetter.trim()` followed by `SchemaGetter.toLowerCase()`, producing a string that is trimmed and lowercased. +- The `encode` logic is `SchemaGetter.passthrough()`, which returns the input unchanged. ## Transforming One Schema into Another diff --git a/packages/effect/package.json b/packages/effect/package.json index 515ab66b438..656bc52dcf4 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 a1d28cca18c..bbfc32a6ef9 100644 --- a/packages/effect/runtimeperf/README.md +++ b/packages/effect/runtimeperf/README.md @@ -42,6 +42,7 @@ pnpm runtimeperf object-32-valid pnpm runtimeperf schema/object-32-valid-effect pnpm runtimeperf --family arrays pnpm runtimeperf --implementation zod4 +pnpm runtimeperf --implementation zod4-compiled ``` Override measurement settings: @@ -86,6 +87,13 @@ adapters, recursion and cold paths. The `schema-benchmarks` suite contains the complete timing matrices exposed by the upstream Effect, Valibot and Zod adapters. +The `moltar-parse-safe` and `moltar-assert-loose` suites preserve Moltar's +object shape. Their valid cases reproduce the upstream timed operation; the +extra-property and invalid cases are Effect extensions. They compare +interpreted Effect, Effect JIT and AOT, Valibot, ordinary and jitless Zod where +applicable, and Zod `compile`, using the dependency versions recorded in each +report. + The `arbitrary` suite compares the native public API with direct fast-check v4 arbitraries in separate processes. It measures derivation through the first recursive sample, steady-state recursive sampling, optional-Struct sampling, fixed-length string generation to exercise constraint pushdown, @@ -123,13 +131,18 @@ Zod parsing cases import `zod/v4` and call `safeParse` with `{ jitless: true }`; its Standard Schema and codec cases use their native APIs. Valibot uses the corresponding `is`, `safeParse` and Standard Schema APIs. The focused Effect adapter family measures the overhead of public APIs that wrap parser issues. +The compiler comparison calls `z.compile(schema, { strict: true })` and uses +Zod's `validate` API for boolean checks. Ten representative scenarios also run +against equivalent Valibot schemas using `parse` and `is`. ## 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. The Effect +fixture calibrates one batch size that every implementation in the same +scenario uses. This keeps the enclosing loop identical across implementations; +V8 can otherwise optimize sub-10 ns callbacks differently at different batch +sizes. 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..1b08f7c4e65 100644 --- a/packages/effect/runtimeperf/compare.mts +++ b/packages/effect/runtimeperf/compare.mts @@ -1,13 +1,14 @@ import { spawnSync } from "node:child_process" -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs" +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs" import os from "node:os" -import { join } from "node:path" +import { dirname, join } from "node:path" import process from "node:process" import { materializeFixture } from "./materialize.mts" import { analyzePairs } from "./stats.mts" import { aggregateMeasurements, calibrateFixture, + comparePath, configPath, coverageSummary, effectDir, @@ -16,6 +17,7 @@ import { libraryVersions, loadRegistry, makeRunId, + materializePath, measureFixture, parseArgs, printTable, @@ -25,6 +27,8 @@ import { resolveDefaults, selectFixtures, sha256, + statsPath, + utilsPath, workerPath, writeJson } from "./utils.mts" @@ -90,6 +94,22 @@ const createWorktree = (runRoot, name, sha) => { return path } +const applyWorktreeChanges = (path, untracked) => { + const diff = runGit(["diff", "--binary", "HEAD", "--"]) + if (diff !== "") { + const result = run("git", ["apply", "--binary", "-"], { cwd: path, input: `${diff}\n` }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${result.stdout}${result.stderr}`.trim()) + } + } + for (const file of untracked) { + const target = join(path, file.path) + mkdirSync(dirname(target), { recursive: true }) + cpSync(join(repoRoot, file.path), target, { recursive: true }) + } +} + const worktreeState = () => { const diff = runGit(["diff", "--binary", "HEAD", "--"]) const untrackedOutput = runGit([ @@ -136,16 +156,13 @@ const main = () => { try { const baseRoot = createWorktree(runRoot, "base", baseSha) worktrees.push(baseRoot) - const headRoot = options.head === "worktree" - ? repoRoot - : createWorktree(runRoot, "head", headSha) - if (headRoot !== repoRoot) worktrees.push(headRoot) + const headRoot = createWorktree(runRoot, "head", headSha) + worktrees.push(headRoot) + if (options.head === "worktree") applyWorktreeChanges(headRoot, state.untracked) for (const fixture of selected) { const baseFixturePath = materializeFixture(baseRoot, fixture) - const headFixturePath = headRoot === repoRoot - ? fixture.fixturePath - : materializeFixture(headRoot, fixture) + const headFixturePath = materializeFixture(headRoot, fixture) const baseCalibration = calibrateFixture(fixture, defaults, baseFixturePath) const headCalibration = calibrateFixture(fixture, defaults, headFixturePath) const batchSize = Math.max(baseCalibration.batchSize, headCalibration.batchSize) @@ -227,7 +244,11 @@ const main = () => { artifactMode: "repository", coverage: coverageSummary(selected), hashes: { + compare: hashFile(comparePath), config: hashFile(configPath), + materialize: hashFile(materializePath), + stats: hashFile(statsPath), + utils: hashFile(utilsPath), worker: hashFile(workerPath), fixtures: Object.fromEntries( [...new Set(selected.map((fixture) => fixture.fixturePath))] diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 4878a5a82e7..607012584f3 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -1703,6 +1703,1425 @@ ] } ] + }, + { + "name": "moltar-parse-safe", + "fixtures": [ + { + "file": "suites/moltar/fixtures/interpreted.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "effect", + "operation": "decode-unknown-sync", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "effect-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/jit.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "effect-jit", + "operation": "decode-unknown-sync", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-jit-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "effect-jit-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-jit-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/aot.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "effect-aot", + "operation": "decode-unknown-sync", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-aot-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "effect-aot-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-aot-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/valibot.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "valibot", + "operation": "parse", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "valibot-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "valibot-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "valibot-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/zod.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "zod-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4", + "operation": "parse", + "path": "valid" + }, + { + "name": "zod-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4", + "operation": "parse", + "path": "extra-valid" + }, + { + "name": "zod-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4", + "operation": "parse", + "path": "invalid" + }, + { + "name": "zod-jitless-valid", + "export": "parseJitlessValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "valid" + }, + { + "name": "zod-jitless-extra-valid", + "export": "parseJitlessExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "extra-valid" + }, + { + "name": "zod-jitless-invalid", + "export": "parseJitlessInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "invalid" + }, + { + "name": "zod-compiled-valid", + "export": "parseCompiledValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "valid" + }, + { + "name": "zod-compiled-extra-valid", + "export": "parseCompiledExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "extra-valid" + }, + { + "name": "zod-compiled-invalid", + "export": "parseCompiledInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "invalid" + } + ] + } + ] + }, + { + "name": "moltar-assert-loose", + "fixtures": [ + { + "file": "suites/moltar/fixtures/interpreted.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "effect", + "operation": "is", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "effect-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/jit.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "effect-jit", + "operation": "is", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-jit-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "effect-jit-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-jit-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/aot.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "effect-aot", + "operation": "is", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-aot-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "effect-aot-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-aot-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/valibot.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "valibot", + "operation": "is", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "valibot-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "valibot-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "valibot-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/zod.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "zod-parse-valid", + "export": "assertParseValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4", + "operation": "parse-and-assert", + "path": "valid" + }, + { + "name": "zod-parse-extra-valid", + "export": "assertParseExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4", + "operation": "parse-and-assert", + "path": "extra-valid" + }, + { + "name": "zod-jitless-validate-valid", + "export": "isJitlessValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-jitless", + "operation": "validate-jitless", + "path": "valid" + }, + { + "name": "zod-jitless-validate-extra-valid", + "export": "isJitlessExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-jitless", + "operation": "validate-jitless", + "path": "extra-valid" + }, + { + "name": "zod-jitless-validate-invalid", + "export": "isJitlessInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-jitless", + "operation": "validate-jitless", + "path": "invalid" + }, + { + "name": "zod-validate-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "valid" + }, + { + "name": "zod-validate-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "extra-valid" + }, + { + "name": "zod-validate-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "invalid" + }, + { + "name": "zod-compiled-valid", + "export": "isCompiledValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "valid" + }, + { + "name": "zod-compiled-extra-valid", + "export": "isCompiledExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "extra-valid" + }, + { + "name": "zod-compiled-invalid", + "export": "isCompiledInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "invalid" + } + ] + } + ] + }, + { + "name": "compiler-rebuild", + "fixtures": [ + { + "file": "suites/compiler-rebuild/fixtures/interpreted.ts", + "defaults": { + "tier": 1, + "family": "interpreted", + "implementation": "effect", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "interpreted-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "interpreted-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "interpreted-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "interpreted-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "interpreted-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "interpreted-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "interpreted-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "interpreted-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "astTags": [ + "Objects", + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 2 + }, + { + "name": "interpreted-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "interpreted-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 33 + }, + { + "name": "interpreted-tuple", + "export": "tuple", + "scenario": "compiler-rebuild-tuple", + "astTags": [ + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 34 + }, + { + "name": "interpreted-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "interpreted-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + }, + { + "name": "interpreted-oneOf", + "export": "oneOf", + "scenario": "compiler-rebuild-oneOf", + "astTags": [ + "Union", + "Objects", + "String", + "Number" + ], + "size": 2 + }, + { + "name": "interpreted-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "astTags": [ + "Objects", + "String", + "Number" + ], + "size": 32 + }, + { + "name": "interpreted-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, + { + "name": "interpreted-middleware", + "export": "middleware", + "scenario": "compiler-rebuild-middleware" + }, + { + "name": "interpreted-recursive", + "export": "recursive", + "scenario": "compiler-rebuild-recursive", + "astTags": [ + "Objects", + "Arrays", + "Suspend", + "Number" + ], + "size": 31 + }, + { + "name": "interpreted-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "astTags": [ + "Declaration", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "interpreted-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "astTags": [ + "Objects", + "Number" + ], + "size": 32 + }, + { + "name": "interpreted-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "interpreted-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/jit.ts", + "defaults": { + "tier": 1, + "family": "jit", + "implementation": "effect-jit", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "jit-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "jit-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "jit-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "jit-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "jit-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "jit-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "jit-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "jit-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "astTags": [ + "Objects", + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 2 + }, + { + "name": "jit-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "jit-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 33 + }, + { + "name": "jit-tuple", + "export": "tuple", + "scenario": "compiler-rebuild-tuple", + "astTags": [ + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 34 + }, + { + "name": "jit-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "jit-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + }, + { + "name": "jit-oneOf", + "export": "oneOf", + "scenario": "compiler-rebuild-oneOf", + "astTags": [ + "Union", + "Objects", + "String", + "Number" + ], + "size": 2 + }, + { + "name": "jit-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "astTags": [ + "Objects", + "String", + "Number" + ], + "size": 32 + }, + { + "name": "jit-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, + { + "name": "jit-middleware", + "export": "middleware", + "scenario": "compiler-rebuild-middleware" + }, + { + "name": "jit-recursive", + "export": "recursive", + "scenario": "compiler-rebuild-recursive", + "astTags": [ + "Objects", + "Arrays", + "Suspend", + "Number" + ], + "size": 31 + }, + { + "name": "jit-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "astTags": [ + "Declaration", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "jit-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "astTags": [ + "Objects", + "Number" + ], + "size": 32 + }, + { + "name": "jit-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "jit-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/aot.ts", + "defaults": { + "tier": 1, + "family": "aot", + "implementation": "effect-aot", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "aot-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "aot-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "aot-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "aot-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "aot-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "aot-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "aot-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "aot-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "astTags": [ + "Objects", + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 2 + }, + { + "name": "aot-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "aot-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 33 + }, + { + "name": "aot-tuple", + "export": "tuple", + "scenario": "compiler-rebuild-tuple", + "astTags": [ + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 34 + }, + { + "name": "aot-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "aot-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + }, + { + "name": "aot-oneOf", + "export": "oneOf", + "scenario": "compiler-rebuild-oneOf", + "astTags": [ + "Union", + "Objects", + "String", + "Number" + ], + "size": 2 + }, + { + "name": "aot-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "astTags": [ + "Objects", + "String", + "Number" + ], + "size": 32 + }, + { + "name": "aot-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, + { + "name": "aot-middleware", + "export": "middleware", + "scenario": "compiler-rebuild-middleware" + }, + { + "name": "aot-recursive", + "export": "recursive", + "scenario": "compiler-rebuild-recursive", + "astTags": [ + "Objects", + "Arrays", + "Suspend", + "Number" + ], + "size": 31 + }, + { + "name": "aot-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "astTags": [ + "Declaration", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "aot-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "astTags": [ + "Objects", + "Number" + ], + "size": 32 + }, + { + "name": "aot-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "aot-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/valibot.ts", + "defaults": { + "tier": 1, + "family": "valibot", + "implementation": "valibot", + "astTags": [], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "valibot-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "valibot-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "valibot-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "valibot-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "valibot-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "size": 32 + }, + { + "name": "valibot-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "valibot-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "size": 8 + }, + { + "name": "valibot-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "size": 32 + }, + { + "name": "valibot-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "size": 32 + }, + { + "name": "valibot-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "size": 32 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/zod-jitless.ts", + "defaults": { + "tier": 1, + "family": "zod-jitless", + "implementation": "zod4-jitless", + "astTags": [], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "zod-jitless-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "zod-jitless-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "zod-jitless-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "zod-jitless-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "zod-jitless-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "size": 32 + }, + { + "name": "zod-jitless-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "zod-jitless-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "size": 8 + }, + { + "name": "zod-jitless-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "size": 32 + }, + { + "name": "zod-jitless-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "size": 32 + }, + { + "name": "zod-jitless-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "size": 32 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/zod-compiled.ts", + "defaults": { + "tier": 1, + "family": "zod-compiled", + "implementation": "zod4-compiled", + "astTags": [], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "zod-compiled-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "zod-compiled-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "zod-compiled-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "zod-compiled-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "zod-compiled-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "zod-compiled-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "zod-compiled-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "zod-compiled-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "size": 2 + }, + { + "name": "zod-compiled-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "size": 32 + }, + { + "name": "zod-compiled-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "size": 33 + }, + { + "name": "zod-compiled-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "zod-compiled-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "size": 8 + }, + { + "name": "zod-compiled-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "size": 32 + }, + { + "name": "zod-compiled-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "size": 1 + }, + { + "name": "zod-compiled-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "size": 32 + }, + { + "name": "zod-compiled-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "size": 32 + }, + { + "name": "zod-compiled-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "size": 32 + }, + { + "name": "zod-compiled-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "size": 8 + } + ] + } + ] } ] } diff --git a/packages/effect/runtimeperf/run.mts b/packages/effect/runtimeperf/run.mts index b97665f60bb..3cac08601d9 100644 --- a/packages/effect/runtimeperf/run.mts +++ b/packages/effect/runtimeperf/run.mts @@ -18,7 +18,11 @@ import { relativeToRepo, reportPath, resolveDefaults, + runPath, + scenarioBatchSize, selectFixtures, + statsPath, + utilsPath, workerPath, writeJson } from "./utils.mts" @@ -31,7 +35,7 @@ Options: --warmup-time --tier <0-3> --family - --implementation + --implementation ` const rotate = (items, offset) => items.map((_, index) => items[(index + offset) % items.length]) @@ -52,12 +56,12 @@ const main = () => { for (const [scenario, group] of groups) { const calibrations = new Map(group.map((fixture) => [fixture, calibrateFixture(fixture, defaults)])) + const batchSize = scenarioBatchSize(group, calibrations) const byTarget = new Map(group.map((fixture) => [fixture.target, []])) for (let round = 0; round < defaults.rounds; round++) { for (const fixture of rotate(group, round % group.length)) { - const calibration = calibrations.get(fixture) - const measurement = measureFixture(fixture, defaults, calibration.batchSize) + const measurement = measureFixture(fixture, defaults, batchSize) byTarget.get(fixture.target).push(measurement) executionOrder.push({ scenario, round: round + 1, target: fixture.target }) } @@ -68,7 +72,7 @@ const main = () => { const measurements = byTarget.get(fixture.target) results.push({ fixture, - batchSize: calibration.batchSize, + batchSize, calibration, measurements, aggregate: aggregateMeasurements(measurements) @@ -81,7 +85,7 @@ const main = () => { const effect = group.find((result) => result.fixture.implementation === "effect") if (!effect) continue for (const candidate of group) { - if (candidate === effect) continue + if (candidate.fixture.implementation === "effect") continue crossLibrary.push({ scenario, implementation: candidate.fixture.implementation, @@ -116,16 +120,14 @@ const main = () => { cpu: os.cpus()[0]?.model ?? "unknown" }, libraries: libraryVersions(), - crossLibraryDecodeApis: { - effect: "SchemaParser.decodeUnknownExit (SchemaIssue)", - valibot: "safeParser", - zod4: "safeParse ({ jitless: true })" - }, artifactMode: "repository", git: currentGitState(), coverage: coverageSummary(selected), hashes: { config: hashFile(configPath), + run: hashFile(runPath), + stats: hashFile(statsPath), + utils: hashFile(utilsPath), worker: hashFile(workerPath), fixtures: Object.fromEntries( [...new Set(selected.map((fixture) => fixture.fixturePath))] @@ -140,11 +142,12 @@ const main = () => { writeJson(path, report) const comparisons = new Map(crossLibrary.map((item) => [`${item.scenario}/${item.implementation}`, item])) printTable( - ["scenario", "implementation", "ns/op", "mad", "vs Effect"], + ["scenario", "family", "implementation", "ns/op", "mad", "vs Effect"], results.map((result) => { const comparison = comparisons.get(`${result.fixture.scenario}/${result.fixture.implementation}`) return [ result.fixture.scenario, + result.fixture.family, result.fixture.implementation, formatNs(result.aggregate.median), formatNs(result.aggregate.mad), diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md new file mode 100644 index 00000000000..0a8ccc20d20 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md @@ -0,0 +1,80 @@ +# Compiler rebuild comparison + +The `compiler-rebuild` suite measures 22 public SchemaParser operations with the +interpreter, selective JIT and generated AOT modules. Eighteen cases also run +against `z.compile(schema, { strict: true })`; ten representative cases run +against Valibot and Zod with `{ jitless: true }`. The fixture families name the +execution mode. Cases +cover simple and nested Structs, Arrays, tuples, Records, anyOf/oneOf Unions, +transformations, middleware, recursive schemas, Declarations, construction, +and successful and failing validation. + +The Effect tuple has a trailing element after its rest element, Zod compilation +does not support recursive schemas or `z.xor`, and Zod has no equivalent +decoding middleware. Those four cases intentionally have no Zod fixture. Zod +compilation only accelerates forward parsing, so the encode case measures its +documented runtime fallback. Zod parsing stands in for Effect construction +because Zod has no separate constructor operation. Valibot parsing with defaults +is used for the same comparison. + +```sh +pnpm runtimeperf-compare compiler-rebuild --base schema-compiler +pnpm runtimeperf-compare compiler-rebuild --base main --family interpreted +pnpm runtimeperf compiler-rebuild +``` + +The first command compares the current working tree with the archived compiler +branch. The second isolates changes to interpreted parsing against main. Schema +and parser construction are outside the steady-state measurements. AOT fixtures +generate their version-specific module in a separate process before loading it. + +The standard paired harness records five alternating rounds, validates every +fixture before and after measurement, uses a common batch within each pair, and +reports bootstrap confidence intervals. Cross-mode rankings are descriptive; +the paired base/head comparison is the regression evidence. + +## Retained heap and first use + +For a current cross-library comparison of retained heap, V8 code memory, peak +RSS and preparation CPU, run: + +```sh +node packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts \ + tmp/schema-compiler-resources.json 5 100,500 +node packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts \ + tmp/schema-compiler-resources.json +``` + +Each sample runs in a fresh process. The report calculates median per-schema +slopes between 100 and 500 distinct schemas, which removes fixed module and +lazy initialization costs. Fixture inputs are released before retained-memory +measurement. Preparation CPU includes adapter creation and first use; AOT also +includes loading and installing the generated module. AOT source generation is +reported separately because it runs at build time. Hot synchronous CPU is +already represented by the normal runtime measurements. + +The older Effect-only probe below remains available for comparisons with its +existing archived results. + +The standalone cost probe accepts a checkout root, mode, operation, shape and +schema count. Run several fresh processes per combination and alternate the +checkout order. Supported modes are `interpreted`, `jit` and `aot`; operations +are `decode`, `invalid`, `is` and `make`; shapes are `struct`, `array`, `transform` +and `default`. + +```sh +node --expose-gc packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts heap "$PWD" jit decode struct 500 +node --expose-gc packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts cold "$PWD" jit decode struct 500 +``` + +Heap measurements exclude schema construction and module imports, and retain +both schema objects and public parser functions. They include operation setup +and first use. Forced GC removes transient parse output and issues. The AOT +generator runs in a separate process, so its retained heap is not attributed to +the runtime application. + +First-use measurements include constructing a fresh schema, installing its +compiler when requested, creating a public parser and calling it. They exclude +module imports and AOT source generation. Repeated shapes can benefit from V8's +source cache, so this is not process startup latency. Treat these timing probes +as exploratory, separately from the paired throughput results. diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts new file mode 100644 index 00000000000..1280c99218d --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const [command, root, mode, operation, shape = "struct", countString = "500"] = process.argv.slice(2) +const count = Number(countString) +const includes = (values: ReadonlyArray, value: string | undefined) => + value !== undefined && values.includes(value) +if (command !== "heap" && command !== "cold" && command !== "generate") { + throw new Error("command must be heap, cold or generate") +} +if (root === undefined) throw new Error("root is required") +if (!includes(["interpreted", "jit", "aot"], mode)) { + throw new Error("mode must be interpreted, jit or aot") +} +if (!includes(["decode", "invalid", "is", "make"], operation)) { + throw new Error("operation must be decode, invalid, is or make") +} +if (!includes(["struct", "array", "transform", "default"], shape)) { + throw new Error("shape must be struct, array, transform or default") +} +if (!Number.isSafeInteger(count) || count <= 0) throw new Error("count must be a positive integer") +const load = (path: string) => import(pathToFileURL(join(root, "packages/effect/src", path + ".ts")).href) +const Schema = await load("Schema") +const Parser = await load("SchemaParser") +const AST = await load("SchemaAST") +const Effect = await load("Effect") +const create = () => { + const struct = Schema.Struct({ name: Schema.String, age: Schema.Number, active: Schema.Boolean }) + return shape === "array" ? Schema.Array(struct) + : shape === "transform" ? Schema.Struct({ value: Schema.NumberFromString }) + : shape === "default" ? Schema.Struct({ value: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }) + : struct +} +const valid = shape === "array" ? Array.from({ length: 32 }, () => ({ name: "Ada", age: 37, active: true })) + : shape === "transform" ? { value: "1" } + : shape === "default" ? { value: 1 } + : { name: "Ada", age: 37, active: true } +const typeValid = shape === "transform" ? { value: 1 } : valid +const input = operation === "invalid" ? { name: "Ada", age: "bad", active: true } + : operation === "make" && shape === "default" ? {} + : operation === "make" || operation === "is" ? typeValid + : valid +const expected = shape === "transform" || shape === "default" ? { value: 1 } : valid + +if (command === "generate") { + const AOT = await load("unstable/schema/SchemaAOTCompiler") + const schema = create() + const targetOperation = operation === "make" ? "make" : operation === "is" ? "is" : "decode" + const ast = targetOperation === "decode" ? schema.ast : AST.toType(schema.ast) + writeFileSync(process.argv[8], AOT.compile([{ ast, operations: [targetOperation] }])) +} else { + for (let i = 0; i < 5; i++) globalThis.gc?.() + const beforeImport = process.memoryUsage().heapUsed + let enable: ((ast: unknown) => void) | undefined + let install: ((asts: Array) => void) | undefined + let directory: string | undefined + if (mode === "jit") { + enable = (await load("unstable/schema/SchemaJITCompiler")).enable + } else if (mode === "aot") { + directory = mkdtempSync(join(root, "packages/effect/.compiler-memory-")) + const generated = join(directory, "generated.mjs") + execFileSync(process.execPath, [fileURLToPath(import.meta.url), "generate", root, mode, operation, shape, countString, generated]) + install = (await import(pathToFileURL(generated).href)).install + } + for (let i = 0; i < 5; i++) globalThis.gc?.() + const moduleBytes = process.memoryUsage().heapUsed - beforeImport + const prepare = (schema: any) => { + const ast = operation === "make" || operation === "is" ? AST.toType(schema.ast) : schema.ast + if (enable) enable(ast) + if (install) install([ast]) + return operation === "make" ? Parser.make(schema) + : operation === "is" ? Parser.is(schema) + : Parser.decodeUnknownSync(schema) + } + const run = (parse: (input: unknown) => unknown) => { + try { + return parse(input) + } catch (error) { + if (operation !== "invalid") throw error + return error + } + } + const validate = (value: unknown) => { + if (operation === "invalid") { + assert.ok(value instanceof Error) + assert.equal(value.message, "Schema validation failed") + assert.equal("cause" in value, true) + } + else if (operation === "is") assert.equal(value, true) + else assert.deepEqual(value, expected) + } + try { + for (let i = 0; i < 20; i++) validate(run(prepare(create()))) + if (command === "heap") { + assert.equal(typeof globalThis.gc, "function") + const schemas = Array.from({ length: count }, create) + for (let i = 0; i < 5; i++) globalThis.gc!() + const before = process.memoryUsage().heapUsed + const parsers = schemas.map((schema) => { + const parse = prepare(schema) + validate(run(parse)) + return parse + }) + for (let i = 0; i < 5; i++) globalThis.gc!() + const after = process.memoryUsage().heapUsed + // Keep schemas and adapters alive across the measurement. + assert.equal(parsers.length, count) + assert.equal(schemas.length, count) + process.stdout.write(JSON.stringify({ mode, operation, shape, count, moduleBytes, bytesPerSchema: (after - before) / count })) + } else if (command === "cold") { + const samples = [] + for (let round = 0; round < 7; round++) { + let value: unknown + const start = process.hrtime.bigint() + for (let i = 0; i < count; i++) value = run(prepare(create())) + samples.push(Number(process.hrtime.bigint() - start) / count) + validate(value) + } + process.stdout.write(JSON.stringify({ mode, operation, shape, count, nsPerSchema: samples })) + } + } finally { + if (directory) rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts new file mode 100644 index 00000000000..2129564b51b --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts @@ -0,0 +1,36 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { fixture, roots } from "./cases.ts" +const directory = mkdtempSync(fileURLToPath(new URL("./.aot-", import.meta.url))) +try { + const file = join(directory, "generated.mjs") + execFileSync(process.execPath, [fileURLToPath(new URL("./generate.mts", import.meta.url)), file]) + const generated = await import(pathToFileURL(file).href) + generated.install(roots) +} finally { + rmSync(directory, { recursive: true, force: true }) +} +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const tuple = () => fixture("tuple") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const oneOf = () => fixture("oneOf") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const middleware = () => fixture("middleware") +export const recursive = () => fixture("recursive") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts new file mode 100644 index 00000000000..c7fe4262222 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts @@ -0,0 +1,124 @@ +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as SchemaAST from "effect/SchemaAST" +import * as SchemaParser from "effect/SchemaParser" +import * as SchemaTransformation from "effect/SchemaTransformation" +import assert from "node:assert/strict" + +export const person = () => Schema.Struct({ name: Schema.String, age: Schema.Number, active: Schema.Boolean }) +export const input = { name: "Ada", age: 37, active: true } +const small = person() +const nested = Schema.Struct({ user: person(), tags: Schema.Array(Schema.String) }) +const array = Schema.Array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const tuple = Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) +const tupleInput = ["head", ...Array.from({ length: 32 }, (_, i) => i), true] +const record = Schema.Record(Schema.String, person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const union = Schema.Union( + Array.from({ length: 8 }, (_, i) => Schema.Struct({ tag: Schema.Literal(i), value: Schema.Number })) +) +const oneOf = Schema.Union([Schema.Struct({ a: Schema.String }), Schema.Struct({ b: Schema.Number })], { + mode: "oneOf" +}) +const fields = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, Schema.NumberFromString])) +const transformed = Schema.Struct(fields) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const checkedTransform = Schema.String.pipe( + Schema.decodeTo( + Schema.Number.check(Schema.isGreaterThan(0)), + SchemaTransformation.transform({ decode: Number, encode: String }) + ) +) +const middleware = small.pipe(Schema.middlewareDecoding((effect) => effect)) +const declaration = Schema.ReadonlySet(person()) +const declarationInput = new Set(arrayInput) +interface Node { + readonly value: number + readonly children: ReadonlyArray +} +const recursive: Schema.Codec = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => recursive)) +}) +const node = (depth: number): Node => ({ + value: depth, + children: depth === 0 ? [] : [node(depth - 1), node(depth - 1)] +}) +const recursiveInput = node(4) +const defaults = Schema.Struct( + Object.fromEntries( + Array.from( + { length: 32 }, + (_, i) => [`v${i}`, Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(i)))] + ) + ) +) + +type Case = { + schema: Schema.Constraint + input: unknown + expected: unknown + operation?: "is" | "make" | "encode" + invalid?: boolean + options?: SchemaAST.ParseOptions +} + +export const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseExtra: { schema: small, input: { ...input, extra: 1 }, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + encode: { schema: small, input, expected: input, operation: "encode" }, + strict: { schema: small, input, expected: input, options: { onExcessProperty: "error" } }, + nested: { schema: nested, input: { user: input, tags: ["a", "b"] }, expected: { user: input, tags: ["a", "b"] } }, + array: { schema: array, input: arrayInput, expected: arrayInput }, + arrayInvalid: { schema: array, input: [...arrayInput, { ...input, age: "bad" }], expected: true, invalid: true }, + tuple: { schema: tuple, input: tupleInput, expected: tupleInput }, + record: { schema: record, input: recordInput, expected: recordInput }, + union: { schema: union, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + oneOf: { schema: oneOf, input: { b: 1 }, expected: { b: 1 } }, + transform: { schema: transformed, input: transformedInput, expected: transformedOutput }, + transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, + middleware: { schema: middleware, input, expected: input }, + recursive: { schema: recursive, input: recursiveInput, expected: recursiveInput }, + declaration: { schema: declaration, input: declarationInput, expected: declarationInput }, + makeStruct: { schema: defaults, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: array, input: arrayInput, expected: arrayInput, operation: "make" }, + makeUnion: { schema: union, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 }, operation: "make" } +} + +export const targets = Object.values(cases).map(({ operation, schema }) => + operation === "encode" + ? { ast: SchemaAST.flip(schema.ast), operations: ["decode"] as const } + : operation === "is" || operation === "make" + ? { ast: SchemaAST.toType(schema.ast), operations: [operation] as const } + : { ast: schema.ast, operations: ["decode"] as const } +) +export const roots = targets.map((target) => target.ast) + +export const fixture = (name: string) => { + const { schema, input, expected, operation, invalid, options } = cases[name] + const parse = operation === "is" ? + SchemaParser.is(schema) + : operation === "make" ? + SchemaParser.make(schema) + : operation === "encode" ? + SchemaParser.encodeUnknownSync(schema as Schema.ConstraintEncoder) + : SchemaParser.decodeUnknownSync(schema as Schema.ConstraintDecoder, options) + return { + run: invalid ? + () => { + try { + parse(input) + return false + } catch { + return true + } + } : + () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts new file mode 100644 index 00000000000..defdd093e59 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts @@ -0,0 +1,5 @@ +import { writeFileSync } from "node:fs" +import { compile } from "effect/unstable/schema/SchemaAOTCompiler" +import { targets } from "./cases.ts" + +writeFileSync(process.argv[2], compile(targets)) diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts new file mode 100644 index 00000000000..7fafae2fb89 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts @@ -0,0 +1,23 @@ +import { fixture } from "./cases.ts" +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const tuple = () => fixture("tuple") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const oneOf = () => fixture("oneOf") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const middleware = () => fixture("middleware") +export const recursive = () => fixture("recursive") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts new file mode 100644 index 00000000000..0cea982625f --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts @@ -0,0 +1,25 @@ +import { enable } from "effect/unstable/schema/SchemaJITCompiler" +import { fixture, roots } from "./cases.ts" +for (const ast of roots) enable(ast) +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const tuple = () => fixture("tuple") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const oneOf = () => fixture("oneOf") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const middleware = () => fixture("middleware") +export const recursive = () => fixture("recursive") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts new file mode 100644 index 00000000000..6d856a3a6ea --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import * as v from "valibot" + +const person = () => v.object({ name: v.string(), age: v.number(), active: v.boolean() }) +const input = { name: "Ada", age: 37, active: true } +const small = person() +const arraySchema = v.array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const recordSchema = v.record(v.string(), person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const unionSchema = v.variant( + "tag", + Array.from({ length: 8 }, (_, i) => v.object({ tag: v.literal(i), value: v.number() })) +) +const transformedSchema = v.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`v${i}`, v.pipe(v.string(), v.transform(Number))]) + ) +) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const defaultsSchema = v.object( + Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, v.optional(v.number(), i)])) +) + +type Case = { + readonly schema: v.BaseSchema> + readonly input: unknown + readonly expected: unknown + readonly operation?: "is" | "make" + readonly invalid?: boolean +} + +const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, + record: { schema: recordSchema, input: recordInput, expected: recordInput }, + union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, + makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: arraySchema, input: arrayInput, expected: arrayInput, operation: "make" } +} + +const fixture = (name: string) => { + const { schema, input, expected, operation, invalid } = cases[name] + const parse = operation === "is" + ? (input: unknown) => v.is(schema, input) + : (input: unknown) => v.parse(schema, input) + return { + run: invalid + ? () => { + try { + parse(input) + return false + } catch { + return true + } + } + : () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} + +export const parseValid = () => fixture("parseValid") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const array = () => fixture("array") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const transform = () => fixture("transform") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts new file mode 100644 index 00000000000..7e270ac7628 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" + +const person = () => z.object({ name: z.string(), age: z.number(), active: z.boolean() }) +const input = { name: "Ada", age: 37, active: true } +const small = person() +const nestedSchema = z.object({ user: person(), tags: z.array(z.string()) }) +const arraySchema = z.array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const recordSchema = z.record(z.string(), person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const unionSchema = z.union( + Array.from({ length: 8 }, (_, i) => z.object({ tag: z.literal(i), value: z.number() })) as [ + z.ZodObject, + z.ZodObject, + ...Array + ] +) +const fields = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`v${i}`, z.string().transform(Number)]) +) +const transformedSchema = z.object(fields) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const checkedTransform = z.string().transform(Number).pipe(z.number().positive()) +const declarationSchema = z.set(person()) +const declarationInput = new Set(arrayInput) +const defaultsSchema = z.object( + Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, z.number().default(i)])) +) + +type Case = { + readonly schema: z.ZodType + readonly input: unknown + readonly expected: unknown + readonly operation?: "is" | "make" | "encode" + readonly invalid?: boolean +} + +const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseExtra: { schema: small, input: { ...input, extra: 1 }, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + encode: { schema: small, input, expected: input, operation: "encode" }, + strict: { + schema: z.strictObject({ name: z.string(), age: z.number(), active: z.boolean() }), + input, + expected: input + }, + nested: { + schema: nestedSchema, + input: { user: input, tags: ["a", "b"] }, + expected: { user: input, tags: ["a", "b"] } + }, + array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, + arrayInvalid: { + schema: arraySchema, + input: [...arrayInput, { ...input, age: "bad" }], + expected: true, + invalid: true + }, + record: { schema: recordSchema, input: recordInput, expected: recordInput }, + union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, + transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, + declaration: { schema: declarationSchema, input: declarationInput, expected: declarationInput }, + makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: arraySchema, input: arrayInput, expected: arrayInput, operation: "make" }, + makeUnion: { + schema: unionSchema, + input: { tag: 7, value: 1 }, + expected: { tag: 7, value: 1 }, + operation: "make" + } +} + +const makeFixture = ( + { input, expected, invalid }: Case, + parse: (input: unknown) => unknown +) => { + return { + run: invalid ? + () => { + try { + parse(input) + return false + } catch { + return true + } + } : + () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} + +export const compiledFixture = (name: string) => { + const value = cases[name] + const schema = z.compile(value.schema, { strict: true }) + const parse = value.operation === "is" ? + (input: unknown) => z.validate(schema, input) + : value.operation === "encode" ? + (input: unknown) => z.encode(schema, input) + : (input: unknown) => schema.parse(input) + return makeFixture(value, parse) +} + +export const jitlessFixture = (name: string) => { + const value = cases[name] + const parse = value.operation === "is" ? + (input: unknown) => z.validate(value.schema, input, { jitless: true }) + : value.operation === "encode" ? + (input: unknown) => z.encode(value.schema, input, { jitless: true }) + : (input: unknown) => value.schema.parse(input, { jitless: true }) + return makeFixture(value, parse) +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts new file mode 100644 index 00000000000..881019e8103 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts @@ -0,0 +1,20 @@ +import { compiledFixture as fixture } from "./zod-cases.ts" + +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts new file mode 100644 index 00000000000..cda75669642 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts @@ -0,0 +1,12 @@ +import { jitlessFixture as fixture } from "./zod-cases.ts" + +export const parseValid = () => fixture("parseValid") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const array = () => fixture("array") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const transform = () => fixture("transform") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts new file mode 100644 index 00000000000..99e2aa6f0a1 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts @@ -0,0 +1,235 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +type Sample = { + readonly aotBuild?: { + readonly generateCpu: { readonly cpuMicros: number } + readonly maxRssBytes: number + readonly schemaCpu: { readonly cpuMicros: number } + readonly sourceBytes: number + } + readonly case: string + readonly count: number + readonly cpu: { + readonly aotModule?: { readonly cpuMicros: number } + readonly firstCall: { readonly cpuMicros: number } + readonly prepare: { readonly cpuMicros: number } + } + readonly implementation: string + readonly memory: { + readonly compilerPerSchema: Memory + readonly module: Memory + readonly retainedLibraryPerSchema: Memory + } + readonly round: number +} + +type Memory = { + readonly bytecodeBytes: number + readonly codeBytes: number + readonly externalSourceBytes: number + readonly heapBytes: number + readonly maxRssBytes: number +} + +type Report = { + readonly environment: Record + readonly measurement: { + readonly cases: ReadonlyArray + readonly counts: ReadonlyArray + readonly implementations: ReadonlyArray + readonly rounds: number + } + readonly results: ReadonlyArray +} + +const path = resolve(process.argv[2] ?? "tmp/schema-compiler-resources.json") +const report = JSON.parse(readFileSync(path, "utf8")) as Report +const lowCount = Math.min(...report.measurement.counts) +const highCount = Math.max(...report.measurement.counts) +if (lowCount === highCount) throw new Error("the report needs at least two schema counts") + +const median = (values: ReadonlyArray) => { + const sorted = values.toSorted((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +const samples = (implementation: string, caseName: string) => { + const pairs = [] + for (let round = 0; round < report.measurement.rounds; round++) { + const low = report.results.find((sample) => + sample.round === round && sample.implementation === implementation && sample.case === caseName && + sample.count === lowCount + ) + const high = report.results.find((sample) => + sample.round === round && sample.implementation === implementation && sample.case === caseName && + sample.count === highCount + ) + if (low === undefined || high === undefined) throw new Error(`missing samples for ${implementation}/${caseName}`) + pairs.push({ high, low }) + } + return pairs +} + +const perSchemaSlope = ( + implementation: string, + caseName: string, + value: (sample: Sample) => number +) => median(samples(implementation, caseName).map(({ high, low }) => + (value(high) * highCount - value(low) * lowCount) / (highCount - lowCount))) + +const totalSlope = ( + implementation: string, + caseName: string, + value: (sample: Sample) => number +) => median(samples(implementation, caseName).map(({ high, low }) => + (value(high) - value(low)) / (highCount - lowCount))) + +const heap = (implementation: string, caseName: string) => + perSchemaSlope(implementation, caseName, (sample) => sample.memory.retainedLibraryPerSchema.heapBytes) / 1024 +const code = (implementation: string, caseName: string) => + perSchemaSlope( + implementation, + caseName, + (sample) => { + const memory = sample.memory.retainedLibraryPerSchema + return memory.bytecodeBytes + memory.codeBytes + memory.externalSourceBytes + } + ) / 1024 +const peakRss = (implementation: string, caseName: string) => + perSchemaSlope(implementation, caseName, (sample) => sample.memory.retainedLibraryPerSchema.maxRssBytes) / 1024 +const startupCpu = (implementation: string, caseName: string) => + totalSlope( + implementation, + caseName, + (sample) => + sample.cpu.prepare.cpuMicros + sample.cpu.firstCall.cpuMicros + + (implementation === "effect-aot" ? sample.cpu.aotModule?.cpuMicros ?? 0 : 0) + ) + +const fixedDelta = ( + left: string, + right: string, + value: (sample: Sample) => number +) => { + const differences = [] + for (const caseName of report.measurement.cases) { + for (let round = 0; round < report.measurement.rounds; round++) { + for (const count of report.measurement.counts) { + const find = (implementation: string) => report.results.find((sample) => + sample.implementation === implementation && sample.case === caseName && sample.round === round && + sample.count === count + )! + differences.push(value(find(left)) - value(find(right))) + } + } + } + return median(differences) +} + +const names: Record = { + "array-decode": "Array of 32 Structs", + "default-make": "Construct 32 defaults", + "struct-decode": "Struct decode", + "struct-invalid": "Struct invalid decode", + "struct-is": "Struct guard", + "transform-decode": "32 transformations", + "union-decode": "Discriminated Union (8)" +} + +const table = ( + implementations: ReadonlyArray, + value: (implementation: string, caseName: string) => number, + unit: string, + digits: number +) => { + const labels: Record = { + "effect-aot": "Effect AOT", + "effect-interpreted": "Effect interpreted", + "effect-jit": "Effect JIT", + "valibot": "Valibot", + "zod-compiled": "Zod compile", + "zod-jitless": "Zod jitless" + } + const lines = [ + `| Case | ${implementations.map((implementation) => labels[implementation]).join(" | ")} |`, + `|---|${implementations.map(() => "---:").join("|")}|` + ] + for (const caseName of report.measurement.cases) { + lines.push( + `| ${names[caseName]} | ${ + implementations.map((implementation) => `${value(implementation, caseName).toFixed(digits)} ${unit}`).join(" | ") + } |` + ) + } + return lines.join("\n") +} + +const interpreted = ["effect-interpreted", "valibot", "zod-jitless"] +const compiled = ["effect-jit", "effect-aot", "zod-compiled"] +const environment = report.environment + +process.stdout.write(`# Schema compiler resource comparison + +Incremental costs are median per-schema slopes between ${lowCount} and ${highCount} distinct schemas across ${ + report.measurement.rounds +} fresh-process rounds. Lower is better. Fixed module imports and fixture inputs are excluded. + +Environment: Node ${environment.node}, V8 ${environment.v8}, ${environment.cpu}, ${environment.platform} ${ + environment.arch +}; Valibot ${environment.valibot}, Zod ${environment.zod}. + +Importing the JIT compiler retains ${ + (fixedDelta("effect-jit", "effect-interpreted", (sample) => sample.memory.module.heapBytes) / 1024).toFixed(1) +} KiB of additional fixed JavaScript heap and ${ + (fixedDelta("effect-jit", "effect-interpreted", (sample) => + sample.memory.module.codeBytes + sample.memory.module.bytecodeBytes + sample.memory.module.externalSourceBytes) / 1024) + .toFixed(1) +} KiB of V8 code, bytecode and source in this source-tree setup. + +## Retained JavaScript heap + +### Interpreted + +${table(interpreted, heap, "KiB/schema", 2)} + +### Compiled + +${table(compiled, heap, "KiB/schema", 2)} + +## Retained V8 code, bytecode and source + +${table(compiled, code, "KiB/schema", 2)} + +## Runtime preparation CPU + +This includes adapter creation and the first call. AOT also includes importing and installing the generated module. AOT build-time generation is excluded. + +### Interpreted + +${table(interpreted, startupCpu, "µs/schema", 1)} + +### Compiled + +${table(compiled, startupCpu, "µs/schema", 1)} + +## Peak runtime RSS growth + +${table(compiled, peakRss, "KiB/schema", 1)} + +## AOT build cost + +| Case | CPU | Generated source | Peak RSS growth | +|---|---:|---:|---:| +${report.measurement.cases.map((caseName) => { + const cpu = totalSlope( + "effect-aot", + caseName, + (sample) => (sample.aotBuild?.schemaCpu.cpuMicros ?? 0) + (sample.aotBuild?.generateCpu.cpuMicros ?? 0) + ) + const source = totalSlope("effect-aot", caseName, (sample) => sample.aotBuild?.sourceBytes ?? 0) / 1024 + const rss = totalSlope("effect-aot", caseName, (sample) => sample.aotBuild?.maxRssBytes ?? 0) / 1024 + return `| ${names[caseName]} | ${cpu.toFixed(1)} µs/schema | ${source.toFixed(2)} KiB/schema | ${rss.toFixed(1)} KiB/schema |` +}).join("\n")} +`) diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts new file mode 100644 index 00000000000..8de96f956fe --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts @@ -0,0 +1,479 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import * as v8 from "node:v8" + +const implementations = [ + "effect-interpreted", + "effect-jit", + "effect-aot", + "valibot", + "zod-jitless", + "zod-compiled" +] as const +type Implementation = typeof implementations[number] + +const cases = [ + "struct-decode", + "struct-invalid", + "struct-is", + "array-decode", + "union-decode", + "transform-decode", + "default-make" +] as const +type CaseName = typeof cases[number] + +type BuiltCase = { + readonly schema: unknown + input: unknown + expected: unknown + readonly invalid?: boolean + readonly ast?: unknown +} + +type Parser = (input: unknown) => unknown + +const [command, root, implementationArgument, caseArgument, countArgument, output] = process.argv.slice(2) +const includes = (values: ReadonlyArray, value: string | undefined): value is A => + value !== undefined && values.includes(value as A) + +if (command !== "measure" && command !== "generate") { + throw new Error("command must be measure or generate") +} +if (root === undefined) throw new Error("root is required") +if (!includes(implementations, implementationArgument)) { + throw new Error(`implementation must be one of: ${implementations.join(", ")}`) +} +if (!includes(cases, caseArgument)) { + throw new Error(`case must be one of: ${cases.join(", ")}`) +} +const implementation: Implementation = implementationArgument +const caseName: CaseName = caseArgument +const count = Number(countArgument) +if (!Number.isSafeInteger(count) || count <= 0) throw new Error("count must be a positive integer") + +const forceGc = () => { + assert.equal(typeof globalThis.gc, "function", "run this probe with --expose-gc") + for (let i = 0; i < 5; i++) globalThis.gc!() +} + +type CpuSample = { + readonly cpuMicros: number + readonly systemMicros: number + readonly userMicros: number + readonly wallNanos: number +} + +const measureCpu = async (f: () => A | Promise): Promise => { + const cpuStart = process.cpuUsage() + const wallStart = process.hrtime.bigint() + const value = await f() + const wallNanos = Number(process.hrtime.bigint() - wallStart) + const cpu = process.cpuUsage(cpuStart) + return [value, { + cpuMicros: cpu.user + cpu.system, + systemMicros: cpu.system, + userMicros: cpu.user, + wallNanos + }] +} + +type MemorySample = { + readonly bytecodeBytes: number + readonly codeBytes: number + readonly externalSourceBytes: number + readonly heapBytes: number + readonly maxRssBytes: number + readonly rssBytes: number +} + +const memory = (): MemorySample => { + const usage = process.memoryUsage() + const code = v8.getHeapCodeStatistics() + return { + bytecodeBytes: code.bytecode_and_metadata_size, + codeBytes: code.code_and_metadata_size, + externalSourceBytes: code.external_script_source_size, + heapBytes: usage.heapUsed, + maxRssBytes: process.resourceUsage().maxRSS * 1024, + rssBytes: usage.rss + } +} + +const delta = (after: MemorySample, before: MemorySample) => ({ + bytecodeBytes: after.bytecodeBytes - before.bytecodeBytes, + codeBytes: after.codeBytes - before.codeBytes, + externalSourceBytes: after.externalSourceBytes - before.externalSourceBytes, + heapBytes: after.heapBytes - before.heapBytes, + maxRssBytes: after.maxRssBytes - before.maxRssBytes, + rssBytes: after.rssBytes - before.rssBytes +}) + +const perSchema = (sample: ReturnType) => ({ + bytecodeBytes: sample.bytecodeBytes / count, + codeBytes: sample.codeBytes / count, + externalSourceBytes: sample.externalSourceBytes / count, + heapBytes: sample.heapBytes / count, + maxRssBytes: sample.maxRssBytes / count, + rssBytes: sample.rssBytes / count +}) + +const loadEffectModule = (path: string) => + import(pathToFileURL(join(root, "packages/effect/src", `${path}.ts`)).href) + +const loadEffectSchemaModules = async () => ({ + Effect: await loadEffectModule("Effect"), + Schema: await loadEffectModule("Schema"), + SchemaAST: await loadEffectModule("SchemaAST") +}) + +type EffectSchemaModules = Awaited> + +const buildEffectCase = ({ Effect, Schema, SchemaAST }: EffectSchemaModules, index: number): BuiltCase => { + const suffix = String(index) + const name = `name${suffix}` + const age = `age${suffix}` + const active = `active${suffix}` + const person = () => Schema.Struct({ [name]: Schema.String, [age]: Schema.Number, [active]: Schema.Boolean }) + const value = { [name]: "Ada", [age]: 37, [active]: true } + switch (caseName) { + case "struct-decode": { + const schema = person() + return { schema, ast: schema.ast, input: value, expected: value } + } + case "struct-invalid": { + const schema = person() + return { schema, ast: schema.ast, input: { ...value, [age]: "bad" }, expected: true, invalid: true } + } + case "struct-is": { + const schema = person() + return { schema, ast: SchemaAST.toType(schema.ast), input: value, expected: true } + } + case "array-decode": { + const schema = Schema.Array(person()) + const input = Array.from({ length: 32 }, () => ({ ...value })) + return { schema, ast: schema.ast, input, expected: input } + } + case "union-decode": { + const tag = `tag${suffix}` + const schema = Schema.Union( + Array.from({ length: 8 }, (_, member) => + Schema.Struct({ [tag]: Schema.Literal(member), [`value${suffix}`]: Schema.Number })) + ) + const input = { [tag]: 7, [`value${suffix}`]: 1 } + return { schema, ast: schema.ast, input, expected: input } + } + case "transform-decode": { + const schema = Schema.Struct( + Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, Schema.NumberFromString])) + ) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, ast: schema.ast, input, expected } + } + case "default-make": { + const schema = Schema.Struct( + Object.fromEntries( + Array.from( + { length: 32 }, + (_, field) => [ + `v${suffix}_${field}`, + Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(field))) + ] + ) + ) + ) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, ast: SchemaAST.toType(schema.ast), input: {}, expected } + } + } +} + +const loadEffect = async (jit: boolean) => { + const modules = await loadEffectSchemaModules() + const SchemaParser = await loadEffectModule("SchemaParser") + const enable = jit ? (await loadEffectModule("unstable/schema/SchemaJITCompiler")).enable : undefined + + const prepare = (built: ReadonlyArray): ReadonlyArray => { + return built.map(({ ast, schema }) => { + if (enable !== undefined) enable(ast) + if (caseName === "struct-is") return SchemaParser.is(schema) + if (caseName === "default-make") return SchemaParser.make(schema) + return SchemaParser.decodeUnknownSync(schema) + }) + } + + return { build: (index: number) => buildEffectCase(modules, index), prepare } +} + +const loadZod = async (compiled: boolean) => { + const z = await import("zod/v4") + + const build = (index: number): BuiltCase => { + const suffix = String(index) + const name = `name${suffix}` + const age = `age${suffix}` + const active = `active${suffix}` + const person = () => z.object({ [name]: z.string(), [age]: z.number(), [active]: z.boolean() }) + const value = { [name]: "Ada", [age]: 37, [active]: true } + switch (caseName) { + case "struct-decode": + return { schema: person(), input: value, expected: value } + case "struct-invalid": + return { schema: person(), input: { ...value, [age]: "bad" }, expected: true, invalid: true } + case "struct-is": + return { schema: person(), input: value, expected: true } + case "array-decode": { + const input = Array.from({ length: 32 }, () => ({ ...value })) + return { schema: z.array(person()), input, expected: input } + } + case "union-decode": { + const tag = `tag${suffix}` + const schema = z.union( + Array.from({ length: 8 }, (_, member) => + z.object({ [tag]: z.literal(member), [`value${suffix}`]: z.number() })) as [ + ReturnType, + ReturnType, + ...Array> + ] + ) + const input = { [tag]: 7, [`value${suffix}`]: 1 } + return { schema, input, expected: input } + } + case "transform-decode": { + const schema = z.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, z.string().transform(Number)]) + ) + ) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input, expected } + } + case "default-make": { + const schema = z.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, z.number().default(field)]) + ) + ) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input: {}, expected } + } + } + } + + const prepare = (built: ReadonlyArray): ReadonlyArray => { + return built.map(({ schema }) => { + const target = compiled ? z.compile(schema, { strict: true }) : schema + if (caseName === "struct-is") { + return compiled + ? (input: unknown) => z.validate(target, input) + : (input: unknown) => z.validate(target, input, { jitless: true }) + } + return compiled + ? (input: unknown) => target.parse(input) + : (input: unknown) => target.parse(input, { jitless: true }) + }) + } + + return { build, prepare } +} + +const loadValibot = async () => { + const v = await import("valibot") + + const build = (index: number): BuiltCase => { + const suffix = String(index) + const name = `name${suffix}` + const age = `age${suffix}` + const active = `active${suffix}` + const person = () => v.object({ [name]: v.string(), [age]: v.number(), [active]: v.boolean() }) + const value = { [name]: "Ada", [age]: 37, [active]: true } + switch (caseName) { + case "struct-decode": + return { schema: person(), input: value, expected: value } + case "struct-invalid": + return { schema: person(), input: { ...value, [age]: "bad" }, expected: true, invalid: true } + case "struct-is": + return { schema: person(), input: value, expected: true } + case "array-decode": { + const input = Array.from({ length: 32 }, () => ({ ...value })) + return { schema: v.array(person()), input, expected: input } + } + case "union-decode": { + const tag = `tag${suffix}` + const schema = v.variant( + tag, + Array.from({ length: 8 }, (_, member) => + v.object({ [tag]: v.literal(member), [`value${suffix}`]: v.number() })) + ) + const input = { [tag]: 7, [`value${suffix}`]: 1 } + return { schema, input, expected: input } + } + case "transform-decode": { + const schema = v.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [ + `v${suffix}_${field}`, + v.pipe(v.string(), v.transform(Number)) + ]) + ) + ) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input, expected } + } + case "default-make": { + const schema = v.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, v.optional(v.number(), field)]) + ) + ) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input: {}, expected } + } + } + } + + const prepare = (built: ReadonlyArray): ReadonlyArray => { + return built.map(({ schema }) => + caseName === "struct-is" + ? (input: unknown) => v.is(schema, input) + : (input: unknown) => v.parse(schema, input)) + } + + return { build, prepare } +} + +const runParser = (parser: Parser, built: BuiltCase): unknown => { + if (!built.invalid) return parser(built.input) + try { + parser(built.input) + return false + } catch { + return true + } +} + +const validate = (actual: unknown, built: BuiltCase) => assert.deepEqual(actual, built.expected) + +if (command === "generate") { + if (output === undefined) throw new Error("output is required for generate") + const [modules, moduleCpu] = await measureCpu(async () => { + return { + ...await loadEffectSchemaModules(), + AOT: await loadEffectModule("unstable/schema/SchemaAOTCompiler") + } + }) + const [built, schemaCpu] = await measureCpu(() => + Array.from({ length: count }, (_, index) => buildEffectCase(modules, index).ast) + ) + const operation = caseName === "struct-is" ? "is" : caseName === "default-make" ? "make" : "decode" + const [source, generateCpu] = await measureCpu(() => + modules.AOT.compile(built.map((ast) => ({ ast, operations: [operation] })))) + writeFileSync(output, source) + process.stdout.write(JSON.stringify({ + generateCpu, + heapBytes: process.memoryUsage().heapUsed, + maxRssBytes: process.resourceUsage().maxRSS * 1024, + moduleCpu, + schemaCpu, + sourceBytes: Buffer.byteLength(source) + })) +} else { + forceGc() + const beforeModule = memory() + const [library, moduleCpu] = await measureCpu(() => { + switch (implementation) { + case "effect-interpreted": + return loadEffect(false) + case "effect-jit": + case "effect-aot": + return loadEffect(implementation === "effect-jit") + case "zod-jitless": + case "zod-compiled": + return loadZod(implementation === "zod-compiled") + case "valibot": + return loadValibot() + } + }) + forceGc() + const afterModule = memory() + + const [built, schemaCpu] = await measureCpu(() => Array.from({ length: count }, (_, index) => library.build(index))) + forceGc() + const afterSchemas = memory() + + let aotBuild: unknown + let aotModuleCpu: CpuSample | undefined + let directory: string | undefined + let parsers: ReadonlyArray + let prepareCpu: CpuSample + try { + if (implementation === "effect-aot") { + directory = mkdtempSync(join(root, "packages/effect/.compiler-resources-")) + const generated = join(directory, "generated.mjs") + aotBuild = JSON.parse(execFileSync( + process.execPath, + ["--expose-gc", fileURLToPath(import.meta.url), "generate", root, implementation, caseName, String(count), generated], + { encoding: "utf8" } + )) + const [installed, measuredModuleCpu] = await measureCpu(async () => { + const generatedModule = await import(pathToFileURL(generated).href) + generatedModule.install(built.map((value) => value.ast)) + }) + void installed + aotModuleCpu = measuredModuleCpu + } + const prepared = await measureCpu(() => library.prepare(built)) + parsers = prepared[0] + prepareCpu = prepared[1] + forceGc() + const afterPrepare = memory() + + const [results, firstCallCpu] = await measureCpu(() => parsers.map((parser, index) => runParser(parser, built[index]))) + results.forEach((actual, index) => validate(actual, built[index])) + results.length = 0 + forceGc() + const afterFirstCall = memory() + + for (const value of built) { + value.input = undefined + value.expected = undefined + } + forceGc() + const afterRelease = memory() + + assert.equal(built.length, count) + assert.equal(parsers.length, count) + process.stdout.write(JSON.stringify({ + aotBuild, + case: caseName, + count, + cpu: { + aotModule: aotModuleCpu, + firstCall: firstCallCpu, + module: moduleCpu, + prepare: prepareCpu, + schema: schemaCpu + }, + implementation, + memory: { + compilerPerSchema: perSchema(delta(afterFirstCall, afterSchemas)), + firstCallPerSchema: perSchema(delta(afterFirstCall, afterPrepare)), + module: delta(afterModule, beforeModule), + preparePerSchema: perSchema(delta(afterPrepare, afterSchemas)), + retainedLibraryPerSchema: perSchema(delta(afterRelease, afterModule)), + schemaPerSchema: perSchema(delta(afterSchemas, afterModule)), + totalWithFixturePerSchema: perSchema(delta(afterFirstCall, afterModule)) + } + })) + } finally { + if (directory !== undefined) { + rmSync(directory, { recursive: true, force: true }) + } + } +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts new file mode 100644 index 00000000000..daa0ccb4721 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts @@ -0,0 +1,93 @@ +import { execFileSync } from "node:child_process" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import os from "node:os" +import { dirname, join, resolve } from "node:path" +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" + +const implementations = [ + "effect-interpreted", + "valibot", + "zod-jitless", + "effect-jit", + "effect-aot", + "zod-compiled" +] as const + +const cases = [ + "struct-decode", + "struct-invalid", + "struct-is", + "array-decode", + "union-decode", + "transform-decode", + "default-make" +] as const + +const [outputArgument = "tmp/schema-compiler-resources.json", roundsArgument = "5", countsArgument = "100,500"] = + process.argv.slice(2) +const rounds = Number(roundsArgument) +const counts = countsArgument.split(",").map(Number) +if (!Number.isSafeInteger(rounds) || rounds <= 0) throw new Error("rounds must be a positive integer") +if (counts.some((count) => !Number.isSafeInteger(count) || count <= 0)) { + throw new Error("counts must be comma-separated positive integers") +} + +const root = process.cwd() +const output = resolve(outputArgument) +const worker = fileURLToPath(new URL("./resources.mts", import.meta.url)) +const require = createRequire(import.meta.url) +const packageVersion = (name: string) => { + let directory = dirname(require.resolve(name)) + while (true) { + try { + const metadata = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) + if (metadata.name === name) return metadata.version as string + } catch { + // Continue at the parent directory. + } + const parent = dirname(directory) + if (parent === directory) throw new Error(`package.json not found for ${name}`) + directory = parent + } +} + +const results: Array = [] +for (let round = 0; round < rounds; round++) { + const orderedImplementations = round % 2 === 0 ? implementations : implementations.toReversed() + const orderedCounts = round % 2 === 0 ? counts : counts.toReversed() + for (const count of orderedCounts) { + for (const caseName of cases) { + for (const implementation of orderedImplementations) { + const sample = JSON.parse(execFileSync( + process.execPath, + ["--expose-gc", worker, "measure", root, implementation, caseName, String(count)], + { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 } + )) + results.push({ round, ...sample }) + } + } + process.stderr.write(`resource round ${round + 1}/${rounds}, count ${count} complete\n`) + } +} + +mkdirSync(dirname(output), { recursive: true }) +writeFileSync(output, JSON.stringify({ + environment: { + arch: process.arch, + cpu: os.cpus()[0]?.model ?? "unknown", + node: process.version, + platform: process.platform, + valibot: packageVersion("valibot"), + v8: process.versions.v8, + zod: packageVersion("zod") + }, + measurement: { + cases, + counts, + implementations, + rounds + }, + results +}, null, 2)) +process.stdout.write(`${output}\n`) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts new file mode 100644 index 00000000000..1a402112555 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts @@ -0,0 +1,17 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { roots } from "./cases.ts" + +const directory = mkdtempSync(fileURLToPath(new URL("./.aot-", import.meta.url))) +try { + const file = join(directory, "generated.mjs") + execFileSync(process.execPath, [fileURLToPath(new URL("./generate.mts", import.meta.url)), file]) + const generated = await import(pathToFileURL(file).href) + generated.install(roots) +} finally { + rmSync(directory, { recursive: true, force: true }) +} + +export { isExtraValid, isInvalid, isValid, parseExtraValid, parseInvalid, parseValid } from "./cases.ts" diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts new file mode 100644 index 00000000000..ba38d5ea0b0 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts @@ -0,0 +1,65 @@ +import * as Schema from "effect/Schema" +import * as SchemaAST from "effect/SchemaAST" +import * as SchemaParser from "effect/SchemaParser" +import assert from "node:assert/strict" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +export const schema = 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 + }) +}) + +export const targets = [ + { ast: schema.ast, operations: ["decode"] }, + { ast: SchemaAST.toType(schema.ast), operations: ["is"] }, + { ast: SchemaAST.flip(schema.ast), operations: ["decode"] } +] as const +export const roots = targets.map((target) => target.ast) + +const parseCase = (input: unknown, invalid = false) => () => { + const parse = SchemaParser.decodeUnknownSync(schema) + return { + run: invalid + ? () => { + try { + parse(input) + return false + } catch { + return true + } + } + : () => parse(input), + validate: invalid + ? (result: unknown) => assert.equal(result, true) + : (result: unknown) => assert.deepEqual(result, validData) + } +} + +const guardCase = (input: unknown, expected: boolean) => () => { + const guard = SchemaParser.is(schema) + return { + run: expected + ? () => { + if (!guard(input)) throw new Error("Invalid") + return true + } + : () => guard(input), + validate: (result: unknown) => assert.equal(result, expected) + } +} + +export const parseValid = parseCase(validData) +export const parseExtraValid = parseCase(validDataWithExtras) +export const parseInvalid = parseCase(invalidData, true) +export const isValid = guardCase(validData, true) +export const isExtraValid = guardCase(validDataWithExtras, true) +export const isInvalid = guardCase(invalidData, false) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/data.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/data.ts new file mode 100644 index 00000000000..d247f4eb016 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/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/fixtures/generate.mts b/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts new file mode 100644 index 00000000000..defdd093e59 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts @@ -0,0 +1,5 @@ +import { writeFileSync } from "node:fs" +import { compile } from "effect/unstable/schema/SchemaAOTCompiler" +import { targets } from "./cases.ts" + +writeFileSync(process.argv[2], compile(targets)) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts new file mode 100644 index 00000000000..448732fdabb --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts @@ -0,0 +1 @@ +export { isExtraValid, isInvalid, isValid, parseExtraValid, parseInvalid, parseValid } from "./cases.ts" diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts new file mode 100644 index 00000000000..005d5588a12 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts @@ -0,0 +1,6 @@ +import { enable } from "effect/unstable/schema/SchemaJITCompiler" +import { roots } from "./cases.ts" + +for (const ast of roots) enable(ast) + +export { isExtraValid, isInvalid, isValid, parseExtraValid, parseInvalid, parseValid } from "./cases.ts" diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts new file mode 100644 index 00000000000..4de6e0cb250 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import * as v from "valibot" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +const shape = { + number: v.number(), + negNumber: v.number(), + maxNumber: v.number(), + string: v.string(), + longString: v.string(), + boolean: v.boolean(), + deeplyNested: v.object({ + foo: v.string(), + num: v.number(), + bool: v.boolean() + }) +} + +const parseSchema = v.object(shape) +const guardSchema = v.looseObject({ ...shape, deeplyNested: v.looseObject(shape.deeplyNested.entries) }) + +const parseCase = (input: unknown, invalid = false) => () => ({ + run: invalid + ? () => { + try { + v.parse(parseSchema, input) + return false + } catch { + return true + } + } + : () => v.parse(parseSchema, input), + validate: invalid + ? (result: unknown) => assert.equal(result, true) + : (result: unknown) => assert.deepEqual(result, validData) +}) + +const guardCase = (input: unknown, expected: boolean) => () => ({ + run: expected + ? () => { + if (!v.is(guardSchema, input)) throw new Error("Invalid") + return true + } + : () => v.is(guardSchema, input), + validate: (result: unknown) => assert.equal(result, expected) +}) + +export const parseValid = parseCase(validData) +export const parseExtraValid = parseCase(validDataWithExtras) +export const parseInvalid = parseCase(invalidData, true) +export const isValid = guardCase(validData, true) +export const isExtraValid = guardCase(validDataWithExtras, true) +export const isInvalid = guardCase(invalidData, false) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts new file mode 100644 index 00000000000..245da3a858b --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +const makeShape = () => ({ + 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 makeParseSchema = () => z.object(makeShape()) +const makeGuardSchema = () => { + const shape = makeShape() + return z.object({ ...shape, deeplyNested: shape.deeplyNested.passthrough() }).passthrough() +} + +const parseCase = ( + compile: (schema: ReturnType) => (input: unknown) => unknown, + input: unknown, + invalid = false +) => +() => { + const parse = compile(makeParseSchema()) + return { + run: invalid + ? () => { + try { + parse(input) + return false + } catch { + return true + } + } + : () => parse(input), + validate: invalid + ? (result: unknown) => assert.equal(result, true) + : (result: unknown) => assert.deepEqual(result, validData) + } +} + +const guardCase = ( + compile: (schema: ReturnType) => (input: unknown) => boolean, + input: unknown, + expected: boolean +) => +() => { + const validate = compile(makeGuardSchema()) + return { + run: expected + ? () => { + if (!validate(input)) throw new Error("Invalid") + return true + } + : () => validate(input), + validate: (result: unknown) => assert.equal(result, expected) + } +} + +const assertParseCase = ( + compile: (schema: ReturnType) => (input: unknown) => unknown, + input: unknown +) => +() => { + const parse = compile(makeGuardSchema()) + return { + run: () => { + parse(input) + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +const parse = (schema: ReturnType) => (input: unknown) => schema.parse(input) +const parseJitless = (schema: ReturnType) => (input: unknown) => + schema.parse(input, { jitless: true }) +const parseCompiled = (schema: ReturnType) => { + const compiled = z.compile(schema, { strict: true }) + return (input: unknown) => compiled.parse(input) +} +const validate = (schema: ReturnType) => (input: unknown) => z.validate(schema, input) +const validateJitless = (schema: ReturnType) => (input: unknown) => + z.validate(schema, input, { jitless: true }) +const validateCompiled = (schema: ReturnType) => { + const compiled = z.compile(schema, { strict: true }) + return (input: unknown) => z.validate(compiled, input) +} +const assertParse = (schema: ReturnType) => (input: unknown) => schema.parse(input) + +export const parseValid = parseCase(parse, validData) +export const parseExtraValid = parseCase(parse, validDataWithExtras) +export const parseInvalid = parseCase(parse, invalidData, true) +export const parseJitlessValid = parseCase(parseJitless, validData) +export const parseJitlessExtraValid = parseCase(parseJitless, validDataWithExtras) +export const parseJitlessInvalid = parseCase(parseJitless, invalidData, true) +export const parseCompiledValid = parseCase(parseCompiled, validData) +export const parseCompiledExtraValid = parseCase(parseCompiled, validDataWithExtras) +export const parseCompiledInvalid = parseCase(parseCompiled, invalidData, true) +export const isValid = guardCase(validate, validData, true) +export const isExtraValid = guardCase(validate, validDataWithExtras, true) +export const isInvalid = guardCase(validate, invalidData, false) +export const isJitlessValid = guardCase(validateJitless, validData, true) +export const isJitlessExtraValid = guardCase(validateJitless, validDataWithExtras, true) +export const isJitlessInvalid = guardCase(validateJitless, invalidData, false) +export const isCompiledValid = guardCase(validateCompiled, validData, true) +export const isCompiledExtraValid = guardCase(validateCompiled, validDataWithExtras, true) +export const isCompiledInvalid = guardCase(validateCompiled, invalidData, false) +export const assertParseValid = assertParseCase(assertParse, validData) +export const assertParseExtraValid = assertParseCase(assertParse, validDataWithExtras) diff --git a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts index b21c0dd2d6a..06ddb89a545 100644 --- a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts +++ b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts @@ -66,7 +66,10 @@ const parsingCase = (input, errors, success) => () => { const run = Schema.decodeUnknownOption(makeSchema()) return { run: () => run(input, { errors }), - validate: (result) => assert.equal(Option.isSome(result), success) + validate: (result) => { + assert.equal(Option.isSome(result), success) + if (success && Option.isSome(result)) assert.deepEqual(result.value, validData) + } } } diff --git a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts index fdff6a1b55b..25676aad236 100644 --- a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts +++ b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts @@ -16,7 +16,7 @@ const makeSchema = () => { }) const rating = v.object({ id: v.number(), - stars: v.pipe(v.number(), v.minValue(1), v.maxValue(5)), + stars: v.pipe(v.number(), v.minValue(0), v.maxValue(5)), title: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), text: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)), images: v.array(image) @@ -56,7 +56,10 @@ const parsingCase = (input, options, success) => () => { const schema = makeSchema() return { run: () => v.safeParse(schema, input, options), - validate: (result) => assert.equal(result.success, success) + validate: (result) => { + assert.equal(result.success, success) + if (success) assert.deepEqual(result.output, validData) + } } } @@ -72,6 +75,7 @@ const standardCase = (input, success) => () => { validate: (result) => { assert.equal(typeof result?.then, "undefined") assert.equal(result.issues === undefined, success) + if (success) assert.deepEqual(result.value, validData) } } } diff --git a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts index 81e56f48217..609f06c497c 100644 --- a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts +++ b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts @@ -29,7 +29,7 @@ const makeSchema = () => { description: z.string().min(1).max(500), price: z.number().min(1).max(10000), discount: z.number().min(1).max(100).nullable(), - quantity: z.number().min(0).max(10), + quantity: z.number().min(1).max(10), tags: z.array(z.string().min(1).max(30)), images: z.array(image), ratings: z.array(rating) @@ -46,7 +46,10 @@ const parsingCase = (input, success) => () => { const options = { jitless: true } return { run: () => schema.safeParse(input, options), - validate: (result) => assert.equal(result.success, success) + validate: (result) => { + assert.equal(result.success, success) + if (success) assert.deepEqual(result.data, validData) + } } } @@ -60,6 +63,7 @@ const standardCase = (input, success) => () => { validate: (result) => { assert.equal(typeof result?.then, "undefined") assert.equal(result.issues === undefined, success) + if (success) assert.deepEqual(result.value, validData) } } } diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts b/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts index e5d891cd717..f9fd2ac0593 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts @@ -28,7 +28,10 @@ export const exitValid = () => { const run = Schema.decodeUnknownExit(schema) return { run: () => run(input), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, input) + } } } @@ -44,7 +47,10 @@ export const optionValid = () => { const run = Schema.decodeUnknownOption(schema) return { run: () => run(input), - validate: (result) => assert.equal(Option.isSome(result), true) + validate: (result) => { + assert.equal(Option.isSome(result), true) + if (Option.isSome(result)) assert.deepEqual(result.value, input) + } } } @@ -60,7 +66,10 @@ export const resultValid = () => { const run = Schema.decodeUnknownResult(schema) return { run: () => run(input), - validate: (result) => assert.equal(Result.isSuccess(result), true) + validate: (result) => { + assert.equal(Result.isSuccess(result), true) + if (Result.isSuccess(result)) assert.deepEqual(result.success, input) + } } } diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts index 773fdce5321..dc7895913bf 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts @@ -7,25 +7,37 @@ import assert from "node:assert/strict" const decodeCase = (schema, input, success, options) => () => { const run = Schema.decodeUnknownExit(schema, options) + const isOutput = success ? Schema.is(schema) : undefined return { run: () => run(input), - validate: (result) => assert.equal(result._tag, success ? "Success" : "Failure") + validate: (result) => { + assert.equal(result._tag, success ? "Success" : "Failure") + if (result._tag === "Success") assert.equal(isOutput?.(result.value), true) + } } } const decodeParserCase = (schema, input, success, options) => () => { const run = SchemaParser.decodeUnknownExit(schema, options) + const isOutput = success ? Schema.is(schema) : undefined return { run: () => run(input), - validate: (result) => assert.equal(result._tag, success ? "Success" : "Failure") + validate: (result) => { + assert.equal(result._tag, success ? "Success" : "Failure") + if (result._tag === "Success") assert.equal(isOutput?.(result.value), true) + } } } const encodeParserCase = (schema, input, success, options) => () => { const run = SchemaParser.encodeUnknownExit(schema, options) + const isOutput = success ? Schema.is(Schema.flip(schema)) : undefined return { run: () => run(input), - validate: (result) => assert.equal(result._tag, success ? "Success" : "Failure") + validate: (result) => { + assert.equal(result._tag, success ? "Success" : "Failure") + if (result._tag === "Success") assert.equal(isOutput?.(result.value), true) + } } } @@ -146,7 +158,7 @@ export const optionalPresentValid = decodeCase( export const optionalPresentInvalid = decodeCase(optionalStruct, { required: "value", optionalKey: 1 }, false) const suspendedString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((input) => Effect.suspend(() => Effect.succeed(input))), + decode: SchemaGetter.transformOptionalEffect((input) => Effect.suspend(() => Effect.succeed(input))), encode: SchemaGetter.passthrough() })) const suspendedObjectFields = Object.fromEntries( diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts b/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts index 59dc535f96d..7ad53b37950 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts @@ -110,7 +110,10 @@ export const effectFirstMakeObject2 = () => ({ export const effectFirstDecodeCheckedObject32 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectCheckedSchema())(input), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, input) + } }) export const effectFirstDecodeTemplateLiteral = () => ({ @@ -120,17 +123,26 @@ export const effectFirstDecodeTemplateLiteral = () => ({ export const effectFirstDecodeRecord32 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectRecordSchema())(input), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, input) + } }) export const effectFirstDecodeLiteral100 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectLiteral100Schema())("value99"), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.equal(result.value, "value99") + } }) export const effectFirstDecodeTagged100 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectTagged100Schema())(taggedInput), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, taggedInput) + } }) export const effectFirstDecodeEncodingChain8 = () => ({ diff --git a/packages/effect/runtimeperf/test/registry.test.mts b/packages/effect/runtimeperf/test/registry.test.mts index 7cd39bee97e..52612f88dfa 100644 --- a/packages/effect/runtimeperf/test/registry.test.mts +++ b/packages/effect/runtimeperf/test/registry.test.mts @@ -2,14 +2,33 @@ 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, scenarioBatchSize } from "../utils.mts" describe("runtimeperf registry", () => { + it("uses the Effect calibration for every implementation in a scenario", () => { + const zod = { implementation: "zod4-compiled" } + const effect = { implementation: "effect" } + assert.equal(scenarioBatchSize([zod, effect], new Map([ + [zod, { batchSize: 4_096 }], + [effect, { batchSize: 256 }] + ])), 256) + }) + 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-aot", + "effect-jit", + "fast-check-v4", + "valibot", + "zod4", + "zod4-compiled", + "zod4-jitless", + "zod4-validate" + ].includes(fixture.implementation)) } }) @@ -103,7 +122,7 @@ describe("runtimeperf registry", () => { const { fixtures } = loadRegistry() const zodFiles = new Set( fixtures - .filter((fixture) => fixture.implementation === "zod4") + .filter((fixture) => fixture.suite === "schema-benchmarks" && fixture.implementation === "zod4") .map((fixture) => fixture.fixturePath) ) assert.ok(zodFiles.size > 0) @@ -115,6 +134,34 @@ describe("runtimeperf registry", () => { } }) + it("uses strict Zod compilation for the compiler comparison fixtures", async () => { + const { fixtures } = loadRegistry() + const compiled = fixtures.filter((fixture) => + fixture.suite === "compiler-rebuild" && fixture.implementation === "zod4-compiled" + ) + assert.equal(compiled.length, 18) + const paths = new Set(compiled.map((fixture) => fixture.fixturePath)) + assert.equal(paths.size, 1) + const source = await readFile([...paths][0], "utf8") + assert.match(source, /from "\.\/zod-cases\.ts"/) + const shared = await readFile(new URL("../suites/compiler-rebuild/fixtures/zod-cases.ts", import.meta.url), "utf8") + assert.match(shared, /from "zod\/v4"/) + assert.match(shared, /z\.compile\(value\.schema, \{ strict: true \}\)/) + }) + + it("uses interpreted Zod for the jitless compiler comparison fixtures", async () => { + const { fixtures } = loadRegistry() + const jitless = fixtures.filter((fixture) => + fixture.suite === "compiler-rebuild" && fixture.implementation === "zod4-jitless" + ) + assert.equal(jitless.length, 10) + const paths = new Set(jitless.map((fixture) => fixture.fixturePath)) + assert.equal(paths.size, 1) + const source = await readFile(new URL("../suites/compiler-rebuild/fixtures/zod-cases.ts", import.meta.url), "utf8") + assert.match(source, /z\.validate\(value\.schema, input, \{ jitless: true \}\)/) + assert.match(source, /value\.schema\.parse\(input, \{ jitless: true \}\)/) + }) + it("loads, runs and validates every fixture export", async () => { const { fixtures } = loadRegistry() const modules = new Map() diff --git a/packages/effect/runtimeperf/utils.mts b/packages/effect/runtimeperf/utils.mts index 2dc6210b022..2df3859b580 100644 --- a/packages/effect/runtimeperf/utils.mts +++ b/packages/effect/runtimeperf/utils.mts @@ -8,6 +8,11 @@ import { aggregate } from "./stats.mts" export const runtimeperfDir = dirname(fileURLToPath(import.meta.url)) export const effectDir = resolve(runtimeperfDir, "..") export const repoRoot = resolve(effectDir, "../..") +export const runPath = join(runtimeperfDir, "run.mts") +export const comparePath = join(runtimeperfDir, "compare.mts") +export const materializePath = join(runtimeperfDir, "materialize.mts") +export const statsPath = join(runtimeperfDir, "stats.mts") +export const utilsPath = fileURLToPath(import.meta.url) export const workerPath = join(runtimeperfDir, "worker.mts") export const configPath = join(runtimeperfDir, "config.json") export const resultsRoot = join(repoRoot, "tmp", "runtimeperf", "results") @@ -147,7 +152,7 @@ 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.startsWith("effect")) } if (selected.length === 0) { throw new Error("No runtimeperf fixtures matched the selection") @@ -216,6 +221,13 @@ export const measureFixture = (fixture, defaults, batchSize, fixturePath = fixtu export const aggregateMeasurements = (measurements) => aggregate(measurements.map((item) => item.nsPerOp)) +export const scenarioBatchSize = (fixtures, calibrations) => { + const reference = fixtures.find((fixture) => fixture.implementation === "effect") ?? fixtures[0] + const calibration = calibrations.get(reference) + if (calibration === undefined) throw new Error("Missing scenario reference calibration") + return calibration.batchSize +} + export const coverageSummary = (fixtures) => ({ tiers: [...new Set(fixtures.map((fixture) => fixture.tier))].sort(), families: [...new Set(fixtures.map((fixture) => fixture.family))].sort(), @@ -223,7 +235,7 @@ export const coverageSummary = (fixtures) => ({ effectAstTags: [ ...new Set( fixtures - .filter((fixture) => fixture.implementation === "effect") + .filter((fixture) => fixture.implementation.startsWith("effect")) .flatMap((fixture) => fixture.astTags) ) ].sort() diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index c8a764b94ec..3db34fb2208 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -1193,7 +1193,8 @@ export function Array>( const arrayString = Schema.String.pipe( Schema.decodeTo(Schema.toCodecStringTree(array), { decode: SchemaGetter.split(resolvedOptions), - encode: SchemaGetter.passthrough, Schema.StringTree>({ strict: false }).compose( + encode: SchemaGetter.compose( + SchemaGetter.passthrough, Schema.StringTree>({ strict: false }), SchemaGetter.transform((input) => input.join(separator)) ) }) @@ -1270,7 +1271,8 @@ export function Record< const recordString = Schema.String.pipe( Schema.decodeTo(Schema.toCodecStringTree(record), { decode: split.decode, - encode: SchemaGetter.passthrough, Schema.StringTree>({ strict: false }).compose( + encode: SchemaGetter.compose( + SchemaGetter.passthrough, Schema.StringTree>({ strict: false }), split.encode ) }) diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 0b6a455c166..97f13b8070b 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -579,8 +579,8 @@ export const defaultParseOptions: ParseOptions = {} * * - `isOptional` — the property key may be absent from the input. * - `isMutable` — the property is `readonly` when `false`. - * - `constructorDefault` — a {@link Link} applied during construction to - * supply missing values. + * - `constructorDefault` — an effect evaluated during construction to supply + * missing values. * - `annotations` — key-level annotations (e.g. description of the key * itself). * @@ -593,7 +593,7 @@ export interface Context { readonly isOptional: boolean readonly isMutable: boolean /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - readonly constructorDefault: Link | undefined + readonly constructorDefault: Effect.Effect | undefined readonly annotations: Schema.Annotations.Key | undefined } @@ -606,20 +606,20 @@ export interface Context { export const Context: new( isOptional: boolean, isMutable: boolean, /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - constructorDefault?: Link | undefined, + constructorDefault?: Effect.Effect | undefined, annotations?: Schema.Annotations.Key | undefined ) => Context = class { readonly isOptional: boolean readonly isMutable: boolean /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - readonly constructorDefault: Link | undefined + readonly constructorDefault: Effect.Effect | undefined readonly annotations: Schema.Annotations.Key | undefined constructor( isOptional: boolean, isMutable: boolean, /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - constructorDefault: Link | undefined = undefined, + constructorDefault: Effect.Effect | undefined = undefined, annotations: Schema.Annotations.Key | undefined = undefined ) { this.isOptional = isOptional @@ -2222,7 +2222,10 @@ export interface Arrays extends ASTNode { readonly encodingChecks: Checks | undefined /** @internal */ - getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser + getParser( + compile: SchemaParser.Compiler, + compileField?: SchemaParser.Compiler + ): SchemaParser.Parser /** @internal */ recur(recur: (ast: AST) => AST): Arrays @@ -2294,7 +2297,7 @@ export const Arrays: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile + compileField: SchemaParser.Compiler = compile ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -2326,8 +2329,8 @@ export const Arrays: new( 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) })) + elements = ast.elements.map((ast) => ({ ast, parser: compileField(ast) })) + rest = ast.rest.map((ast) => ({ ast, parser: compileField(ast) })) } const len = input.length @@ -2403,9 +2406,10 @@ export const Arrays: new( return "array" } } + type ArrayParserState = { readonly ast: AST - readonly input: unknown + readonly input: ReadonlyArray readonly len: number readonly getParser: ( tailThreshold: number, @@ -2414,7 +2418,37 @@ type ArrayParserState = { readonly tailThreshold: number readonly options: ParseOptions readonly output: Array - issues: Array | undefined + issues: Arr.NonEmptyArray | undefined +} + +/** @internal */ +export function stepArray( + s: ArrayParserState, + item: unknown, + exit: Exit.Exit, + i: number +) { + 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 parseArrayOptions = { @@ -2422,32 +2456,11 @@ const parseArrayOptions = { const value = i < s.len ? item : InternalParser.missing return s.getParser(s.tailThreshold, i).parser(value, s.options) }, - step(s: ArrayParserState, item: unknown, exit: Exit.Exit, i: number) { - 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) - ) - } - } - } + step: stepArray } -const parseArray = iterateEager()(parseArrayOptions) +/** @internal */ +export const parseArray = iterateEager()(parseArrayOptions) const parseArrayConcurrent = iterateConcurrent()(parseArrayOptions) const wrapPropertyKeyIssue = ( @@ -2707,7 +2720,10 @@ export interface Objects extends ASTNode { readonly encodingChecks: Checks | undefined /** @internal */ - getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser + getParser( + compile: SchemaParser.Compiler, + compileField?: SchemaParser.Compiler + ): SchemaParser.Parser /** @internal */ flip(recur: (ast: AST) => AST): AST @@ -2771,7 +2787,7 @@ export const Objects: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile + compileField: SchemaParser.Compiler = compile ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -2866,7 +2882,7 @@ export const Objects: new( const compileMembers = (): Array => { if (!properties) { properties = ast.propertySignatures.map((ps) => ({ - parser: compileConstructorDefault(ps.type), + parser: compileField(ps.type), name: ps.name, type: ps.type })) @@ -2874,7 +2890,7 @@ export const Objects: new( ? ast.indexSignatures.map((is) => ({ is, parserKey: compile(parameterFromPropertyKey(is.parameter)), - parserValue: compileConstructorDefault(is.type) + parserValue: compileField(is.type) })) : undefined } @@ -3104,7 +3120,7 @@ type ObjectParserState = { readonly input: Record readonly options: ParseOptions readonly out: Record - issues: Array | undefined + issues: Arr.NonEmptyArray | undefined } type ParsedProperty = { @@ -3113,7 +3129,8 @@ type ParsedProperty = { readonly type: AST } -function stepProperty( +/** @internal */ +export function stepProperty( s: ObjectParserState, p: ParsedProperty, exit: Exit.Exit @@ -3154,7 +3171,8 @@ const parsePropertiesOptions = { step: stepProperty } -const parseProperties = iterateEager()(parsePropertiesOptions) +/** @internal */ +export const parseProperties = iterateEager()(parsePropertiesOptions) const parsePropertiesConcurrent = iterateConcurrent()(parsePropertiesOptions) function combineChecks(a: Checks | undefined, b: Checks | undefined): Checks | undefined { @@ -3584,7 +3602,7 @@ export interface Union extends ASTNode { readonly encodingChecks: Checks | undefined /** @internal */ - getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser + getParser(compile: SchemaParser.Compiler, compileField?: SchemaParser.Compiler): SchemaParser.Parser /** @internal */ recur(recur: (ast: AST) => AST): Union @@ -3647,7 +3665,7 @@ export const Union: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault?: SchemaParser.Compiler + compileField?: SchemaParser.Compiler ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -3656,7 +3674,7 @@ export const Union: new( if (input === InternalParser.missing) { return InternalParser.missingExit } - const candidates = getCandidates(input, ast.types, compileConstructorDefault !== undefined) + const candidates = getCandidates(input, ast.types, compileField !== undefined) if (candidates.length === 0) { return Effect.fail(new SchemaIssue.AnyOf(ast, [], input, options)) @@ -4414,14 +4432,9 @@ export function withConstructorDefault( ast: A, defaultValue: Effect.Effect ): A { - const transformation = new SchemaTransformation.Transformation( - SchemaGetter.withDefault(defaultValue), - SchemaGetter.passthrough() - ) - const constructorDefault = new Link(unknown, transformation) const context = ast.context ? - new Context(ast.context.isOptional, ast.context.isMutable, constructorDefault, ast.context.annotations) : - new Context(false, false, constructorDefault) + new Context(ast.context.isOptional, ast.context.isMutable, defaultValue, ast.context.annotations) : + new Context(false, false, defaultValue) return replaceContext(ast, context) } @@ -4738,7 +4751,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 diff --git a/packages/effect/src/SchemaGetter.ts b/packages/effect/src/SchemaGetter.ts index 00bf6cdb623..4cc3750ca42 100644 --- a/packages/effect/src/SchemaGetter.ts +++ b/packages/effect/src/SchemaGetter.ts @@ -15,6 +15,7 @@ import * as Arr from "./Array.ts" import * as DateTime from "./DateTime.ts" import * as Effect from "./Effect.ts" import * as Encoding from "./Encoding.ts" +import { dual } from "./Function.ts" import * as InternalRecord from "./internal/record.ts" import * as Option from "./Option.ts" import * as Pipeable from "./Pipeable.ts" @@ -25,6 +26,66 @@ import type * as SchemaAST from "./SchemaAST.ts" import * as SchemaIssue from "./SchemaIssue.ts" import * as Str from "./String.ts" +/** + * A transformation that returns its input unchanged. + * + * @category models + * @since 4.0.0 + */ +export interface Passthrough extends Pipeable.Pipeable { + readonly _tag: "Passthrough" +} + +/** + * A synchronous transformation of present values. + * + * @category models + * @since 4.0.0 + */ +export interface Transform extends Pipeable.Pipeable { + readonly _tag: "Transform" + readonly transform: (input: E) => T +} + +/** + * A synchronous transformation of optional values. + * + * @category models + * @since 4.0.0 + */ +export interface TransformOptional extends Pipeable.Pipeable { + readonly _tag: "TransformOptional" + readonly transform: (input: Option.Option) => Option.Option +} + +/** + * An effectful transformation of present values. + * + * @category models + * @since 4.0.0 + */ +export interface TransformEffect extends Pipeable.Pipeable { + readonly _tag: "TransformEffect" + readonly transform: ( + input: E, + options: SchemaAST.ParseOptions + ) => Effect.Effect +} + +/** + * An effectful transformation of optional values. + * + * @category models + * @since 4.0.0 + */ +export interface TransformOptionalEffect extends Pipeable.Pipeable { + readonly _tag: "TransformOptionalEffect" + readonly transform: ( + input: Option.Option, + options: SchemaAST.ParseOptions + ) => Effect.Effect, SchemaIssue.Issue, R> +} + /** * Represents a composable transformation from an encoded type `E` to a decoded type `T`. * @@ -35,14 +96,14 @@ import * as Str from "./String.ts" * * **Details** * - * A getter wraps a function `Option -> Effect, Issue, R>`. It - * receives `Option.None` when the encoded key is absent, such as a missing - * struct field, and returns `Option.None` to omit the value from the decoded - * output. It fails with `Issue` on invalid input and may require Effect - * services via `R`. `.map(f)` applies `f` to the decoded value inside `Some` - * while leaving `None` unchanged. `.compose(other)` chains two getters by - * feeding the output of `this` into `other`; passthrough getters on either side - * are optimized away. + * A getter receives an `Option` and produces an `Option`. `Option.none()` + * represents a missing struct field and can also omit a field from the output. + * A getter may fail with a schema issue or require services through `R`. The + * tagged representation distinguishes synchronous transformations from + * transformations that return an `Effect`, allowing schema parsers to select + * the corresponding execution path when they are built. Getter values expose + * `pipe`; use the standalone {@link map}, {@link compose}, and {@link run} + * functions to operate on them. * * **Example** (Creating and composing getters) * @@ -51,65 +112,207 @@ import * as Str from "./String.ts" * * const parseNumber = SchemaGetter.transform((s) => Number(s)) * const double = SchemaGetter.transform((n) => n * 2) - * const composed = parseNumber.compose(double) - * await Effect.runPromise(composed.run(Option.some("21"), {})) // => Option.some(42) + * const composed = SchemaGetter.compose(parseNumber, double) + * Effect.runSync(SchemaGetter.run(composed, Option.some("21"), {})) // => Option.some(42) * ``` * - * @see {@link transform} to create a getter from a pure function * @see {@link passthrough} for the identity getter + * @see {@link transform} to create a getter from a pure function * @see {@link transformEffect} for effectful transformation * * @category models * @since 4.0.0 */ -export interface Getter extends Pipeable.Pipeable { - readonly run: ( - input: Option.Option, - options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> - map(f: (t: T) => T2): Getter - compose(other: Getter): Getter +export type Getter = + | Passthrough + | Transform + | TransformOptional + | TransformEffect + | TransformOptionalEffect + +const runGetter = ( + self: Getter, + input: Option.Option, + options: SchemaAST.ParseOptions +): Effect.Effect, SchemaIssue.Issue, R> => { + switch (self._tag) { + case "Passthrough": + return Effect.succeed(input as unknown as Option.Option) + case "Transform": + return Effect.succeed(Option.map(input, self.transform)) + case "TransformOptional": + return Effect.succeed(self.transform(input)) + case "TransformEffect": + return Option.isNone(input) + ? Effect.succeedNone + : Effect.mapEager(self.transform(input.value, options), Option.some) + case "TransformOptionalEffect": + return self.transform(input, options) + } } /** - * Constructs a composable schema getter. + * Runs a getter directly. * - * @category constructors + * **Details** + * + * This is a convenience API for executing a getter outside a schema. When the + * getter belongs to a schema, use the corresponding `SchemaParser` API. The + * result is an `Effect` for every getter variant, including synchronous ones. + * + * **Example** (Running a getter) + * + * ```ts import.meta.vitest + * import { Effect, Option, SchemaGetter } from "effect" + * + * const getter = SchemaGetter.transform(Number) + * + * const result = Effect.runSync( + * SchemaGetter.run(getter, Option.some("42"), {}) + * ) + * result // => Option.some(42) + * ``` + * + * @category converting * @since 4.0.0 */ -export const Getter: new( - run: ( +export const run: { + ( input: Option.Option, options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> -) => Getter = class extends Pipeable.Class { - readonly run: ( + ): (self: Getter) => Effect.Effect, SchemaIssue.Issue, R> + ( + self: Getter, input: Option.Option, options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> + ): Effect.Effect, SchemaIssue.Issue, R> +} = dual(3, runGetter) + +const makeGetter = (fields: A): A & Pipeable.Pipeable => + Object.assign(Object.create(Pipeable.Prototype), fields) + +const composeOptionalEffect = ( + first: Getter, + second: Getter +): Getter => + transformOptionalEffect((input, options) => + Effect.flatMapEager(runGetter(first, input, options), (output) => runGetter(second, output, options)) + ) - constructor( - run: ( - input: Option.Option, - options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> - ) { - super() - this.run = run - } - map(f: (t: T) => T2): Getter { - return new Getter((oe, options) => this.run(oe, options).pipe(Effect.mapEager(Option.map(f)))) - } - compose(other: Getter): Getter { - if (isPassthrough(this)) { - return other as any +/** + * Composes two getters by passing the output of the first to the second. + * + * **When to use** + * + * Use when a schema conversion requires multiple transformation steps. + * + * **Details** + * + * Composition forwards `Option.none()` to getters that handle optional values + * and skips getters that operate only on present values. Composing with + * {@link passthrough} returns the other getter unchanged. The function supports + * both `compose(first, second)` and `first.pipe(compose(second))`. + * + * **Example** (Parsing and normalizing a number) + * + * ```ts import.meta.vitest + * import { Effect, Option, SchemaGetter } from "effect" + * + * const getter = SchemaGetter.compose( + * SchemaGetter.transform(Number), + * SchemaGetter.transform((n) => Math.max(0, n)) + * ) + * + * Effect.runSync(SchemaGetter.run(getter, Option.some("-1"), {})) // => Option.some(0) + * ``` + * + * @category combining + * @since 4.0.0 + */ +export const compose: { + (other: Getter): (self: Getter) => Getter + (self: Getter, other: Getter): Getter +} = dual(2, ( + self: Getter, + other: Getter +): Getter => { + if (self._tag === "Passthrough") return other as Getter + if (other._tag === "Passthrough") return self as unknown as Getter + switch (self._tag) { + case "Transform": { + switch (other._tag) { + case "Transform": + return transform((input: E) => other.transform(self.transform(input))) + case "TransformOptional": + return transformOptional((input) => other.transform(Option.map(input, self.transform))) + case "TransformEffect": + return transformEffect((input: E, options) => other.transform(self.transform(input), options)) + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) + } + } + case "TransformOptional": { + switch (other._tag) { + case "Transform": + return transformOptional((input) => Option.map(self.transform(input), other.transform)) + case "TransformOptional": + return transformOptional((input) => other.transform(self.transform(input))) + case "TransformEffect": + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) + } } - if (isPassthrough(other)) { - return this as any + case "TransformEffect": { + switch (other._tag) { + case "Transform": + return transformEffect((input: E, options) => + Effect.mapEager(self.transform(input, options), other.transform) + ) + case "TransformOptional": + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) + case "TransformEffect": + return transformEffect((input: E, options) => + Effect.flatMapEager(self.transform(input, options), (output) => other.transform(output, options)) + ) + } } - return new Getter((oe, options) => this.run(oe, options).pipe(Effect.flatMapEager((ot) => other.run(ot, options)))) + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) } -} +}) + +/** + * Maps the output of a getter while preserving missing values. + * + * **When to use** + * + * Use to add a synchronous transformation after an existing getter. + * + * **Details** + * + * The mapping function runs only for `Option.some`. The function supports both + * `map(self, f)` and `self.pipe(map(f))`. + * + * **Example** (Mapping a getter result) + * + * ```ts import.meta.vitest + * import { Effect, Option, SchemaGetter } from "effect" + * + * const getter = SchemaGetter.transform(Number).pipe( + * SchemaGetter.map((n) => n * 2) + * ) + * + * Effect.runSync(SchemaGetter.run(getter, Option.some("21"), {})) // => Option.some(42) + * ``` + * + * @category mapping + * @since 4.0.0 + */ +export const map: { + (f: (value: T) => T2): (self: Getter) => Getter + (self: Getter, f: (value: T) => T2): Getter +} = dual(2, (self: Getter, f: (value: T) => T2): Getter => compose(self, transform(f))) /** * Creates a getter that always produces the given constant value, ignoring the input. @@ -130,7 +333,7 @@ export const Getter: new( * import { Effect, Option, SchemaGetter } from "effect" * * const alwaysZero = SchemaGetter.succeed(0) - * await Effect.runPromise(alwaysZero.run(Option.none(), {})) // => Option.some(0) + * Effect.runSync(SchemaGetter.run(alwaysZero, Option.none(), {})) // => Option.some(0) * ``` * * @see {@link transform} when you need to use the input value @@ -140,7 +343,7 @@ export const Getter: new( * @since 4.0.0 */ export function succeed(t: T): Getter { - return new Getter(() => Effect.succeedSome(t)) + return transformOptional(() => Option.some(t)) } /** @@ -165,7 +368,9 @@ export function succeed(t: T): Getter { * const rejectAll = SchemaGetter.fail( * () => new SchemaIssue.InvalidValue({ message: "not allowed" }) * ) - * const issue = await Effect.runPromise(Effect.flip(rejectAll.run(Option.some("x"), {}))) + * const issue = await Effect.runPromise( + * Effect.flip(SchemaGetter.run(rejectAll, Option.some("x"), {})) + * ) * issue._tag // => "InvalidValue" * ``` * @@ -178,7 +383,7 @@ export function succeed(t: T): Getter { export function fail( f: (oe: Option.Option, options: SchemaAST.ParseOptions) => SchemaIssue.Issue ): Getter { - return new Getter((oe, options) => Effect.fail(f(oe, options))) + return transformOptionalEffect((oe, options) => Effect.fail(f(oe, options))) } /** @@ -203,7 +408,9 @@ export function fail( * const noEncode = SchemaGetter.forbidden( * () => "encoding is not supported" * ) - * const issue = await Effect.runPromise(Effect.flip(noEncode.run(Option.some(1), {}))) + * const issue = await Effect.runPromise( + * Effect.flip(SchemaGetter.run(noEncode, Option.some(1), {})) + * ) * issue._tag // => "Forbidden" * ``` * @@ -239,7 +446,7 @@ export function forbidden(message: (oe: Option.Option) => string): Gett * import { Effect, Option, SchemaGetter } from "effect" * * const issue = await Effect.runPromise( - * Effect.flip(SchemaGetter.forbiddenEncoding.run(Option.some("value"), {})) + * Effect.flip(SchemaGetter.run(SchemaGetter.forbiddenEncoding, Option.some("value"), {})) * ) * issue._tag // => "Forbidden" * ``` @@ -251,11 +458,7 @@ export function forbidden(message: (oe: Option.Option) => string): Gett */ export const forbiddenEncoding: Getter = forbidden(() => "Encoding is not supported") -const passthrough_ = new Getter(Effect.succeed) - -function isPassthrough(getter: Getter): getter is typeof passthrough_ { - return getter.run === passthrough_.run -} +const passthrough_: Passthrough = makeGetter({ _tag: "Passthrough" }) /** * Returns the identity getter — passes the value through unchanged. @@ -268,7 +471,7 @@ function isPassthrough(getter: Getter): getter is typeof passt * **Details** * * - Pure, no allocation (singleton instance). - * - Optimized away during `.compose()` — composing with a passthrough is free. + * - Optimized away by {@link compose} — composing with a passthrough is free. * - The default overload requires `T === E`. Pass `{ strict: false }` to opt * out of the type constraint. * @@ -319,7 +522,7 @@ export function passthrough(): Getter { * * // string extends string, so this is valid * const g = SchemaGetter.passthroughSupertype() - * await Effect.runPromise(g.run(Option.some("hello"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(g, Option.some("hello"), {})) // => Option.some("hello") * ``` * * @see {@link passthrough} when types are identical @@ -352,7 +555,7 @@ export function passthroughSupertype(): Getter { * * // "hello" extends string, so E extends T * const g = SchemaGetter.passthroughSubtype() - * await Effect.runPromise(g.run(Option.some("hello"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(g, Option.some("hello"), {})) // => Option.some("hello") * ``` * * @see {@link passthrough} when types are identical @@ -366,45 +569,6 @@ export function passthroughSubtype(): Getter { return passthrough_ } -/** - * Creates a getter that handles the case when the input is absent (`Option.None`). - * - * **When to use** - * - * Use when you need a schema getter to provide a fallback or computed value for - * missing struct keys. - * - Building custom "default value" logic more complex than {@link withDefault}. - * - * **Details** - * - * - When input is `None`, calls `f` to produce the result. - * - When input is `Some`, passes it through unchanged. - * - `f` receives the parse options and may return `None` to keep the value absent. - * - * **Example** (Providing a default timestamp for a missing field) - * - * ```ts import.meta.vitest - * import { Effect, Option, SchemaGetter } from "effect" - * - * const withTimestamp = SchemaGetter.onNone(() => - * Effect.succeed(Option.some(0)) - * ) - * await Effect.runPromise(withTimestamp.run(Option.none(), {})) // => Option.some(0) - * ``` - * - * @see {@link required} when absent input should fail - * @see {@link withDefault} for a simpler default value for undefined inputs - * @see {@link onSome} to handle only present values - * - * @category transforming - * @since 4.0.0 - */ -export function onNone( - f: (options: SchemaAST.ParseOptions) => Effect.Effect, SchemaIssue.Issue, R> -): Getter { - return new Getter((ot, options) => Option.isNone(ot) ? f(options) : Effect.succeed(ot)) -} - /** * Creates a getter that fails with `MissingKey` if the input is absent (`Option.None`). * @@ -425,57 +589,21 @@ export function onNone( * import { Effect, Option, SchemaGetter } from "effect" * * const mustExist = SchemaGetter.required() - * const issue = await Effect.runPromise(Effect.flip(mustExist.run(Option.none(), {}))) + * const issue = await Effect.runPromise( + * Effect.flip(SchemaGetter.run(mustExist, Option.none(), {})) + * ) * issue._tag // => "MissingKey" * ``` * - * @see {@link onNone} to provide a fallback instead of failing * @see {@link withDefault} to substitute a default for undefined values * * @category validation * @since 4.0.0 */ export function required(annotations?: Schema.Annotations.Key): Getter { - return onNone(() => Effect.fail(new SchemaIssue.MissingKey(annotations))) -} - -/** - * Creates a getter that handles present values (`Option.Some`), passing `None` through. - * - * **When to use** - * - * Use when you need a schema getter to transform or validate only when a field - * value is present. - * - Missing keys should remain absent in the output. - * - * **Details** - * - * - When input is `None`, returns `None` (no-op). - * - When input is `Some(e)`, calls `f(e, options)` to produce the result. - * - `f` may return `None` to omit the value, or fail with an `Issue`. - * - * **Example** (Transforming only present values) - * - * ```ts import.meta.vitest - * import { Effect, Option, SchemaGetter } from "effect" - * - * const parseIfPresent = SchemaGetter.onSome( - * (s) => Effect.succeed(Option.some(Number(s))) - * ) - * await Effect.runPromise(parseIfPresent.run(Option.some("42"), {})) // => Option.some(42) - * ``` - * - * @see {@link onNone} to handle only absent values - * @see {@link transform} for a simpler pure transformation of present values - * @see {@link transformEffect} for effectful transformation of present values - * - * @category transforming - * @since 4.0.0 - */ -export function onSome( - f: (e: E, options: SchemaAST.ParseOptions) => Effect.Effect, SchemaIssue.Issue, R> -): Getter { - return new Getter((oe, options) => Option.isNone(oe) ? Effect.succeedNone : f(oe.value, options)) + return transformOptionalEffect((input) => + Option.isNone(input) ? Effect.fail(new SchemaIssue.MissingKey(annotations)) : Effect.succeed(input) + ) } /** @@ -506,7 +634,7 @@ export function onSome( * const nonNegative = SchemaGetter.checkEffect((n) => * Effect.succeed(n >= 0 ? undefined : "must be non-negative") * ) - * await Effect.runPromise(nonNegative.run(Option.some(1), {})) // => Option.some(1) + * await Effect.runPromise(SchemaGetter.run(nonNegative, Option.some(1), {})) // => Option.some(1) * ``` * * @see {@link transform} when you need to change the value, not just validate @@ -522,12 +650,12 @@ export function checkEffect( R > ): Getter { - return onSome((t, options) => { + return transformEffect((t, options) => { return f(t, options).pipe(Effect.flatMapEager((out) => { const issue = SchemaIssue.makeSingle(out, t, options) return issue ? Effect.fail(issue) : - Effect.succeed(Option.some(t)) + Effect.succeed(t) })) }) } @@ -570,7 +698,7 @@ export function checkEffect( * @since 4.0.0 */ export function transform(f: (e: E) => T): Getter { - return transformOptional(Option.map(f)) + return makeGetter({ _tag: "Transform", transform: f }) } /** @@ -600,11 +728,11 @@ export function transform(f: (e: E) => T): Getter { * : Effect.succeed(n) * } * ) - * await Effect.runPromise(safeParseInt.run(Option.some("42"), {})) // => Option.some(42) + * await Effect.runPromise(SchemaGetter.run(safeParseInt, Option.some("42"), {})) // => Option.some(42) * ``` * * @see {@link transform} when transformation cannot fail - * @see {@link onSome} when you need full `Option` control over the output + * @see {@link transformOptionalEffect} when you need full `Option` control over the output * * @category transforming * @since 4.0.0 @@ -612,7 +740,7 @@ export function transform(f: (e: E) => T): Getter { export function transformEffect( f: (e: E, options: SchemaAST.ParseOptions) => Effect.Effect ): Getter { - return onSome((e, options) => f(e, options).pipe(Effect.mapEager(Option.some))) + return makeGetter({ _tag: "TransformEffect", transform: f }) } /** @@ -636,7 +764,7 @@ export function transformEffect( * const skipEmpty = SchemaGetter.transformOptional((o) => * Option.filter(o, (s) => s.length > 0) * ) - * await Effect.runPromise(skipEmpty.run(Option.some(""), {})) // => Option.none() + * Effect.runSync(SchemaGetter.run(skipEmpty, Option.some(""), {})) // => Option.none() * ``` * * @see {@link transform} when you only need to transform present values @@ -646,7 +774,22 @@ export function transformEffect( * @since 4.0.0 */ export function transformOptional(f: (oe: Option.Option) => Option.Option): Getter { - return new Getter((oe) => Effect.succeed(f(oe))) + return makeGetter({ _tag: "TransformOptional", transform: f }) +} + +/** + * Creates a getter that effectfully transforms the full `Option`. + * + * @category transforming + * @since 4.0.0 + */ +export function transformOptionalEffect( + f: ( + input: Option.Option, + options: SchemaAST.ParseOptions + ) => Effect.Effect, SchemaIssue.Issue, R> +): Getter { + return makeGetter({ _tag: "TransformOptionalEffect", transform: f }) } /** @@ -668,7 +811,7 @@ export function transformOptional(f: (oe: Option.Option) => Option.Opti * import { Effect, Option, SchemaGetter } from "effect" * * const omitField = SchemaGetter.omit() - * await Effect.runPromise(omitField.run(Option.some("hidden"), {})) // => Option.none() + * Effect.runSync(SchemaGetter.run(omitField, Option.some("hidden"), {})) // => Option.none() * ``` * * @see {@link transformOptional} when you want conditional omission @@ -678,7 +821,7 @@ export function transformOptional(f: (oe: Option.Option) => Option.Opti * @since 4.0.0 */ export function omit(): Getter { - return new Getter(() => Effect.succeedNone) + return transformOptional(() => Option.none()) } /** @@ -701,10 +844,10 @@ export function omit(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const withZero = SchemaGetter.withDefault(Effect.succeed(0)) - * await Effect.runPromise(withZero.run(Option.some(undefined), {})) // => Option.some(0) + * await Effect.runPromise(SchemaGetter.run(withZero, Option.some(undefined), {})) // => Option.some(0) * ``` * - * @see {@link onNone} to handle only absent keys (not `undefined` values) + * @see {@link transformOptionalEffect} for custom effectful missing-key handling * @see {@link required} when absent input should fail instead of using a default * * @category transforming @@ -713,7 +856,7 @@ export function omit(): Getter { export function withDefault( defaultValue: Effect.Effect ): Getter { - return new Getter((o) => { + return transformOptionalEffect((o) => { const filtered = Option.filter(o, Predicate.isNotUndefined) return Option.isSome(filtered) ? Effect.succeed(filtered) : Effect.mapEager(defaultValue, Option.some) }) @@ -737,7 +880,7 @@ export function withDefault( * import { Effect, Option, SchemaGetter } from "effect" * * const toString = SchemaGetter.String() - * await Effect.runPromise(toString.run(Option.some(42), {})) // => Option.some("42") + * Effect.runSync(SchemaGetter.run(toString, Option.some(42), {})) // => Option.some("42") * ``` * * @see {@link transform} for custom string conversions @@ -768,7 +911,7 @@ export function String(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toNumber = SchemaGetter.Number() - * await Effect.runPromise(toNumber.run(Option.some("42"), {})) // => Option.some(42) + * Effect.runSync(SchemaGetter.run(toNumber, Option.some("42"), {})) // => Option.some(42) * ``` * * @see {@link transformEffect} for effectful or validated number parsing @@ -798,7 +941,7 @@ export function Number(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toBool = SchemaGetter.Boolean() - * await Effect.runPromise(toBool.run(Option.some("true"), {})) // => Option.some(true) + * Effect.runSync(SchemaGetter.run(toBool, Option.some("true"), {})) // => Option.some(true) * ``` * * @category converting @@ -827,7 +970,7 @@ export function Boolean(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toBigInt = SchemaGetter.BigInt() - * await Effect.runPromise(toBigInt.run(Option.some("42"), {})) // => Option.some(42n) + * Effect.runSync(SchemaGetter.run(toBigInt, Option.some("42"), {})) // => Option.some(42n) * ``` * * @category converting @@ -856,7 +999,7 @@ export function BigInt(): Getter() - * const result = await Effect.runPromise(toDate.run(Option.some("1970-01-01"), {})) + * const result = Effect.runSync(SchemaGetter.run(toDate, Option.some("1970-01-01"), {})) * Option.map(result, (date) => date.toISOString()) // => Option.some("1970-01-01T00:00:00.000Z") * ``` * @@ -882,7 +1025,7 @@ export function Date(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const trimmed = SchemaGetter.trim() - * await Effect.runPromise(trimmed.run(Option.some(" hello "), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(trimmed, Option.some(" hello "), {})) // => Option.some("hello") * ``` * * @category transforming @@ -905,7 +1048,7 @@ export function trim(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const cap = SchemaGetter.capitalize() - * await Effect.runPromise(cap.run(Option.some("hello"), {})) // => Option.some("Hello") + * Effect.runSync(SchemaGetter.run(cap, Option.some("hello"), {})) // => Option.some("Hello") * ``` * * @category transforming @@ -928,7 +1071,7 @@ export function capitalize(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const uncap = SchemaGetter.uncapitalize() - * await Effect.runPromise(uncap.run(Option.some("Hello"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(uncap, Option.some("Hello"), {})) // => Option.some("hello") * ``` * * @category transforming @@ -951,7 +1094,7 @@ export function uncapitalize(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toCamel = SchemaGetter.snakeToCamel() - * await Effect.runPromise(toCamel.run(Option.some("user_name"), {})) // => Option.some("userName") + * Effect.runSync(SchemaGetter.run(toCamel, Option.some("user_name"), {})) // => Option.some("userName") * ``` * * @see {@link camelToSnake} for the inverse operation @@ -976,7 +1119,7 @@ export function snakeToCamel(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toSnake = SchemaGetter.camelToSnake() - * await Effect.runPromise(toSnake.run(Option.some("userName"), {})) // => Option.some("user_name") + * Effect.runSync(SchemaGetter.run(toSnake, Option.some("userName"), {})) // => Option.some("user_name") * ``` * * @see {@link snakeToCamel} for the inverse operation @@ -1001,7 +1144,7 @@ export function camelToSnake(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const lower = SchemaGetter.toLowerCase() - * await Effect.runPromise(lower.run(Option.some("HELLO"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(lower, Option.some("HELLO"), {})) // => Option.some("hello") * ``` * * @see {@link toUpperCase} for the inverse operation @@ -1026,7 +1169,7 @@ export function toLowerCase(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const upper = SchemaGetter.toUpperCase() - * await Effect.runPromise(upper.run(Option.some("hello"), {})) // => Option.some("HELLO") + * Effect.runSync(SchemaGetter.run(upper, Option.some("hello"), {})) // => Option.some("HELLO") * ``` * * @see {@link toLowerCase} for the inverse operation @@ -1065,7 +1208,8 @@ type ParseJsonOptions = { * import { Effect, Option, SchemaGetter } from "effect" * * const parse = SchemaGetter.parseJson() - * await Effect.runPromise(parse.run(Option.some("{\"a\":1}"), {})) // => Option.some({ a: 1 }) + * const result = await Effect.runPromise(SchemaGetter.run(parse, Option.some("{\"a\":1}"), {})) + * result // => Option.some({ a: 1 }) * ``` * * @see {@link stringifyJson} for the inverse operation @@ -1076,9 +1220,9 @@ type ParseJsonOptions = { export function parseJson(): Getter export function parseJson(options: ParseJsonOptions): Getter export function parseJson(options?: ParseJsonOptions | undefined): Getter { - return onSome((input, parseOptions) => + return transformEffect((input, parseOptions) => Effect.try({ - try: () => Option.some(JSON.parse(input, options?.reviver)), + try: () => JSON.parse(input, options?.reviver), catch: () => new SchemaIssue.InvalidValue( { expected: "a valid JSON string" }, @@ -1127,7 +1271,8 @@ type StringifyJsonOptions = { * import { Effect, Option, SchemaGetter } from "effect" * * const stringify = SchemaGetter.stringifyJson() - * await Effect.runPromise(stringify.run(Option.some({ a: 1 }), {})) // => Option.some("{\"a\":1}") + * const result = await Effect.runPromise(SchemaGetter.run(stringify, Option.some({ a: 1 }), {})) + * result // => Option.some("{\"a\":1}") * ``` * * @see {@link parseJson} for the inverse operation @@ -1136,14 +1281,14 @@ type StringifyJsonOptions = { * @since 4.0.0 */ export function stringifyJson(options?: StringifyJsonOptions): Getter { - return onSome((input, parseOptions) => + return transformEffect((input, parseOptions) => Effect.try({ try: () => { const output = JSON.stringify(input, options?.replacer as any, options?.space) if (output === undefined) { throw new TypeError("Value cannot be represented as JSON") } - return Option.some(output) + return output }, catch: () => new SchemaIssue.InvalidValue( @@ -1175,7 +1320,8 @@ export function stringifyJson(options?: StringifyJsonOptions): Getter() - * await Effect.runPromise(parse.run(Option.some("a=1,b=2"), {})) // => Option.some({ a: "1", b: "2" }) + * const result = Effect.runSync(SchemaGetter.run(parse, Option.some("a=1,b=2"), {})) + * result // => Option.some({ a: "1", b: "2" }) * ``` * * @see {@link joinKeyValue} for the inverse operation @@ -1221,7 +1367,8 @@ export function splitKeyValue(options?: { * import { Effect, Option, SchemaGetter } from "effect" * * const join = SchemaGetter.joinKeyValue() - * await Effect.runPromise(join.run(Option.some({ a: "1", b: "2" }), {})) // => Option.some("a=1,b=2") + * const result = Effect.runSync(SchemaGetter.run(join, Option.some({ a: "1", b: "2" }), {})) + * result // => Option.some("a=1,b=2") * ``` * * @see {@link splitKeyValue} for the inverse operation @@ -1259,7 +1406,8 @@ export function joinKeyValue>(options?: { * import { Effect, Option, SchemaGetter } from "effect" * * const splitComma = SchemaGetter.split() - * await Effect.runPromise(splitComma.run(Option.some("a,b,c"), {})) // => Option.some(["a", "b", "c"]) + * const result = Effect.runSync(SchemaGetter.run(splitComma, Option.some("a,b,c"), {})) + * result // => Option.some(["a", "b", "c"]) * ``` * * @see {@link splitKeyValue} when values are key-value pairs @@ -1287,7 +1435,8 @@ export function split(options?: { * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeBase64() - * await Effect.runPromise(encode.run(Option.some(new Uint8Array([1, 2, 3])), {})) // => Option.some("AQID") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some(new Uint8Array([1, 2, 3])), {})) + * result // => Option.some("AQID") * ``` * * @see {@link decodeBase64} for the inverse operation to `Uint8Array` @@ -1314,7 +1463,8 @@ export function encodeBase64(): Getter * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeBase64Url() - * await Effect.runPromise(encode.run(Option.some(new Uint8Array([251, 255])), {})) // => Option.some("-_8") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some(new Uint8Array([251, 255])), {})) + * result // => Option.some("-_8") * ``` * * @see {@link decodeBase64Url} for the inverse operation to `Uint8Array` @@ -1341,7 +1491,8 @@ export function encodeBase64Url(): Getter() - * await Effect.runPromise(encode.run(Option.some(new Uint8Array([1, 2, 3])), {})) // => Option.some("010203") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some(new Uint8Array([1, 2, 3])), {})) + * result // => Option.some("010203") * ``` * * @see {@link decodeHex} for the inverse operation to `Uint8Array` @@ -1367,7 +1518,7 @@ export function encodeHex(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64() - * const result = await Effect.runPromise(decode.run(Option.some("AQID"), {})) + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("AQID"), {})) * Option.map(result, Array.from) // => Option.some([1, 2, 3]) * ``` * @@ -1404,7 +1555,8 @@ export function decodeBase64(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64String() - * await Effect.runPromise(decode.run(Option.some("aGVsbG8="), {})) // => Option.some("hello") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("aGVsbG8="), {})) + * result // => Option.some("hello") * ``` * * @see {@link decodeBase64} to decode to `Uint8Array` instead @@ -1442,7 +1594,7 @@ export function decodeBase64String(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64Url() - * const result = await Effect.runPromise(decode.run(Option.some("-_8="), {})) + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("-_8="), {})) * Option.map(result, Array.from) // => Option.some([251, 255]) * ``` * @@ -1481,7 +1633,8 @@ export function decodeBase64Url(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64UrlString() - * await Effect.runPromise(decode.run(Option.some("aGVsbG8"), {})) // => Option.some("hello") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("aGVsbG8"), {})) + * result // => Option.some("hello") * ``` * * @see {@link decodeBase64Url} to decode to `Uint8Array` instead @@ -1519,7 +1672,7 @@ export function decodeBase64UrlString(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeHex() - * const result = await Effect.runPromise(decode.run(Option.some("010203"), {})) + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("010203"), {})) * Option.map(result, Array.from) // => Option.some([1, 2, 3]) * ``` * @@ -1558,7 +1711,8 @@ export function decodeHex(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeHexString() - * await Effect.runPromise(decode.run(Option.some("68656c6c6f"), {})) // => Option.some("hello") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("68656c6c6f"), {})) + * result // => Option.some("hello") * ``` * * @see {@link decodeHex} to decode to `Uint8Array` instead @@ -1598,7 +1752,8 @@ export function decodeHexString(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeUriComponent() - * await Effect.runPromise(encode.run(Option.some("hello world"), {})) // => Option.some("hello%20world") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some("hello world"), {})) + * result // => Option.some("hello%20world") * ``` * * @see {@link decodeUriComponent} for the inverse operation @@ -1623,7 +1778,8 @@ export function encodeUriComponent(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeUriComponent() - * await Effect.runPromise(decode.run(Option.some("hello%20world"), {})) // => Option.some("hello world") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("hello%20world"), {})) + * result // => Option.some("hello world") * ``` * * @see {@link encodeUriComponent} for the inverse operation @@ -1670,7 +1826,9 @@ export function decodeUriComponent(): Getter { * import { DateTime, Effect, Option, SchemaGetter } from "effect" * * const parseDate = SchemaGetter.dateTimeUtcFromInput() - * const result = await Effect.runPromise(parseDate.run(Option.some("2024-01-01T00:00:00Z"), {})) + * const result = await Effect.runPromise( + * SchemaGetter.run(parseDate, Option.some("2024-01-01T00:00:00Z"), {}) + * ) * Option.map(result, DateTime.toEpochMillis) // => Option.some(1704067200000) * ``` * @@ -1713,7 +1871,8 @@ export function dateTimeUtcFromInput(): Gette * const decode = SchemaGetter.decodeFormData() * const formData = new FormData() * formData.append("user[name]", "Alice") - * await Effect.runPromise(decode.run(Option.some(formData), {})) // => Option.some({ user: { name: "Alice" } }) + * const result = Effect.runSync(SchemaGetter.run(decode, Option.some(formData), {})) + * result // => Option.some({ user: { name: "Alice" } }) * ``` * * @see {@link encodeFormData} for the corresponding encoder @@ -1751,7 +1910,7 @@ const collectFormDataEntries = collectBracketPathEntries((value): value is strin * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeFormData() - * const result = await Effect.runPromise(encode.run(Option.some({ name: "Alice" }), {})) + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some({ name: "Alice" }), {})) * Option.map(result, (formData) => formData.get("name")) // => Option.some("Alice") * ``` * @@ -1796,7 +1955,8 @@ export function encodeFormData(): Getter { * * const decode = SchemaGetter.decodeURLSearchParams() * const params = new URLSearchParams("user[name]=Alice") - * await Effect.runPromise(decode.run(Option.some(params), {})) // => Option.some({ user: { name: "Alice" } }) + * const result = Effect.runSync(SchemaGetter.run(decode, Option.some(params), {})) + * result // => Option.some({ user: { name: "Alice" } }) * ``` * * @see {@link encodeURLSearchParams} for the corresponding encoder @@ -1831,7 +1991,7 @@ const collectURLSearchParamsEntries = collectBracketPathEntries(Predicate.isStri * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeURLSearchParams() - * const result = await Effect.runPromise(encode.run(Option.some({ name: "Alice" }), {})) + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some({ name: "Alice" }), {})) * Option.map(result, (params) => params.toString()) // => Option.some("name=Alice") * ``` * diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 47b068c7c9b..2c955e9d78e 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" @@ -109,18 +109,7 @@ export function makeOption(schema: S) { * @since 4.0.0 */ export function make(schema: S) { - const parser = makeEffect(schema) - return (input: S["~type.make.in"], options?: Schema.MakeOptions): S["Type"] => { - const exit = Effect.runSyncExit(parser(input, options)) - if (Exit.isSuccess(exit)) { - return exit.value - } - const issue = InternalSchemaCause.getSchemaIssueOrThrow( - exit.cause, - "Constructor adapter can only throw schema issues" - ) - throw new Error("Schema validation failed", { cause: issue }) - } + return makeConstructorSync(SchemaAST.toType(schema.ast)) } /** @@ -150,19 +139,55 @@ export function is(schema: S): (input: I) => inp return _is(schema.ast) } -/** @internal */ -export function _is(ast: SchemaAST.AST) { - const parser = asExit(run(SchemaAST.toType(ast))) - return (input: I): input is I & T => { - const exit = parser(input, SchemaAST.defaultParseOptions) - if (Exit.isSuccess(exit)) { - return true +function makeIs(ast: SchemaAST.AST): (input: I) => input is I & T { + if (!CompilerRegistry.compilerAdaptersEnabled) { + const parser = asExit(run(ast)) + return (input: I): input is I & T => { + const exit = parser(input, SchemaAST.defaultParseOptions) + if (Exit.isSuccess(exit)) return true + InternalSchemaCause.getSchemaIssueOrThrow( + exit.cause, + "Type guard adapter can only return false for schema issues" + ) + return false } + } + const entry = CompilerRegistry.resolve(ast) + const guard = entry.is + if (guard !== undefined) { + return (input: I): input is I & T => { + try { + return guard(input, SchemaAST.defaultParseOptions) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow( + Cause.die(error), + "Type guard adapter can only return false for schema issues" + ) + return false + } + } + } + const parser = entry.parser + return (input: I): input is I & T => { + const exit = Effect.runSyncExit(parserResult(parser(input, SchemaAST.defaultParseOptions), input)) + if (Exit.isSuccess(exit)) return true InternalSchemaCause.getSchemaIssueOrThrow(exit.cause, "Type guard adapter can only return false for schema issues") return false } } +/** @internal */ +export function _is(ast: SchemaAST.AST) { + const typeAST = SchemaAST.toType(ast) + let guard: (input: I) => input is I & T = (input: I): input is I & T => { + guard = makeIs(typeAST) + return guard(input) + } + return (input: I): input is I & T => { + return guard(input) + } +} + /** @internal */ export function _issue(ast: SchemaAST.AST) { const parser = run(ast) @@ -526,7 +551,9 @@ export function decodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Type"] { - return asSync(decodeUnknownEffect(schema, options)) + return CompilerRegistry.compilerAdaptersEnabled + ? makeSync(schema.ast, options) + : asSync(decodeUnknownEffect(schema, options)) } /** @@ -871,7 +898,9 @@ export function encodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Encoded"] { - return asSync(encodeUnknownEffect(schema, options)) + return CompilerRegistry.compilerAdaptersEnabled + ? makeSync(SchemaAST.flip(schema.ast), options) + : asSync(encodeUnknownEffect(schema, options)) } /** @@ -926,6 +955,22 @@ export function run(ast: SchemaAST.AST) { return runWithCompiler(normalCompiler, ast) } +function parserResult( + result: Effect.Effect, + input: unknown +): Effect.Effect { + 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 +} + function runWithCompiler(compiler: Compiler, ast: SchemaAST.AST) { let parser: Parser return (input: unknown, options?: SchemaAST.ParseOptions): Effect.Effect => { @@ -998,6 +1043,49 @@ function asResult( } } +function makeSync( + ast: SchemaAST.AST, + options?: SchemaAST.ParseOptions +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + let run: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined + return (input, overrideOptions) => + (run ??= makeSyncEntry(CompilerRegistry.resolve(ast), options))(input, overrideOptions) +} + +function makeSyncEntry( + entry: CompilerRegistry.Entry, + options?: SchemaAST.ParseOptions +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + const decode = entry.decode + let detailed: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined + const run = decode === undefined + ? (input: unknown, parseOptions = SchemaAST.defaultParseOptions): T => + (detailed ??= makeDetailedSync(entry))(input, parseOptions) + : (input: unknown, parseOptions = SchemaAST.defaultParseOptions): T => { + if (input === InternalParser.missing) { + return (detailed ??= makeDetailedSync(entry))(input, parseOptions) + } + let output: unknown + try { + output = decode(input, parseOptions) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow(Cause.die(error), "Sync adapter can only throw schema issues") + throw error + } + if (output !== CompilerRegistry.invalid) return output as T + return (detailed ??= makeDetailedSync(entry))(input, parseOptions) + } + return options === undefined + ? run + : (input, overrideOptions) => run(input, mergeParseOptions(options, overrideOptions)) +} + +function makeDetailedSync( + entry: CompilerRegistry.Entry +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + return asSync(runWithCompiler(() => entry.decodeEffect, entry.ast)) +} + function asSync( parser: (input: E, options?: SchemaAST.ParseOptions) => Effect.Effect ): (input: E, options?: SchemaAST.ParseOptions) => T { @@ -1012,6 +1100,41 @@ function asSync( } } +function runSync(effect: Effect.Effect, message: string): T { + const exit = Effect.runSyncExit(effect) + if (Exit.isSuccess(exit)) { + return exit.value + } + const issue = InternalSchemaCause.getSchemaIssueOrThrow(exit.cause, message) + throw new Error("Schema validation failed", { cause: issue }) +} + +function makeConstructorSync( + ast: SchemaAST.AST +): (input: E, options?: Schema.MakeOptions) => T { + let entry: CompilerRegistry.Entry | undefined + let parser: Parser | undefined + return (input, options) => { + entry ??= CompilerRegistry.resolve(ast) + const parseOptions = options?.disableChecks + ? options.parseOptions ? { ...options.parseOptions, disableChecks: true } : { disableChecks: true } + : options?.parseOptions ?? SchemaAST.defaultParseOptions + const make = entry.make + if (make !== undefined && input !== InternalParser.missing) { + let output: unknown + try { + output = make(input, parseOptions) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow(Cause.die(error), "Constructor adapter can only throw schema issues") + throw error + } + if (output !== CompilerRegistry.invalid && output !== InternalParser.missing) return output as T + } + const result = (parser ??= entry.makeEffect)(input, parseOptions) + return runSync(parserResult(result, input), "Constructor adapter can only throw schema issues") + } +} + /** @internal */ export interface Parser { ( @@ -1025,186 +1148,5 @@ 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) -} - -function applyTransformation( - result: Effect.Effect, - current: unknown, - transformation: SchemaAST.Link["transformation"], - 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) -} - -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) - } -} - -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 - if (!links && !checks && !encodingChecks) { - return parser - } - 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) - }) - } - } - - 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) - }) - } - } - - return result - } - if (!links) { - return parseLocal - } - return ( - input: unknown, - options: SchemaAST.ParseOptions - ) => { - 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 normalCompiler: Compiler = (ast) => CompilerRegistry.resolve(ast).parser +const constructorCompiler: Compiler = (ast) => CompilerRegistry.resolve(ast).makeEffect diff --git a/packages/effect/src/SchemaTransformation.ts b/packages/effect/src/SchemaTransformation.ts index 836e87ed21b..8a882c2d111 100644 --- a/packages/effect/src/SchemaTransformation.ts +++ b/packages/effect/src/SchemaTransformation.ts @@ -18,6 +18,7 @@ import * as DateTime from "./DateTime.ts" import * as Duration from "./Duration.ts" import * as Effect from "./Effect.ts" import { format, formatDate, formatJson } from "./Formatter.ts" +import { dual } from "./Function.ts" import * as Option from "./Option.ts" import * as Predicate from "./Predicate.ts" import type { ErrorOptions, Json } from "./Schema.ts" @@ -145,17 +146,18 @@ const TypeId = "~effect/SchemaTransformation/Transformation" * `Schema.decode`, `Schema.encode`, and `Schema.link`. Each direction is a * `SchemaGetter.Getter` that handles optionality, failure, and Effect services. * - * - Immutable — `flip()` and `compose()` return new instances. + * - Immutable — `flip()` and {@link compose} return new instances. * - `flip()` swaps the decode and encode getters. - * - `compose(other)` chains: `this.decode` then `other.decode` for decoding, - * `other.encode` then `this.encode` for encoding. + * - `compose(self, other)` chains: `self.decode` then `other.decode` for decoding, + * `other.encode` then `self.encode` for encoding. * * **Example** (Composing two transformations) * * ```ts import.meta.vitest * import { SchemaTransformation } from "effect" * - * const trimAndLower = SchemaTransformation.trim().compose( + * const trimAndLower = SchemaTransformation.compose( + * SchemaTransformation.trim(), * SchemaTransformation.toLowerCase() * ) * trimAndLower._tag // => "Transformation" @@ -175,7 +177,6 @@ export interface Transformation { readonly decode: SchemaGetter.Getter readonly encode: SchemaGetter.Getter flip(): Transformation - compose(other: Transformation): Transformation } /** @@ -203,14 +204,56 @@ export const Transformation: new( flip(): Transformation { return new Transformation(this.encode, this.decode) } - compose(other: Transformation): Transformation { - return new Transformation( - this.decode.compose(other.decode), - other.encode.compose(this.encode) - ) - } } +/** + * Composes two schema transformations into a single bidirectional conversion. + * + * **When to use** + * + * Use when decoding and encoding require the same sequence of conversion + * steps in opposite directions. + * + * **Details** + * + * Decoding applies `self.decode` followed by `other.decode`. Encoding applies + * `other.encode` followed by `self.encode`. The function supports both + * `compose(self, other)` and `compose(other)(self)`. + * + * **Example** (Trimming and lowercasing a string) + * + * ```ts import.meta.vitest + * import { Schema, SchemaTransformation } from "effect" + * + * const transformation = SchemaTransformation.compose( + * SchemaTransformation.trim(), + * SchemaTransformation.toLowerCase() + * ) + * const schema = Schema.String.pipe(Schema.decode(transformation)) + * + * Schema.decodeUnknownSync(schema)(" HELLO ") // => "hello" + * ``` + * + * @category combining + * @since 4.0.0 + */ +export const compose: { + ( + other: Transformation + ): (self: Transformation) => Transformation + ( + self: Transformation, + other: Transformation + ): Transformation +} = dual(2, ( + self: Transformation, + other: Transformation +): Transformation => + new Transformation( + SchemaGetter.compose(self.decode, other.decode), + SchemaGetter.compose(other.encode, self.encode) + )) + /** * Returns `true` if `u` is a `Transformation` instance. * diff --git a/packages/effect/src/internal/arbitrary/schema.ts b/packages/effect/src/internal/arbitrary/schema.ts index be19cced950..3013da9e5fa 100644 --- a/packages/effect/src/internal/arbitrary/schema.ts +++ b/packages/effect/src/internal/arbitrary/schema.ts @@ -1664,7 +1664,7 @@ export function compile(schema: S): Model.Compiled< const decodeDeclaration = SchemaParser.run(ast) const decode = (value: unknown): Model.Computation> => { const transformed = link.transformation._tag === "Transformation" - ? link.transformation.decode.run(Option.some(value), SchemaAST.defaultParseOptions) + ? SchemaGetter.run(link.transformation.decode, Option.some(value), SchemaAST.defaultParseOptions) : link.transformation.decode(Effect.succeed(Option.some(value)), SchemaAST.defaultParseOptions) return Model.flatMapComputation(optionComputation(transformed), (outer) => { if (Option.isNone(outer) || Option.isNone(outer.value)) return Option.none() diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts new file mode 100644 index 00000000000..e6a243aac8d --- /dev/null +++ b/packages/effect/src/internal/schema/codegen.ts @@ -0,0 +1,837 @@ +import * as SchemaAST from "../../SchemaAST.ts" +import type { CompiledDecoder } from "../../unstable/schema/SchemaCompiler.ts" +import type { runtime } from "../../unstable/schema/SchemaCompiler/runtime.ts" + +const getEncodingChecks = (ast: SchemaAST.AST): SchemaAST.Checks | undefined => + "encodingChecks" in ast ? ast.encodingChecks : undefined +const getExpectedKeys = (ast: SchemaAST.Objects): ReadonlyArray => + ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name) + +const isOptional = (ast: SchemaAST.AST): boolean => ast.context?.isOptional ?? false + +const maxGeneratedDepth = 256 +/** @internal */ +export const maxGeneratedNodes = 2048 + +type Emission = "unsupported" | "decode" | "is" +type Operation = "decode" | "is" + +const failureExpression = (operation: Operation): string => operation === "decode" ? "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 === "decode") isOutputFree = false + } + for (const element of ast.rest) { + const emission = getEmission(element, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "decode") isOutputFree = false + } + return isOutputFree ? "is" : "decode" + } + 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 === "decode") 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 === "decode" || value === "decode") isOutputFree = false + } + return isOutputFree ? "is" : "decode" + } + 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 === "decode") isOutputFree = false + } + return isOutputFree ? "is" : "decode" + } + case "Declaration": + case "Suspend": + return "unsupported" + } +} + +const canEmit = (ast: SchemaAST.AST, depth = 0): boolean => getEmission(ast, depth) !== "unsupported" + +const isMakeSafe = ( + ast: SchemaAST.AST, + depth = 0, + budget = { remaining: maxGeneratedNodes } +): boolean => { + if ( + --budget.remaining < 0 || + depth > maxGeneratedDepth || + ast.encoding !== undefined || + ast.context?.constructorDefault !== undefined || + SchemaAST.getConstructorDescriptor(ast) !== undefined + ) { + return false + } + 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 true + case "TemplateLiteral": + return ast.parts.every((part) => isMakeSafe(part, depth + 1, budget)) + case "Arrays": + return ast.elements.every((element) => isMakeSafe(element, depth + 1, budget)) && + ast.rest.every((element) => isMakeSafe(element, depth + 1, budget)) + case "Objects": + return ast.indexSignatures.length === 0 && + ast.propertySignatures.every((property) => isMakeSafe(property.type, depth + 1, budget)) + case "Union": + case "Declaration": + case "Suspend": + return false + } +} + +const shouldCompileMake = (ast: SchemaAST.AST): ast is SchemaAST.Arrays | SchemaAST.Objects => + (ast._tag === "Arrays" && ast.elements.length === 0 && ast.rest.length === 1 || + ast._tag === "Objects" && ast.propertySignatures.length > 0 && ast.indexSignatures.length === 0) && + isMakeSafe(ast) + +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 */ +export const shouldCompileParser = (ast: SchemaAST.AST, local = false): boolean => { + if (!local && ast.encoding !== undefined) return true + if (SchemaAST.getConstructorDescriptor(ast) !== 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 || getEncodingChecks(ast) !== undefined || ast.encoding !== 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 === "decode" ? output : "true" + const checked = variable(emitter) + statements.push( + `const ${checked}=${output}`, + `if(K(${astConstant},${checked},0,o))return ${invalid}` + ) + return operation === "decode" ? 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 === "decode" + 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 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, path = "ast"): 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, path) + const bindings = { + K: "failsChecks", + T: "matchesTemplateLiteral", + U: "getCandidates", + G: "getIndexSignatureKeys", + D: "defaultParseOptions", + E: "hasExcessProperties" + } as const + const source = `"use strict";${runtimeBindings(operation === "decode" ? { 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 type DecoderOperation = keyof CompiledDecoder + +const renderOperation = (emitted: GeneratedOperation): string => + `(function(C,R){${emitted.source}})([${emitted.bindings.map((binding) => binding.reference).join(",")}],R)` + +/** @internal */ +export function generate(ast: SchemaAST.AST, operation: DecoderOperation): string | undefined { + if (operation === "make") { + if (!shouldCompileMake(ast)) return undefined + if (ast._tag === "Objects") { + return `if(ast.propertySignatures.some(p=>resolve(p.type).source!==void 0))return;return ${ + renderOperation(emitOperation(ast, "decode")) + }` + } + const element = renderOperation(emitOperation(ast.rest[0], "decode", "ast.rest[0]")) + return `const e=resolve(ast.rest[0]),m=e.source===void 0?${element}:e.make;if(m===void 0)return;` + + "return function(i,o){if(i===R.missing)return R.missing;if(!Array.isArray(i))return R.invalid;" + + "const out=new Array(i.length);for(let x=0;x 0 && + ast.indexSignatures.length === 0 && ast.propertySignatures.length <= maxGeneratedNodes + ? emitObject(ast) + : "undefined" + const array = ast._tag === "Arrays" && ast.elements.length === 0 && ast.rest.length === 1 + ? emitArray() + : "undefined" + if (operation === "makeEffect") return `return R.make(ast,resolve,${object},${array})` + const checkpoint = ast.encoding !== undefined && getEmission(ast, 0, true) !== "unsupported" + ? `()=>${renderOperation(emitOperation(ast, "decode"))}` + : "undefined" + return `return R.decode(ast,resolve,${object},${getEmission(ast) !== "unsupported"},${checkpoint},${array})` +} + +const emitArray = (): string => + `function({getElement,step,resume}){return function(s,input,index=0,end=input.length){ + const parser=getElement();let r,t,value; + for(;index { + if (ast.checks !== undefined || getEncodingChecks(ast) !== undefined) return undefined + switch (ast._tag) { + case "Null": + return `${input}===null` + case "Undefined": + return `${input}===void 0` + case "Never": + return "false" + case "Any": + case "Unknown": + return "true" + case "ObjectKeyword": + return `((${input}!==null&&typeof ${input}==="object")||typeof ${input}==="function")` + case "UniqueSymbol": + return `${input}===${path}.symbol` + case "Literal": + return `${input}===${path}.literal` + case "String": + return `typeof ${input}==="string"` + case "Number": + return `typeof ${input}==="number"` + case "Boolean": + return `typeof ${input}==="boolean"` + case "Symbol": + return `typeof ${input}==="symbol"` + case "BigInt": + return `typeof ${input}==="bigint"` + case "TemplateLiteral": + return `R.matchesTemplateLiteral(${path},${input},o)` + default: + return undefined + } +} + +const canInlineEncoding = (ast: SchemaAST.AST): ast is SchemaAST.AST & { readonly encoding: SchemaAST.Encoding } => + ast.encoding !== undefined && + ast.checks === undefined && + getEncodingChecks(ast) === undefined && + inlineIdentityPredicate(ast, "v", "ast") !== undefined && + ast.encoding.every((link) => + link.to.encoding === undefined && + inlineIdentityPredicate(link.to, "v", "ast") !== undefined && + link.transformation._tag === "Transformation" && + (link.transformation.decode._tag === "Passthrough" || link.transformation.decode._tag === "Transform") + ) + +const emitObject = (ast: SchemaAST.Objects): string => { + const initializers: Array = [] + const statements = [ + "if(i===R.missing)return R.missingExit", + "if(o.errors===\"all\"||o.onExcessProperty!==void 0||(o.concurrency!==void 0&&o.concurrency!==1))return fallback(i,o)", + "if(typeof i!==\"object\"||i===null||Array.isArray(i))return R.invalidType(ast,i,o)", + "const properties=getProperties(),out={}", + "const state={ast,input:i,out,options:o,issues:void 0}", + "let r,t,value" + ] + ast.propertySignatures.forEach((property, index) => { + const key = typeof property.name === "symbol" ? `properties[${index}].name` : JSON.stringify(String(property.name)) + const present = property.name === "__proto__" ? `Object.hasOwn(i,${key})` : `${key} in i` + const handle = `if(r===R.sameExit){if(h${index}){${assignProperty("out", key, `v${index}`, property.name)}}}else{` + + `if(!R.effectIsExit(r))return resume(state,${index},r);` + + `if(r._tag==="Success"&&(value=r[R.args])!==R.missing){${assignProperty("out", key, "value", property.name)}}` + + `else{t=step(state,p${index},r);if(t)return t}}` + const propertyPath = `ast.propertySignatures[${index}].type` + if (canInlineEncoding(property.type)) { + const links = property.type.encoding + const sourcePath = `${propertyPath}.encoding[${links.length - 1}].to` + const source = inlineIdentityPredicate(links[links.length - 1].to, `v${index}`, sourcePath)! + const fast: Array = [`let x${index}=v${index};r=void 0;l${index}:{`] + for (let linkIndex = links.length - 1; linkIndex >= 0; linkIndex--) { + const transformation = links[linkIndex].transformation + if (transformation._tag === "Transformation" && transformation.decode._tag === "Transform") { + const transform = `t${index}_${linkIndex}` + initializers.push( + `const ${transform}=${propertyPath}.encoding[${linkIndex}].transformation.decode.transform` + ) + fast.push(`x${index}=${transform}(x${index})`) + } + const targetPath = linkIndex === 0 + ? propertyPath + : `${propertyPath}.encoding[${linkIndex - 1}].to` + const target = linkIndex === 0 ? property.type : links[linkIndex - 1].to + const predicate = inlineIdentityPredicate(target, `x${index}`, targetPath)! + const failure = linkIndex === 0 + ? `R.invalidType(${propertyPath},x${index},o)` + : `R.wrapEncoding(${propertyPath},v${index},o,R.invalidType(${targetPath},x${index},o))` + fast.push( + `if(x${index}===R.missing){r=R.missingExit;break l${index}}else if(!(${predicate})){r=${failure};break l${index}}` + ) + } + fast.push( + `};if(r!==void 0){${handle}}else{${assignProperty("out", key, `x${index}`, property.name)}}` + ) + const run = `if(v${index}!==R.missing&&(${source})){${ + fast.join(";") + }}else{r=p${index}.parser(v${index},o);${handle}}` + statements.push( + `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + run + ) + } else { + statements.push( + `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + `r=p${index}.parser(v${index},o)`, + handle + ) + } + }) + statements.push("return R.succeed(out)") + return `function({ast,getProperties,fallback,resume,step}){${initializers.join(";")};return function(i,o){try{${ + statements.join(";") + }}catch(e){return R.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..5f2ca4d1dd0 --- /dev/null +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -0,0 +1,193 @@ +import * as Effect from "../../Effect.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type { Parser } from "../../SchemaParser.ts" +import type { CompiledDecoder, Decode, Is, Make } 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 Resolve = (ast: SchemaAST.AST) => Entry + +/** @internal */ +export type DecoderSource = Partial + +/** @internal */ +export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => DecoderSource | undefined + +const cache = new WeakMap() +let compiler: ((ast: SchemaAST.AST, resolve: Resolve) => Entry) | undefined + +/** @internal */ +export let compilerAdaptersEnabled = false + +function activateCompilerAdapters(): void { + compilerAdaptersEnabled = true +} + +const decodeChild = (ast: SchemaAST.AST): Parser => + compilerAdaptersEnabled + ? lazyParser(resolve, ast, "parser") + : resolve(ast).parser +const makeChild = (ast: SchemaAST.AST): Parser => + compilerAdaptersEnabled + ? lazyParser(resolve, ast, "makeEffect") + : resolve(ast).makeEffect +const makeField = (ast: SchemaAST.AST): Parser => Interpreter.compileField(ast, makeChild) + +/** @internal */ +export interface Entry { + readonly ast: SchemaAST.AST + readonly source?: DecoderSource | undefined + readonly resolve?: Resolve | undefined + readonly is?: Is | undefined + readonly decode?: Decode | undefined + readonly make?: Make | undefined + readonly decodeEffect: Parser + readonly parser: Parser + readonly makeEffect: Parser +} + +class InterpretedEntry implements Entry { + readonly ast: SchemaAST.AST + declare private cachedDecodeEffect: Parser | undefined + declare private cachedMakeEffect: Parser | undefined + + constructor(ast: SchemaAST.AST) { + this.ast = ast + } + + get decodeEffect(): Parser { + return this.cachedDecodeEffect ??= Interpreter.compile(this.ast, decodeChild) + } + + get parser(): Parser { + return this.decodeEffect + } + + get makeEffect(): Parser { + return this.cachedMakeEffect ??= Interpreter.compile(this.ast, makeChild, makeField) + } +} + +class CompilerEntry extends InterpretedEntry { + readonly source: DecoderSource | undefined + readonly resolve: Resolve + + constructor(ast: SchemaAST.AST, source: DecoderSource | undefined, resolve: Resolve) { + super(ast) + this.source = source + this.resolve = resolve + } + + private save(key: K, value: Entry[K]): Entry[K] { + Object.defineProperty(this, key, { value }) + return value + } + + get is(): Is | undefined { + return this.save("is", this.source?.is) + } + + get decode(): Decode | undefined { + return this.save("decode", this.source?.decode) + } + + get make(): Make | undefined { + return this.save("make", this.source?.make) + } + + override get decodeEffect(): Parser { + return this.save( + "decodeEffect", + this.source?.decodeEffect ?? Interpreter.compile(this.ast, (ast) => lazyParser(this.resolve, ast, "parser")) + ) + } + + override get parser(): Parser { + const decode = this.decode + return decode === undefined + ? this.decodeEffect + : this.save("parser", withDecode(decode, () => this.decodeEffect)) + } + + override get makeEffect(): Parser { + const makeEffect = this.source?.makeEffect + if (makeEffect !== undefined) return this.save("makeEffect", makeEffect) + const child = (ast: SchemaAST.AST): Parser => lazyParser(this.resolve, ast, "makeEffect") + return this.save( + "makeEffect", + Interpreter.compile( + this.ast, + child, + (ast) => Interpreter.compileField(ast, child) + ) + ) + } +} + +/** @internal */ +export function withDecode(fastDecode: Decode, decodeEffect: () => Parser): Parser { + let detailed: Parser | undefined + return (input, options) => { + if (input !== InternalParser.missing) { + try { + const value = fastDecode(input, options) + if (value !== invalid) return value === input ? InternalParser.sameExit : InternalParser.succeed(value) + } catch (error) { + return Effect.die(error) + } + } + return (detailed ??= decodeEffect())(input, options) + } +} + +/** @internal */ +export function lazyParser( + resolve: Resolve, + ast: SchemaAST.AST, + operation: "parser" | "decodeEffect" | "makeEffect" +): Parser { + const entry = resolve(ast) + if (entry.source === undefined || Object.hasOwn(entry, operation)) return entry[operation] + let parser: Parser | undefined + return (input, options) => (parser ??= entry[operation])(input, options) +} + +/** @internal */ +export function resolve(ast: SchemaAST.AST): Entry { + const cached = cache.get(ast) + if (cached !== undefined) return cached + const entry = compiler === undefined ? new InterpretedEntry(ast) : compiler(ast, resolve) + cache.set(ast, entry) + return entry +} + +/** @internal */ +export function set(ast: SchemaAST.AST, decoder: DecoderSource | undefined, resolveChild: Resolve = resolve): Entry { + if (decoder !== undefined) activateCompilerAdapters() + const entry = new CompilerEntry(ast, decoder, resolveChild) + cache.set(ast, entry) + return entry +} + +/** @internal */ +export function install(compile: Compile): void { + activateCompilerAdapters() + compiler = (ast, resolve) => new CompilerEntry(ast, compile(ast, resolve), resolve) +} + +/** @internal */ +export function enable(ast: SchemaAST.AST, compile: Compile): void { + activateCompilerAdapters() + const scoped: Resolve = (child) => { + const cached = cache.get(child) + return cached !== undefined && (cached.source !== undefined || cached.resolve === scoped) + ? cached + : set(child, compile(child, scoped), scoped) + } + const decoder = compile(ast, scoped) + if (decoder !== undefined || cache.get(ast)?.source === undefined) set(ast, decoder, scoped) +} diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts new file mode 100644 index 00000000000..a4600d77a12 --- /dev/null +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -0,0 +1,241 @@ +import * as Cause from "../../Cause.ts" +import * as Effect from "../../Effect.ts" +import type * as Exit from "../../Exit.ts" +import type * as Option from "../../Option.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import type { Compiler, Parser } from "../../SchemaParser.ts" +import { effectIsExit } from "../effect.ts" +import * as InternalParser from "./parser.ts" + +type ApplyTransformation = ( + result: Effect.Effect, + current: unknown, + options: SchemaAST.ParseOptions +) => Effect.Effect + +const flatMapTransformation = ( + result: Effect.Effect, + current: unknown, + f: (value: unknown) => Effect.Effect +): Effect.Effect => + result === InternalParser.sameExit ? f(current) : Effect.flatMapEager(result, f) + +function compileTransformation(transformation: SchemaAST.Link["transformation"]): ApplyTransformation { + if (transformation._tag === "Middleware") { + return (result, current, options) => { + const transformed = result === InternalParser.sameExit + ? transformation.decode(InternalParser.succeed(InternalParser.toOption(current)), options) + : transformation.decode(Effect.mapEager(result, InternalParser.toOption), options) + return fromOptionalEffect(transformed) + } + } + + const getter = transformation.decode + switch (getter._tag) { + case "Passthrough": + return (result, current) => result === InternalParser.sameExit ? InternalParser.succeed(current) : result + case "Transform": { + const transform = (value: unknown) => + value === InternalParser.missing + ? InternalParser.missingExit + : InternalParser.succeed(getter.transform(value)) + return (result, current) => flatMapTransformation(result, current, transform) + } + case "TransformOptional": { + const transform = (value: unknown) => + InternalParser.fromOptionExit(getter.transform(InternalParser.toOption(value))) + return (result, current) => flatMapTransformation(result, current, transform) + } + case "TransformEffect": + return (result, current, options) => + flatMapTransformation(result, current, (value) => + value === InternalParser.missing + ? InternalParser.missingExit + : getter.transform(value, options)) + case "TransformOptionalEffect": + return (result, current, options) => + flatMapTransformation( + result, + current, + (value) => fromOptionalEffect(getter.transform(InternalParser.toOption(value), options)) + ) + } +} + +const fromOptionalEffect = ( + effect: Effect.Effect, SchemaIssue.Issue, unknown> +): Effect.Effect => Effect.flatMapEager(effect, InternalParser.fromOptionExit) + +/** @internal */ +export const wrapEncoding = ( + ast: SchemaAST.AST, + input: unknown, + options: SchemaAST.ParseOptions, + effect: Effect.Effect +): Effect.Effect => + Effect.catchCause( + effect, + (cause) => + Effect.failCauseSync(() => Cause.map(cause, (issue) => new SchemaIssue.Encoding(ast, issue, input, options))) + ) + +function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, compile: Compiler): Parser { + const transform = compileTransformation(descriptor.link.transformation) + 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 transform(result, input, options) + } +} + +function withDefault(ast: SchemaAST.AST, parser: Parser): Parser { + const defaultValue = ast.context!.constructorDefault! + return (input, options) => { + if (input !== InternalParser.missing && input !== undefined) return parser(input, options) + const result = defaultValue + if (effectIsExit(result) && result._tag === "Success") { + const local = parser((result as InternalParser.Success)[InternalParser.args], options) + return local === InternalParser.sameExit ? result : local + } + return Effect.flatMapEager( + wrapEncoding(ast, input, options, result), + (value) => { + const local = parser(value, options) + return local === InternalParser.sameExit ? InternalParser.succeed(value) : local + } + ) + } +} + +/** @internal */ +export function compileField(ast: SchemaAST.AST, compile: Compiler): Parser { + const parser = compile(ast) + return ast.context?.constructorDefault === undefined ? parser : withDefault(ast, parser) +} + +/** @internal */ +export function compile( + ast: SchemaAST.AST, + compile: Compiler, + compileField?: Compiler, + base?: Parser, + specialize?: (local: Parser) => Parser +): Parser { + if (ast._tag === "Declaration") { + // Declaration callbacks can create public parsers for their type parameters. + // Register those ASTs with the same resolver before invoking the callback. + for (const parameter of ast.typeParameters) compile(parameter) + } + // Construction supplies compileField for parent-owned defaults. Its presence + // also selects the constructor semantics of declarations and Unions. + const descriptor = compileField ? SchemaAST.getConstructorDescriptor(ast) : undefined + const parser = descriptor + ? makeConstructorParser(descriptor, compile) + : base ?? ast.getParser(compile, compileField) + const checks = ast.checks + const links = ast.encoding + const transformations = links?.map((link) => compileTransformation(link.transformation)) + const encodingChecks = (ast as any).encodingChecks + if (!links && !checks && !encodingChecks) { + return parser + } + let encodingParsers: ReadonlyArray | undefined + const parseChecks = ( + 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) + }) + } + } + + 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) + }) + } + } + + return result + } + const parseLocal = specialize === undefined ? parseChecks : specialize(parseChecks) + if (!links) { + return parseLocal + } + return ( + input: unknown, + options: SchemaAST.ParseOptions + ) => { + 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 = transformations![i](result, current, 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 = wrapEncoding(ast, input, options, result) + return Effect.flatMapEager(result, (value) => { + const local = parseLocal(value, options) + return local === InternalParser.sameExit ? InternalParser.succeed(value) : local + }) + } +} diff --git a/packages/effect/src/unstable/schema/Model.ts b/packages/effect/src/unstable/schema/Model.ts index 83ed597d3c9..f216d5cbfe9 100644 --- a/packages/effect/src/unstable/schema/Model.ts +++ b/packages/effect/src/unstable/schema/Model.ts @@ -427,7 +427,7 @@ export interface Date extends Schema.decodeTo, S */ export const Date: Date = Schema.String.pipe( Schema.decodeTo(Schema.DateTimeUtc, { - decode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.removeTime), + decode: SchemaGetter.map(SchemaGetter.dateTimeUtcFromInput(), DateTime.removeTime), encode: SchemaGetter.transform(DateTime.formatIsoDate) }) ) diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts new file mode 100644 index 00000000000..1fdbc3f0f23 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -0,0 +1,239 @@ +/** + * 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" +import type { runtime } from "./SchemaCompiler/runtime.ts" + +const helper = (name: keyof typeof runtime): string => `R.${name}` +const decoderOperationOrder: ReadonlyArray = [ + "is", + "decode", + "make", + "decodeEffect", + "makeEffect" +] +const operationOrder: ReadonlyArray = ["decode", "is", "make"] + +const decoder = (sources: ReadonlyMap): string | undefined => { + const members = decoderOperationOrder.flatMap((key) => { + const source = sources.get(key) + return source === undefined ? [] : [`get ${key}(){${source}}`] + }).join(",") + return members.length === 0 ? undefined : `{${members}}` +} + +/** + * A parser operation prepared by {@link compile}. + * + * @category models + * @since 4.0.0 + */ +export type Operation = "decode" | "is" | "make" + +type PlannedOperation = Operation | "decodeEffect" + +/** + * An exact AST and the parser operations to prepare for it. + * + * @category models + * @since 4.0.0 + */ +export interface Target { + /** The registry key installed by the generated module. */ + readonly ast: SchemaAST.AST + /** The operations that should be compiled for this AST. */ + readonly operations: ReadonlyArray +} + +/** + * Generates a JavaScript ES module exporting `install(asts): void` for an + * ordered array of compilation targets. + * + * **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 target 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 + * Struct and homogeneous Array loops are static functions. They share diagnostic and + * asynchronous continuation helpers with the interpreter. Other detailed + * traversals and transformation orchestration use the interpreter with + * registry-resolved children. Transformations and middleware are not replayed. + * Only the requested operation families and their static dependencies are + * emitted. Missing operations retain the lazy interpreter fallback in the + * shared registry. 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 `make` and `makeEffect` operations. + * Pure fixed Struct and homogeneous Array constructors can use `make` for a + * synchronous fast path; failures delegate to `makeEffect` for detailed issues. + * Tuple, Record, Union, leaf, and Class constructors use the existing + * interpreter. Constructor defaults and Class source schemas are read from the + * supplied ASTs, not serialized or executed during generation. `make` is + * omitted whenever replay could repeat observable construction work. + * + * **Gotchas** + * + * Regenerate the module whenever the schema definition or Effect version + * changes. Installation trusts that the runtime array has the same length and + * target 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, target `SchemaAST.toType(schema.ast)` with `make` to prepare + * construction when it differs from the encoded root. Target + * `SchemaAST.flip(schema.ast)` with `decode` to prepare encoding. Unsupported + * operations use the interpreter while statically installed children remain + * available. + * + * @category compilation + * @since 4.0.0 + */ +export const compile = (targets: ReadonlyArray): string => { + interface PlannedNode { + readonly index: number + readonly name: string + readonly reference: string + readonly requested: Set + readonly sources: Map + readonly attempted: Set + readonly compilable: boolean + } + + const seen = new Map() + const bindings: Array = [] + const factories: Array = [] + const factoryNames = new Map() + const installations: Array = [] + + const addSource = (node: SchemaAST.AST, plan: PlannedNode, operation: Codegen.DecoderOperation): boolean => { + if (plan.attempted.has(operation)) return plan.sources.has(operation) + plan.attempted.add(operation) + if (!plan.compilable) return false + const source = Codegen.generate(node, operation) + if (source === undefined) return false + plan.sources.set(operation, source) + return true + } + + const visitDependencies = (node: SchemaAST.AST, name: string, operation: PlannedOperation): void => { + switch (node._tag) { + case "Declaration": + node.typeParameters.forEach((child, index) => visit(child, `${name}.typeParameters[${index}]`, operation)) + break + case "TemplateLiteral": + node.parts.forEach((child, index) => visit(child, `${name}.parts[${index}]`, operation)) + break + case "Arrays": + node.elements.forEach((child, index) => visit(child, `${name}.elements[${index}]`, operation)) + node.rest.forEach((child, index) => visit(child, `${name}.rest[${index}]`, operation)) + break + case "Objects": + node.propertySignatures.forEach((property, index) => + visit(property.type, `${name}.propertySignatures[${index}].type`, operation) + ) + node.indexSignatures.forEach((signature, index) => { + visit( + SchemaAST.parameterFromPropertyKey(signature.parameter), + `${helper("parameterFromPropertyKey")}(${name}.indexSignatures[${index}].parameter)`, + operation + ) + visit(signature.type, `${name}.indexSignatures[${index}].type`, operation) + }) + break + case "Union": + node.types.forEach((child, index) => visit(child, `${name}.types[${index}]`, operation)) + break + } + node.encoding?.forEach((link, index) => visit(link.to, `${name}.encoding[${index}].to`, operation)) + if (operation === "make") { + const descriptor = SchemaAST.getConstructorDescriptor(node) + if (descriptor !== undefined) { + visit(descriptor.link.to, `${helper("getConstructorDescriptor")}(${name}).link.to`, operation) + } + } + } + + function visit(node: SchemaAST.AST, reference: string, operation: PlannedOperation): void { + let plan = seen.get(node) + if (plan === undefined) { + const index = seen.size + plan = { + index, + name: `a${index}`, + reference, + requested: new Set(), + sources: new Map(), + attempted: new Set(), + compilable: Codegen.shouldCompileParser(node) + } + seen.set(node, plan) + } + if (plan.requested.has(operation)) return + plan.requested.add(operation) + + switch (operation) { + case "decode": + addSource(node, plan, "decode") + visit(node, reference, "decodeEffect") + break + case "decodeEffect": + addSource(node, plan, "decodeEffect") + visitDependencies(node, plan.name, operation) + break + case "is": + if (!addSource(node, plan, "is")) visit(node, reference, "decode") + break + case "make": + addSource(node, plan, "make") + addSource(node, plan, "makeEffect") + visitDependencies(node, plan.name, operation) + break + } + } + + targets.forEach((target, index) => { + for (const operation of operationOrder) { + if (target.operations.includes(operation)) visit(target.ast, `asts[${index}]`, operation) + } + }) + for (const plan of seen.values()) { + bindings.push(`const ${plan.name}=${plan.reference};`) + const source = decoder(plan.sources) + if (source !== undefined) { + let factory = factoryNames.get(source) + if (factory === undefined) { + factory = `d${factoryNames.size}` + factoryNames.set(source, factory) + factories.push(`function ${factory}(ast,R,resolve){return ${source}}`) + } + installations.push(`${helper("set")}(${plan.name},${factory}(${plan.name},R,R.resolve));`) + } + } + 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/SchemaAOTCompiler/Build.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts new file mode 100644 index 00000000000..10e167bbed5 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts @@ -0,0 +1,288 @@ +/** + * Builds self-installing modules from ahead-of-time compiled Schema decoders. + * + * @since 4.0.0 + */ +import * as Data from "../../../Data.ts" +import * as Effect from "../../../Effect.ts" +import * as FileSystem from "../../../FileSystem.ts" +import * as Path from "../../../Path.ts" +import type * as PlatformError from "../../../PlatformError.ts" +import * as Schema from "../../../Schema.ts" +import * as SchemaAST from "../../../SchemaAST.ts" +import * as SchemaAOTCompiler from "../SchemaAOTCompiler.ts" + +/** + * An operation whose root AST should be prepared by {@link build}. + * + * @category models + * @since 4.0.0 + */ +export type Operation = "decode" | "encode" | "is" | "make" + +/** + * Loads the exports of a schema module during a build. + * + * @category models + * @since 4.0.0 + */ +export interface ModuleLoader { + (): PromiseLike +} + +/** + * Options for {@link build}. + * + * @category models + * @since 4.0.0 + */ +export interface BuildOptions { + /** + * Maps import specifiers relative to `baseUrl` to loaders for those same + * modules. Only directly exported Schema values are compiled. + */ + readonly modules: Readonly> + /** The URL against which relative module specifiers are resolved. */ + readonly baseUrl: string | URL + /** The file-system path of the generated module. */ + readonly outFile: string + /** The parser operations to prepare. Defaults to decoding. */ + readonly operations?: ReadonlyArray | undefined +} + +/** + * A summary of a completed {@link build}. + * + * @category models + * @since 4.0.0 + */ +export interface BuildResult { + /** The absolute path written by the build. */ + readonly outFile: string + /** The number of loaded modules containing at least one Schema export. */ + readonly modules: number + /** The number of directly exported Schema values found in those modules. */ + readonly schemas: number +} + +/** + * An error raised while loading modules or generating an AOT module. + * + * @category errors + * @since 4.0.0 + */ +export class BuildError extends Data.TaggedError("BuildError")<{ + readonly _tag: "BuildError" + readonly cause: unknown + readonly kind: "Generate" | "InvalidModule" | "LoadModule" | "ResolveModule" + readonly message: string + readonly module?: string | undefined +}> {} + +interface ExportedSchema { + readonly alias: string + readonly exportName: string + readonly schema: Schema.Top +} + +const operationOrder: ReadonlyArray = ["decode", "encode", "is", "make"] + +const importPath = ( + path: Path.Path, + outFile: string, + baseUrl: string | URL, + specifier: string +): Effect.Effect => + Effect.gen(function*() { + const url = yield* Effect.try({ + try: () => new URL(specifier, baseUrl), + catch: (cause) => + new BuildError({ + cause, + kind: "ResolveModule", + message: `Could not resolve schema module ${JSON.stringify(specifier)}`, + module: specifier + }) + }) + const sourcePath = yield* path.fromFileUrl(url).pipe( + Effect.mapError((cause) => + new BuildError({ + cause, + kind: "ResolveModule", + message: `Schema module ${JSON.stringify(specifier)} does not resolve to a file`, + module: specifier + }) + ) + ) + const relative = path.relative(path.dirname(outFile), sourcePath) + if (path.isAbsolute(relative)) { + return yield* new BuildError({ + cause: undefined, + kind: "ResolveModule", + message: `Schema module ${JSON.stringify(specifier)} cannot be imported relative to the output file`, + module: specifier + }) + } + const normalized = path.sep === "/" ? relative : relative.split(path.sep).join("/") + return normalized.startsWith(".") ? normalized : `./${normalized}` + }) + +const root = ( + exported: ExportedSchema, + operation: Operation +): readonly [ + ast: SchemaAST.AST, + operation: SchemaAOTCompiler.Operation, + expression: string, + derived: boolean +] => { + const expression = `${exported.alias}[${JSON.stringify(exported.exportName)}].ast` + switch (operation) { + case "decode": + return [exported.schema.ast, "decode", expression, false] + case "encode": { + const ast = SchemaAST.flip(exported.schema.ast) + return ast === exported.schema.ast + ? [ast, "decode", expression, false] + : [ast, "decode", `A.flip(${expression})`, true] + } + case "is": + case "make": { + const ast = SchemaAST.toType(exported.schema.ast) + return ast === exported.schema.ast + ? [ast, operation, expression, false] + : [ast, operation, `A.toType(${expression})`, true] + } + } +} + +/** + * Writes a self-installing AOT module for Schema values exported by a set of + * modules. + * + * **When to use** + * + * Use when a build script should let an application install generated decoders + * by importing one generated module at startup. + * + * **Details** + * + * Module keys are import specifiers relative to `baseUrl`. Their loaders run + * sequentially during the build. The builder sorts module specifiers and export + * names, compiles direct Schema exports, and writes deterministic source using + * `FileSystem`. The generated module imports those exports and installs their + * decoders in the shared Schema parser registry as a module side effect. A lazy + * record returned by `import.meta.glob` can be passed as `modules` directly. + * + * Decoding is prepared by default. `is` and `make` prepare the type-side AST; + * `encode` prepares the flipped AST. Nested dependencies are discovered by the + * AOT compiler and do not need separate exports. + * + * **Gotchas** + * + * Loaders execute application modules during the build. Each loader must return + * the same module named by its key. The generated file must be rebuilt after a + * schema definition or Effect version changes. Unsupported operations retain + * the interpreter fallback. Build configurations that mark modules as + * side-effect free must retain the generated import. + * + * @category compilation + * @since 4.0.0 + */ +export const build: ( + options: BuildOptions +) => Effect.Effect< + BuildResult, + BuildError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> = Effect.fnUntraced(function*(options) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const outFile = path.resolve(options.outFile) + const requested = new Set(options.operations ?? ["decode"]) + const operations = operationOrder.filter((operation) => requested.has(operation)) + const imports: Array = [] + const exportedSchemas: Array = [] + + for (const specifier of Object.keys(options.modules).sort()) { + const loader = options.modules[specifier]! + const loaded = yield* Effect.tryPromise({ + try: () => loader(), + catch: (cause) => + new BuildError({ + cause, + kind: "LoadModule", + message: `Could not load schema module ${JSON.stringify(specifier)}`, + module: specifier + }) + }) + if (typeof loaded !== "object" || loaded === null) { + return yield* new BuildError({ + cause: loaded, + kind: "InvalidModule", + message: `Schema module ${JSON.stringify(specifier)} did not load a module namespace`, + module: specifier + }) + } + const namespace = loaded as Readonly> + const exports = Object.keys(namespace).filter((name) => Schema.isSchema(namespace[name])).sort() + if (exports.length === 0) continue + const alias = `m${imports.length}` + const specifierFromOutput = yield* importPath(path, outFile, options.baseUrl, specifier) + imports.push(`import * as ${alias} from ${JSON.stringify(specifierFromOutput)};`) + for (const exportName of exports) { + exportedSchemas.push({ + alias, + exportName, + schema: namespace[exportName] as Schema.Top + }) + } + } + + const source = yield* Effect.try({ + try: () => { + const targets: Array<{ + readonly ast: SchemaAST.AST + readonly operations: Array + readonly expression: string + }> = [] + const seen = new Map() + let needsSchemaASTImport = false + for (const exported of exportedSchemas) { + for (const operation of operations) { + const [ast, targetOperation, expression, isDerived] = root(exported, operation) + const index = seen.get(ast) + if (index !== undefined) { + const targetOperations = targets[index].operations + if (!targetOperations.includes(targetOperation)) targetOperations.push(targetOperation) + continue + } + seen.set(ast, targets.length) + targets.push({ ast, operations: [targetOperation], expression }) + needsSchemaASTImport ||= isDerived + } + } + return [ + ...imports, + ...(needsSchemaASTImport ? ["import * as A from \"effect/SchemaAST\";"] : []), + SchemaAOTCompiler.compile(targets), + `install([${targets.map((target) => target.expression).join(",")}]);`, + "" + ].join("\n") + }, + catch: (cause) => + new BuildError({ + cause, + kind: "Generate", + message: "Could not generate the Schema AOT module" + }) + }) + + yield* fs.makeDirectory(path.dirname(outFile), { recursive: true }) + yield* fs.writeFileString(outFile, source) + return { + outFile, + modules: imports.length, + schemas: exportedSchemas.length + } +}) diff --git a/packages/effect/src/unstable/schema/SchemaCompiler.ts b/packages/effect/src/unstable/schema/SchemaCompiler.ts new file mode 100644 index 00000000000..b7f91131a46 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaCompiler.ts @@ -0,0 +1,208 @@ +/** + * 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 optional synchronous 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 Decode} or {@link Make} when the fast path 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 ordinary + * decoding, including `decode` and its diagnostic fallback when 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 decoder 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 + * and can also occur as a valid input value. Type guards without an `is` operation + * use this same fallback. 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} + * for invalid input, never for an unsupported optimization. The detailed decoder + * must also accept valid data that happens to equal this marker. 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 Decode { + (input: unknown, options: SchemaAST.ParseOptions): unknown | typeof invalid +} + +/** + * A compiled constructor that returns the constructed value without detailed + * diagnostic issues. + * + * **Details** + * + * This optional synchronous fast path lets construction return directly when + * it succeeds. Return {@link invalid} to let `makeEffect` produce the normal + * detailed result. Implementations must be deterministic and free of side + * effects because a failed construction can be repeated by `makeEffect`. + * + * Omit this operation when construction can execute defaults, Class + * constructors, transformations, middleware, or other effects that cannot be + * replayed safely. The operation must honor the supplied parse options and + * propagate {@link missing} when no value is produced. + * + * @category models + * @since 4.0.0 + */ +export interface Make { + (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 `decode` 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 DecodeEffect { + (input: unknown, options: SchemaAST.ParseOptions): Effect.Effect +} + +/** + * A complete constructor that returns detailed Schema issues on failure. + * + * @category models + * @since 4.0.0 + */ +export interface MakeEffect { + (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. `decode`, + * `is`, and `make` 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 `decode` when present, returning its output on success or + * calling `decodeEffect` after `invalid`. Without `decode`, or for the {@link missing} + * sentinel, it calls `decodeEffect` directly. Type guards prefer `is`; otherwise + * they use ordinary decoding with the same fast-path/diagnostic fallback. + * A boolean `false` from `is` needs no diagnostic replay. + * Synchronous decoding and encoding share an adapter that returns successful + * `decode` 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. + * Synchronous construction tries `make` when present, returning its output on + * success or calling `makeEffect` after {@link invalid}. Compilers must omit + * `make` when replay could repeat observable construction work. Construction + * never uses `is` or `decode`. 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 decode?: Decode | undefined + readonly make?: Make | undefined + readonly decodeEffect: DecodeEffect + /** + * 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?: MakeEffect | 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 detailed construction for new consumers, without merging + * the old operation into the new entry. An installed `make` can still handle + * synchronous successes. 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..da7d78a945f --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -0,0 +1,231 @@ +/** + * Runtime helpers used by generated schema modules. This module contains no + * source generator or dynamic function construction. Generated modules must + * use the same Effect version as their generator. + * + * @since 4.0.0 + */ +import * as Effect from "../../../Effect.ts" +import { effectIsExit, resolveConcurrency } from "../../../internal/effect.ts" +import { lazyParser, type Resolve, resolve, set, withDecode } from "../../../internal/schema/compilerRegistry.ts" +import * as Interpreter from "../../../internal/schema/interpreter.ts" +import * as InternalParser from "../../../internal/schema/parser.ts" +import * as SchemaAST from "../../../SchemaAST.ts" +import * as SchemaIssue from "../../../SchemaIssue.ts" +import type { Compiler } from "../../../SchemaParser.ts" +import { type Decode, invalid } from "../SchemaCompiler.ts" + +type SchemaIssueParser = ReturnType +type ObjectParserState = Parameters[0] +type ParsedProperty = Parameters[1] +type ArrayParserState = Parameters[0] +type GenerateObject = (context: { + readonly ast: SchemaAST.Objects + readonly getProperties: () => ReadonlyArray + readonly fallback: SchemaIssueParser + readonly resume: ( + state: ObjectParserState, + index: number, + pending: Effect.Effect + ) => Effect.Effect + readonly step: typeof SchemaAST.stepProperty +}) => SchemaIssueParser +type GenerateArray = (context: { + readonly getElement: () => SchemaIssueParser + readonly step: typeof SchemaAST.stepArray + readonly resume: ( + state: ArrayParserState, + item: unknown, + index: number, + pending: Effect.Effect, + end: number + ) => Effect.Effect +}) => typeof SchemaAST.parseArray +const makeObjectBase = ( + ast: SchemaAST.Objects, + compile: Compiler, + compileField: Compiler, + generate: GenerateObject +): SchemaIssueParser => { + let properties: Array | undefined + const getProperties = (): Array => + properties ??= ast.propertySignatures.map((property) => ({ + parser: compileField(property.type), + name: property.name, + type: property.type + })) + let fallback: SchemaIssueParser | undefined + const runFallback: SchemaIssueParser = (input, options) => + (fallback ??= ast.getParser(compile, compileField))(input, options) + const resume = ( + state: ObjectParserState, + index: number, + pending: Effect.Effect + ): Effect.Effect => { + const property = properties![index] + return Effect.flatMap(Effect.exit(pending), (exit) => { + const terminal = SchemaAST.stepProperty(state, property, exit) + if (terminal) return terminal + const done = () => InternalParser.succeed(state.out) + const effect = SchemaAST.parseProperties(state, properties!.slice(index + 1)) + return effect ? Effect.flatMapEager(effect, done) : done() + }) + } + return generate({ ast, getProperties, fallback: runFallback, resume, step: SchemaAST.stepProperty }) +} + +const makeArrayBase = ( + ast: SchemaAST.Arrays, + compile: Compiler, + compileField: Compiler, + generate: GenerateArray +): SchemaIssueParser => { + let element: { readonly ast: SchemaAST.AST; readonly parser: SchemaIssueParser } | undefined + const getElement = () => (element ??= { + ast: ast.rest[0], + parser: compileField(ast.rest[0]) + }) + let fallback: SchemaIssueParser | undefined + const runFallback: SchemaIssueParser = (input, options) => + (fallback ??= ast.getParser(compile, compileField))(input, options) + const run = generate({ + getElement: () => getElement().parser, + step: SchemaAST.stepArray, + resume: (state, item, index, pending, end) => + Effect.flatMap( + Effect.exit(pending), + (exit) => + SchemaAST.stepArray(state, item, exit, index) ?? + SchemaAST.parseArray(state, state.input, index + 1, end) ?? Effect.void + ) + }) + const specialized = Effect.fnUntracedEager(function*(input: unknown, options: SchemaAST.ParseOptions) { + if (input === InternalParser.missing) return InternalParser.missing + if (!Array.isArray(input)) { + return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + const descriptor = getElement() + const len = input.length + const state: ArrayParserState = { + ast, + getParser: () => descriptor, + input, + len, + tailThreshold: len, + output: new globalThis.Array(len), + issues: undefined, + options + } + const effect = run(state, input, 0, len) + if (effect) yield* effect + if (state.issues) { + return yield* Effect.fail(new SchemaIssue.Composite(ast, state.issues, input, options)) + } + return state.output + }) + return (input, options) => + options.concurrency !== undefined && resolveConcurrency(options.concurrency) !== 1 + ? runFallback(input, options) + : specialized(input, options) +} + +const decode = ( + ast: SchemaAST.AST, + resolve: Resolve, + generate?: GenerateObject, + detailed = false, + makeDecode?: () => Decode, + generateArray?: GenerateArray +): SchemaIssueParser => { + const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, detailed ? "decodeEffect" : "parser") + const localChild = makeDecode === undefined + ? child + : (ast: SchemaAST.AST) => lazyParser(resolve, ast, "decodeEffect") + const base = ast._tag === "Objects" && generate !== undefined ? + makeObjectBase(ast, localChild, localChild, generate) + : ast._tag === "Arrays" && generateArray !== undefined + ? makeArrayBase(ast, localChild, localChild, generateArray) + : makeDecode !== undefined + ? ast.getParser(localChild) + : undefined + const specialize = makeDecode === undefined ? undefined : (local: SchemaIssueParser): SchemaIssueParser => { + try { + return withDecode(makeDecode(), () => local) + } catch { + // Initialization failure selects the local interpreter, without parsing again. + return local + } + } + return Interpreter.compile(ast, child, undefined, base, specialize) +} + +const make = ( + ast: SchemaAST.AST, + resolve: Resolve, + generate?: GenerateObject, + generateArray?: GenerateArray +): SchemaIssueParser => { + const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeEffect") + const field = (ast: SchemaAST.AST) => Interpreter.compileField(ast, child) + const base = generate !== undefined && ast._tag === "Objects" ? + makeObjectBase(ast, child, field, generate) + : generateArray !== undefined && ast._tag === "Arrays" + ? makeArrayBase(ast, child, field, generateArray) + : undefined + return Interpreter.compile(ast, child, field, base) +} + +const failsChecks = ( + ast: SchemaAST.AST, + value: unknown, + encoded: boolean, + options: SchemaAST.ParseOptions +): boolean => { + const checks = encoded ? "encodingChecks" in ast ? ast.encodingChecks : undefined : ast.checks + return !options.disableChecks && checks !== undefined && + SchemaAST.collectIssues(checks, value, undefined, ast, options) !== undefined +} + +const hasExcessProperties = ( + ast: SchemaAST.Objects, + input: Record, + options: SchemaAST.ParseOptions +): boolean => { + const covered = new Set( + ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name) + ) + for (const index of ast.indexSignatures) { + for (const key of SchemaAST.getIndexSignatureKeys(input, index.parameter, options)) covered.add(key) + } + return Reflect.ownKeys(input).some((key) => !covered.has(key)) +} + +/** @internal */ +export const runtime = { + decode, + make, + resolve, + set, + invalid, + missing: InternalParser.missing, + missingExit: InternalParser.missingExit, + sameExit: InternalParser.sameExit, + args: InternalParser.args, + succeed: InternalParser.succeed, + effectIsExit, + die: Effect.die, + invalidType: (ast: SchemaAST.AST, input: unknown, options: SchemaAST.ParseOptions) => + Effect.fail(new SchemaIssue.InvalidType(ast, input, options)), + wrapEncoding: Interpreter.wrapEncoding, + failsChecks, + getExpectedKeys: (ast: SchemaAST.Objects) => + ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name), + hasExcessProperties, + matchesTemplateLiteral: (ast: SchemaAST.TemplateLiteral, input: unknown, options: SchemaAST.ParseOptions) => + typeof input === "string" && ast.matchPart(input, options) !== undefined, + getCandidates: SchemaAST.getCandidates, + getIndexSignatureKeys: SchemaAST.getIndexSignatureKeys, + parameterFromPropertyKey: SchemaAST.parameterFromPropertyKey, + getConstructorDescriptor: SchemaAST.getConstructorDescriptor, + defaultParseOptions: SchemaAST.defaultParseOptions +} diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts new file mode 100644 index 00000000000..dd681df7327 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -0,0 +1,89 @@ +/** + * Installs runtime-generated schema decoders without changing SchemaParser's + * public interface. Operations are compiled lazily. Import the separate + * `SchemaJITCompiler/enable` module to enable compilation globally. + * + * @since 4.0.0 + */ +import { type DecoderOperation, generate, shouldCompileParser } from "../../internal/schema/codegen.ts" +import * as Registry from "../../internal/schema/compilerRegistry.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type { CompiledDecoder } from "./SchemaCompiler.ts" +import { runtime } from "./SchemaCompiler/runtime.ts" + +let checked: FunctionConstructor | undefined +let supported = false + +/** @internal */ +export const compiler: Registry.Compile = (ast, resolve) => { + if (!shouldCompileParser(ast)) return undefined + if (checked !== globalThis.Function) { + checked = globalThis.Function + try { + checked("return true") + supported = true + } catch { + supported = false + } + } + if (!supported) return undefined + let decodeFailed = false + const operation = (key: DecoderOperation) => { + const isConstruction = key === "make" || key === "makeEffect" + if (isConstruction || !decodeFailed) { + try { + const source = generate(ast, key) + if (source === undefined) return undefined + return globalThis.Function("ast", "R", "resolve", source)(ast, runtime, resolve) + } catch { + // Only code generation and initialization are inside this catch. + if (!isConstruction) decodeFailed = true + } + } + return key === "decodeEffect" + ? runtime.decode(ast, resolve) + : key === "makeEffect" + ? runtime.make(ast, resolve) + : undefined + } + return { + get is() { + return operation("is") + }, + get decode() { + return operation("decode") + }, + get make() { + return operation("make") + }, + get decodeEffect() { + return operation("decodeEffect") + }, + get makeEffect() { + return operation("makeEffect") + } + } satisfies CompiledDecoder +} + +/** + * Enables lazy JIT compilation for an AST and its parsing dependencies. + * + * **Details** + * + * Uses the same registry as `SchemaCompiler.set`. Compilation failures, including + * environments that block `new Function`, retain interpreted parsing. Errors + * thrown while parsing are not retried. No checks, transformations or defaults + * execute during installation. + * + * **Gotchas** + * + * Enable before first use to optimize every consumer. Functions that already + * captured an entry keep it. Enable type-side and flipped ASTs separately when + * they differ from the supplied AST. + * + * @category compilation + * @since 4.0.0 + */ +export function enable(ast: SchemaAST.AST): void { + Registry.enable(ast, compiler) +} 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..6ab43497fb1 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts @@ -0,0 +1,12 @@ +/** + * Enables lazy JIT compilation globally through a side-effect import. + * Import before the first use of schemas, including construction. Previously + * captured parsers remain usable but are not replaced. If dynamic function + * construction is blocked or compilation fails, parsing uses the interpreter. + * + * @since 4.0.0 + */ +import { install } from "../../../internal/schema/compilerRegistry.ts" +import { compiler } from "../SchemaJITCompiler.ts" + +install(compiler) 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 acbfcbabdf7..ae3db53703e 100644 --- a/packages/effect/test/Formatter.test.ts +++ b/packages/effect/test/Formatter.test.ts @@ -598,13 +598,14 @@ describe("Formatter", () => { it.effect("distinguishes present undefined from absent input in forbidden", () => Effect.gen(function*() { const getter = SchemaGetter.forbidden(() => "not allowed") - const present = yield* getter.run(Option.some(undefined), { reportInput: true }).pipe(Effect.flip) + assertTrue(getter._tag === "TransformOptionalEffect") + const present = yield* getter.transform(Option.some(undefined), { reportInput: true }).pipe(Effect.flip) assertTrue(present._tag === "Forbidden") assertTrue(SchemaIssue.hasInput(present)) strictEqual(present.input, undefined) strictEqual(formatIssue(present), "not allowed") - const absent = yield* getter.run(Option.none(), { reportInput: true }).pipe(Effect.flip) + const absent = yield* getter.transform(Option.none(), { reportInput: true }).pipe(Effect.flip) assertTrue(absent._tag === "Forbidden") assertFalse(SchemaIssue.hasInput(absent)) })) diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 0dce68d6afd..8a67678b429 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2406,7 +2406,8 @@ Expected a value between -2147483648 and 2147483647` it("double transformation", async () => { const schema = Schema.String.pipe( Schema.decode( - SchemaTransformation.trim().compose( + SchemaTransformation.compose( + SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ) ) @@ -2554,7 +2555,8 @@ Expected a value between -2147483648 and 2147483647` it("double transformation", async () => { const schema = Schema.String.pipe( Schema.encode( - SchemaTransformation.trim().compose( + SchemaTransformation.compose( + SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ).flip() ) @@ -8296,12 +8298,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) const r1 = await Schema.decodeUnknownPromise(decodeSchema)("a").then(Result.succeed, Result.fail) @@ -8339,12 +8341,12 @@ Expected a value between -2147483648 and 2147483647` it("should throw an error when the cause is not a schema issue", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.die(new Error("decode defect"))), + decode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("decode defect"))), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.die(new Error("encode defect"))) + encode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("encode defect"))) })) throws(() => Schema.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -8365,12 +8367,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) throws(() => Schema.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -8427,12 +8429,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) throws(() => Schema.decodeUnknownResult(decodeSchema)("a"), (e) => { @@ -8480,12 +8482,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) throws(() => Schema.decodeUnknownSync(decodeSchema)("a"), (e) => { @@ -8504,7 +8506,7 @@ Expected a value between -2147483648 and 2147483647` describe("decodeUnknownResult", () => { it("should throw on async decoding", () => { const AsyncString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((os: Option.Option) => + decode: SchemaGetter.transformOptionalEffect((os: Option.Option) => Effect.gen(function*() { yield* Effect.sleep("10 millis") return os @@ -8520,10 +8522,10 @@ Expected a value between -2147483648 and 2147483647` it("should throw on missing dependency", () => { class MagicNumber extends Context.Service()("MagicNumber") {} const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() @@ -8537,7 +8539,7 @@ Expected a value between -2147483648 and 2147483647` describe("decodeUnknownExit", () => { it("should die on async decoding", () => { const AsyncString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((os: Option.Option) => + decode: SchemaGetter.transformOptionalEffect((os: Option.Option) => Effect.gen(function*() { yield* Effect.sleep("10 millis") return os @@ -8554,10 +8556,10 @@ Expected a value between -2147483648 and 2147483647` it("should die on missing dependency", () => { class MagicNumber extends Context.Service()("MagicNumber") {} const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() @@ -8575,12 +8577,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) const decodeExit = Schema.decodeUnknownExit(decodeSchema)("a") diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts new file mode 100644 index 00000000000..49d4a00568a --- /dev/null +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -0,0 +1,141 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaParser } 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, pathToFileURL } 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 targets = [{ ast: schema.ast, operations: ["decode"] }] as const + const source = SchemaAOTCompiler.compile(targets) + assert.strictEqual(SchemaAOTCompiler.compile(targets), 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("emits only the requested operation family", () => { + const schema = Schema.Struct({ value: Schema.String }) + const decode = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) + assert.include(decode, "get decode(){") + assert.include(decode, "get decodeEffect(){") + assert.notInclude(decode, "get is(){") + assert.notInclude(decode, "get make(){") + assert.notInclude(decode, "get makeEffect(){") + + const make = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["make"] }]) + assert.include(make, "get make(){") + assert.include(make, "get makeEffect(){") + assert.notInclude(make, "get is(){") + assert.notInclude(make, "get decode(){") + }) + + it("omits fast decode operations from diagnostic-only dependencies", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.Array(child) + const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) + assert.strictEqual(source.match(/get decode\(\)\{/g)?.length, 1) + assert.strictEqual(source.match(/get decodeEffect\(\)\{/g)?.length, 2) + + const targeted = SchemaAOTCompiler.compile([ + { ast: schema.ast, operations: ["decode"] }, + { ast: child.ast, operations: ["decode"] } + ]) + assert.strictEqual(targeted.match(/get decode\(\)\{/g)?.length, 2) + assert.strictEqual(targeted.match(/get decodeEffect\(\)\{/g)?.length, 2) + }) + + it("uses registry fallbacks for operations that were not requested", async () => { + const schema = Schema.Struct({ value: Schema.String }) + const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-operations-test-", import.meta.url))) + try { + const file = join(directory, "decode.mjs") + writeFileSync(file, SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }])) + const generated = await import(`${pathToFileURL(file).href}?test=${Date.now()}`) + generated.install([schema.ast]) + + const source = CompilerRegistry.resolve(schema.ast).source + assert.isDefined(source?.decode) + assert.isUndefined(source?.is) + assert.isUndefined(source?.make) + assert.isUndefined(source?.makeEffect) + assert.strictEqual(SchemaParser.is(schema)({ value: "a" }), true) + assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + 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([ + { ast: first.ast, operations: ["decode"] }, + { ast: second.ast, operations: ["decode"] } + ]) + assert.strictEqual( + SchemaAOTCompiler.compile([ + { ast: first.ast, operations: ["decode"] }, + { ast: second.ast, operations: ["decode"] }, + { ast: first.ast, operations: ["decode"] }, + { ast: second.ast, operations: ["decode"] } + ]), + source + ) + assert.strictEqual(source.match(/R\.set\(/g)?.length, 3) + }) + + it("reuses identical decoder factories", () => { + const schema = Schema.Union( + Array.from({ length: 8 }, (_, tag) => Schema.Struct({ tag: Schema.Literal(tag), value: Schema.Number })) + ) + const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) + assert.strictEqual(source.match(/function d\d+\(ast,R,resolve\)/g)?.length, 2) + }) + + 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([{ ast: schema.ast, operations: ["decode", "is", "make"] }]) + ) + } + writeFileSync( + join(directory, "all.mjs"), + SchemaAOTCompiler.compile(roots.map((ast) => ({ ast, operations: ["decode", "is", "make"] }))) + ) + 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/SchemaAOTCompilerBuild.test.ts b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts new file mode 100644 index 00000000000..fb1d71363d6 --- /dev/null +++ b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts @@ -0,0 +1,148 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, FileSystem, Path, SchemaParser } from "effect" +import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" +import * as SchemaAOTCompilerBuild from "effect/unstable/schema/SchemaAOTCompiler/Build" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import * as Schemas from "./fixtures/aot-build.ts" + +describe("SchemaAOTCompilerBuild", { concurrent: false }, () => { + it.effect("writes a deterministic self-installing module", () => + Effect.gen(function*() { + const directory = yield* Effect.acquireRelease( + Effect.sync(() => mkdtempSync(fileURLToPath(new URL("../../.schema-aot-build-test-", import.meta.url)))), + (directory) => Effect.sync(() => rmSync(directory, { recursive: true, force: true })) + ) + const outFile = join(directory, "generated.mjs") + const fileSystem = FileSystem.layerNoop({ + makeDirectory: (path, options) => + Effect.sync(() => { + mkdirSync(path, options) + }), + writeFileString: (path, source) => Effect.sync(() => writeFileSync(path, source)) + }) + const options = { + modules: { + "./fixtures/aot-build.ts": () => Promise.resolve(Schemas) + }, + baseUrl: import.meta.url, + outFile, + operations: ["make", "decode", "is", "encode", "decode"] + } as const + + const first = yield* SchemaAOTCompilerBuild.build(options).pipe( + Effect.provide(fileSystem), + Effect.provide(Path.layer) + ) + const source = yield* Effect.sync(() => readFileSync(outFile, "utf8")) + const second = yield* SchemaAOTCompilerBuild.build(options).pipe( + Effect.provide(fileSystem), + Effect.provide(Path.layer) + ) + const secondSource = yield* Effect.sync(() => readFileSync(outFile, "utf8")) + + assert.deepStrictEqual(first, { + outFile, + modules: 1, + schemas: 2 + }) + assert.deepStrictEqual(second, first) + assert.strictEqual(secondSource, source) + assert.include(source, "effect/SchemaAST") + assert.include(source, "A.flip(m0[\"Port\"].ast)") + assert.include(source, "A.toType(m0[\"Port\"].ast)") + assert.notInclude(source, "ignored") + + const before = CompilerRegistry.resolve(Schemas.User.ast) + yield* Effect.promise(() => import(`${pathToFileURL(outFile).href}?test=${Date.now()}`)) + assert.notStrictEqual(CompilerRegistry.resolve(Schemas.User.ast), before) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(Schemas.User)({ name: "Ada", port: "8080" }), { + name: "Ada", + port: 8080 + }) + assert.strictEqual(SchemaParser.is(Schemas.User)({ name: "Ada", port: 8080 }), true) + assert.deepStrictEqual(SchemaParser.make(Schemas.User)({ name: "Ada", port: 8080 }), { + name: "Ada", + port: 8080 + }) + assert.deepStrictEqual(SchemaParser.encodeUnknownSync(Schemas.User)({ name: "Ada", port: 8080 }), { + name: "Ada", + port: "8080" + }) + })) + + it.effect("uses decoding as the default operation and ignores non-Schema exports", () => + Effect.gen(function*() { + let written = "" + const result = yield* SchemaAOTCompilerBuild.build({ + modules: { + "./fixtures/aot-build.ts": () => Promise.resolve(Schemas), + "./fixtures/ignored.ts": () => Promise.resolve({ value: 1 }) + }, + baseUrl: import.meta.url, + outFile: "/generated/schema-aot.mjs" + }).pipe( + Effect.provide(FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + writeFileString: (_path, source) => + Effect.sync(() => { + written = source + }) + })), + Effect.provide(Path.layer) + ) + + assert.deepStrictEqual(result, { + outFile: "/generated/schema-aot.mjs", + modules: 1, + schemas: 2 + }) + assert.notInclude(written, "import * as A from \"effect/SchemaAST\"") + assert.notInclude(written, "ignored.ts") + assert.notInclude(written, "get is(){") + assert.notInclude(written, "get make(){") + assert.notInclude(written, "get makeEffect(){") + assert.include(written, "install([m0[\"Port\"].ast,m0[\"User\"].ast]);") + })) + + it.effect("reports module loading failures", () => + Effect.gen(function*() { + const error = yield* SchemaAOTCompilerBuild.build({ + modules: { + "./fixtures/failing.ts": () => Promise.reject("boom") + }, + baseUrl: import.meta.url, + outFile: "/generated/schema-aot.mjs" + }).pipe( + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer), + Effect.flip + ) + + assert.instanceOf(error, SchemaAOTCompilerBuild.BuildError) + assert.strictEqual(error.kind, "LoadModule") + assert.strictEqual(error.module, "./fixtures/failing.ts") + assert.strictEqual(error.cause, "boom") + })) + + it.effect("reports invalid loaded modules", () => + Effect.gen(function*() { + const error = yield* SchemaAOTCompilerBuild.build({ + modules: { + "./fixtures/invalid.ts": () => Promise.resolve(1) + }, + baseUrl: import.meta.url, + outFile: "/generated/schema-aot.mjs" + }).pipe( + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer), + Effect.flip + ) + + assert.instanceOf(error, SchemaAOTCompilerBuild.BuildError) + assert.strictEqual(error.kind, "InvalidModule") + assert.strictEqual(error.module, "./fixtures/invalid.ts") + assert.strictEqual(error.cause, 1) + })) +}) diff --git a/packages/effect/test/schema/SchemaAST.test.ts b/packages/effect/test/schema/SchemaAST.test.ts index 714aafb574d..d3d32f0ac9d 100644 --- a/packages/effect/test/schema/SchemaAST.test.ts +++ b/packages/effect/test/schema/SchemaAST.test.ts @@ -1,9 +1,15 @@ -import { Schema, SchemaAST, SchemaGetter, SchemaTransformation } from "effect" +import { Effect, Schema, SchemaAST, SchemaGetter, SchemaTransformation } from "effect" import { runInNewContext } from "node:vm" import { describe, it } from "vitest" import { deepStrictEqual, doesNotThrow, strictEqual, throws } from "../utils/assert.ts" describe("SchemaAST", () => { + it("stores constructor defaults directly in the context", () => { + const defaultValue = Effect.succeed("default") + const ast = SchemaAST.withConstructorDefault(SchemaAST.string, defaultValue) + strictEqual(ast.context?.constructorDefault, defaultValue) + }) + describe("Suspend", () => { it("memoizes the thunk", () => { let calls = 0 diff --git a/packages/effect/test/schema/SchemaCompilerApi.test.ts b/packages/effect/test/schema/SchemaCompilerApi.test.ts new file mode 100644 index 00000000000..66bc4a6c981 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerApi.test.ts @@ -0,0 +1,342 @@ +import { assert, describe, it, vi } 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("resolves a type guard on first invocation", () => { + const schema = Schema.String.annotate({ title: "lazy type guard" }) + const ast = SchemaAST.toType(schema.ast) + const guard = SchemaParser.is(schema) + let calls = 0 + + SchemaCompiler.set(ast, { + is: () => { + calls++ + return true + }, + decodeEffect: Effect.succeed + }) + + strictEqual(guard(1), true) + strictEqual(guard(2), true) + strictEqual(calls, 2) + + SchemaCompiler.set(ast, { + is: () => false, + decodeEffect: Effect.succeed + }) + + strictEqual(guard(3), true) + strictEqual(SchemaParser.is(schema)(3), false) + strictEqual(calls, 3) + }) + + it("reuses interpreted candidates inside a selectively compiled Union", () => { + const child = Schema.String.annotate({ title: "interpreted Union candidate" }) + const schema = Schema.Union([child, Schema.Number]) + const initialize = vi.spyOn(child.ast, "getParser") + try { + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + strictEqual(make("first"), "first") + strictEqual(make("second"), "second") + strictEqual(initialize.mock.calls.length, 1) + } finally { + initialize.mockRestore() + } + }) + + 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, + decode: (_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" + }, + decode: (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, { + decode: () => ({ 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 decode() { + 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, { + decode: (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, { + decode: (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), { + decode: () => "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) + 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 decode() { + 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, { + decode: () => ({ 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/SchemaCompilerArray.test.ts b/packages/effect/test/schema/SchemaCompilerArray.test.ts new file mode 100644 index 00000000000..637f3119452 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerArray.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, it } from "@effect/vitest" +import { Deferred, Effect, Fiber, Schema, SchemaGetter, SchemaParser } from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" + +describe("compiled homogeneous Array traversal", () => { + it.effect("keeps detailed errors and sparse input behavior", () => + Effect.gen(function*() { + const schema = Schema.Array(Schema.NumberFromString) + const inputs = [["1", "2"], ["bad", "2"], ["1", "bad", "bad"], new Array(2)] + const snapshot = Effect.fnUntraced(function*() { + const parse = SchemaParser.decodeUnknownEffect(schema) + const results = [] + for (const errors of ["first", "all"] as const) { + for (const input of inputs) { + results.push(yield* Effect.result(parse(input, { errors }))) + } + } + return results + }) + const interpreted = yield* snapshot() + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(yield* snapshot(), interpreted) + })) + + it.effect("resumes without replaying the pending element or rereading its accessor", () => + Effect.gen(function*() { + const events: Array = [] + const element = Schema.String.pipe(Schema.decode({ + decode: SchemaGetter.transformEffect((value) => + Effect.gen(function*() { + events.push(value) + yield* Effect.yieldNow + return value.toUpperCase() + }) + ), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Array(element) + let reads = 0 + const input = ["a", "b"] + Object.defineProperty(input, "0", { + get() { + reads++ + return "a" + } + }) + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(yield* SchemaParser.decodeUnknownEffect(schema)(input), ["A", "B"]) + assert.deepStrictEqual(events, ["a", "b"]) + assert.strictEqual(reads, 1) + })) + + it("passes missing constructor results to the shared element step", () => { + for (const optional of [false, true]) { + const required = Schema.String.annotate({ title: "Array constructor element" }) + const element = optional ? Schema.optionalKey(required) : required + SchemaCompiler.set(element.ast, { + decodeEffect: Effect.succeed, + makeEffect: () => Effect.succeed(SchemaCompiler.missing) + }) + const schema = Schema.Array(element) + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + if (optional) { + const output = make(["a"]) + assert.strictEqual(output.length, 1) + assert.strictEqual(0 in output, false) + } else { + assert.throws(() => make(["a"]), /Schema validation failed/) + } + } + }) + + it.effect("keeps bounded concurrency on the interpreter's concurrent traversal", () => + Effect.gen(function*() { + const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const calls = [0, 0, 0] + const element = Schema.Number.pipe(Schema.decode({ + decode: SchemaGetter.transformEffect((index) => + Effect.gen(function*() { + calls[index]++ + yield* Deferred.succeed(started[index], undefined) + yield* Deferred.await(releases[index]) + return index + }) + ), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Array(element) + SchemaJITCompiler.enable(schema.ast) + const fiber = yield* SchemaParser.decodeUnknownEffect(schema)([0, 1, 2], { concurrency: 2 }).pipe( + Effect.forkChild + ) + yield* Deferred.await(started[0]) + yield* Deferred.await(started[1]) + assert.strictEqual(yield* Deferred.isDone(started[2]), false) + yield* Deferred.succeed(releases[0], undefined) + yield* Deferred.await(started[2]) + yield* Deferred.succeed(releases[1], undefined) + yield* Deferred.succeed(releases[2], undefined) + assert.deepStrictEqual(yield* Fiber.join(fiber), [0, 1, 2]) + assert.deepStrictEqual(calls, [1, 1, 1]) + })) +}) diff --git a/packages/effect/test/schema/SchemaCompilerConcurrency.test.ts b/packages/effect/test/schema/SchemaCompilerConcurrency.test.ts new file mode 100644 index 00000000000..a4f584c3734 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerConcurrency.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest" +import { Deferred, Effect, Fiber, Schema, SchemaParser } from "effect" +import { SchemaJITCompiler } from "effect/unstable/schema" + +describe("compiled construction concurrency", () => { + for (const product of ["Struct", "Tuple"] as const) { + it.effect(`${product} preserves bounded concurrency and runs defaults once`, () => + Effect.gen(function*() { + const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const calls = [0, 0, 0] + const fields = [0, 1, 2].map((index) => + Schema.Number.pipe(Schema.withConstructorDefault(Effect.gen(function*() { + calls[index]++ + yield* Deferred.succeed(started[index], undefined) + yield* Deferred.await(releases[index]) + return index + }))) + ) + const schema: Schema.Codec = product === "Struct" + ? Schema.Struct({ a: fields[0], b: fields[1], c: fields[2] }) + : Schema.Tuple(fields) + SchemaJITCompiler.enable(schema.ast) + const fiber = yield* SchemaParser.makeEffect(schema)(product === "Struct" ? {} : [], { + parseOptions: { concurrency: 2 } + }).pipe(Effect.forkChild) + yield* Deferred.await(started[0]) + yield* Deferred.await(started[1]) + assert.strictEqual(yield* Deferred.isDone(started[2]), false) + yield* Deferred.succeed(releases[0], undefined) + yield* Deferred.await(started[2]) + yield* Deferred.succeed(releases[1], undefined) + yield* Deferred.succeed(releases[2], undefined) + assert.deepStrictEqual(yield* Fiber.join(fiber), product === "Struct" ? { a: 0, b: 1, c: 2 } : [0, 1, 2]) + assert.deepStrictEqual(calls, [1, 1, 1]) + })) + } +}) diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts new file mode 100644 index 00000000000..52af3d0304a --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -0,0 +1,349 @@ +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) + assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) + assert.strictEqual(forced, 1) + }) + + it("compiles only the raw constructor when construction succeeds", () => { + const schema = Schema.Struct({ a: Schema.String }) + const emit = vi.spyOn(Codegen, "generate") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual( + emit.mock.calls.filter(([, operation]) => operation === "decode" || operation === "is").length, + 0 + ) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").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, "generate") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) + } 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)) + assert.deepStrictEqual(SchemaParser.make(schema)(new Set([{ value: "a" }])), new Set([{ value: "a" }])) + assert.strictEqual(Registry.resolve(child.ast).source !== undefined, true) + }) + + 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.generate + const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { + if (ast === childAST && operation === "makeEffect") { + assert.strictEqual(defaults, 1) + throw new Error("child compile failed") + } + return emit(ast, operation) + }) + 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 generate = Codegen.generate + const failed = vi.spyOn(Codegen, "generate").mockImplementation((ast, key) => { + if (ast === schema.ast && key === (operation === "make" ? "make" : "decode")) { + throw new Error("compile failed") + } + return generate(ast, key) + }) + try { + const first = operation === "make" ? SchemaParser.make(schema) : SchemaParser.decodeUnknownSync(schema) + assert.deepStrictEqual(first({ a: "a" }), { a: "a" }) + const second = operation === "make" ? SchemaParser.decodeUnknownSync(schema) : SchemaParser.make(schema) + assert.deepStrictEqual(second({ a: "a" }), { a: "a" }) + assert( + failed.mock.calls.some(([ast, key]) => + ast === schema.ast && key === (operation === "make" ? "decode" : "make") + ) + ) + } 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.DecodeEffect { + throw new Error("unused decoder") + }, + get decode(): SchemaCompiler.Decode { + throw new Error("unused fast decoder") + }, + 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("uses an installed synchronous constructor without resolving makeEffect", () => { + const schema = Schema.Struct({ a: Schema.String }) + let calls = 0 + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + make: (input, options) => { + calls++ + assert.strictEqual(options, SchemaAST.defaultParseOptions) + return { a: `${(input as { readonly a: string }).a}!` } + }, + get makeEffect(): SchemaCompiler.MakeEffect { + throw new Error("unused detailed constructor") + } + }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a!" }) + assert.strictEqual(calls, 1) + }) + + it("falls back to makeEffect after an installed synchronous constructor fails", () => { + const schema = Schema.Struct({ a: Schema.String }) + let fast = 0 + let detailed = 0 + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + make: () => { + fast++ + return SchemaCompiler.invalid + }, + makeEffect: (input) => { + detailed++ + return Effect.succeed(input) + } + }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(fast, 1) + assert.strictEqual(detailed, 1) + }) + + it("does not return a missing result from an installed synchronous constructor", () => { + const schema = Schema.Struct({ a: Schema.String }) + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + make: () => SchemaCompiler.missing, + makeEffect: () => Effect.succeed(SchemaCompiler.missing) + }) + assert.throws(() => SchemaParser.make(schema)({ a: "a" }), /Schema validation failed/) + }) + + it("composes an installed synchronous child constructor in a compiled Array", () => { + const child = Schema.Struct({ a: Schema.String }) + SchemaCompiler.set(child.ast, { + decodeEffect: Effect.succeed, + make: (input) => ({ a: `${(input as { readonly a: string }).a}!` }), + makeEffect: () => { + throw new Error("unused detailed child constructor") + } + }) + const schema = Schema.Array(child) + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)([{ a: "a" }, { a: "b" }]), [{ a: "a!" }, { a: "b!" }]) + }) + + it("falls back to detailed construction for an invalid compiled Array", () => { + const schema = Schema.Array(Schema.Struct({ a: Schema.String })) + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + assert.deepStrictEqual(make([{ a: "a" }]), [{ a: "a" }]) + assert.throws(() => make([{ a: 1 } as never]), /Schema validation failed/) + }) + + 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.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..a76106f0e41 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerRegression.test.ts @@ -0,0 +1,458 @@ +import { assert, describe, it } from "@effect/vitest" +import { + Effect, + Exit, + Option, + Result, + Schema, + SchemaAST, + SchemaGetter, + SchemaParser, + SchemaTransformation +} from "effect" +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: SchemaGetter.transformOptionalEffect((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, unknown, unknown]> = [ + [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 = SchemaParser.decodeUnknownEffect(schema) + 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: SchemaGetter.transformOptionalEffect((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 decode: SchemaCompiler.Decode = (input, options) => { + seen.push(options) + return input + } + for (const operation of [is, decode]) { + Object.defineProperty(operation, "default", { + get() { + throw new Error("Not part of the compiled decoder contract") + } + }) + } + SchemaCompiler.set(schema.ast, { is, decode, 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 decode() { + strictEqual(this, decoder) + reads.push("decode") + return (input: unknown) => input + }, + get decodeEffect() { + strictEqual(this, decoder) + reads.push("decodeEffect") + 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", "decode"]) + }) + + 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 + }, + decode: (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 decode() { + strictEqual(this, decoder) + reads.push("decode") + return () => SchemaCompiler.invalid + }, + get decodeEffect() { + strictEqual(this, decoder) + reads.push("decodeEffect") + 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, ["decode", "decodeEffect"]) + }) + + 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..8999f7892a3 --- /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.source, undefined) + 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).source !== undefined, true) + }) +}) diff --git a/packages/effect/test/schema/SchemaGetter.test.ts b/packages/effect/test/schema/SchemaGetter.test.ts index 42697e8006d..40d477c0d2a 100644 --- a/packages/effect/test/schema/SchemaGetter.test.ts +++ b/packages/effect/test/schema/SchemaGetter.test.ts @@ -7,7 +7,7 @@ const formatIssue = SchemaIssue.makeFormatterDefault() function makeAsserts(getter: SchemaGetter.Getter) { return async (input: E, expected: T) => { const r = await Effect.runPromise( - getter.run(Option.some(input), {}).pipe( + SchemaGetter.run(getter, Option.some(input), {}).pipe( Effect.mapError(formatIssue), Effect.result ) @@ -20,22 +20,79 @@ describe("SchemaGetter", () => { it.effect("forbiddenEncoding", () => Effect.gen(function*() { const getter: SchemaGetter.Getter = SchemaGetter.forbiddenEncoding - const issue = yield* getter.run(Option.some(1), {}).pipe(Effect.flip) + const issue = yield* SchemaGetter.run(getter, Option.some(1), {}).pipe(Effect.flip) assert.strictEqual(issue._tag, "Forbidden") assert.strictEqual(formatIssue(issue), "Encoding is not supported") })) it.effect("stringifyJson fails when JSON.stringify returns undefined", () => - SchemaGetter.stringifyJson().run(Option.some(undefined), {}).pipe( + SchemaGetter.run(SchemaGetter.stringifyJson(), Option.some(undefined), {}).pipe( Effect.flip, Effect.map((issue) => assert.strictEqual(issue._tag, "InvalidValue")) )) - it("map", () => { - const getter = SchemaGetter.succeed(1).map((t) => t + 1) - const result = Effect.runSync(getter.run(Option.some(1), {})) - assertSome(result, 2) + it.effect("map", () => + Effect.gen(function*() { + const getter = SchemaGetter.map(SchemaGetter.succeed(1), (t) => t + 1) + const result = yield* SchemaGetter.run(Option.some(1), {})(getter) + assertSome(result, 2) + })) + + it("map preserves the specialized execution mode", () => { + assert.strictEqual(SchemaGetter.map(SchemaGetter.passthrough(), String)._tag, "Transform") + assert.strictEqual(SchemaGetter.map(SchemaGetter.transform(Number), String)._tag, "Transform") + assert.strictEqual( + SchemaGetter.map( + SchemaGetter.transformOptional((input: Option.Option) => Option.map(input, Number)), + String + )._tag, + "TransformOptional" + ) + assert.strictEqual( + SchemaGetter.map(SchemaGetter.transformEffect((input: string) => Effect.succeed(Number(input))), String)._tag, + "TransformEffect" + ) + assert.strictEqual( + SchemaGetter.map( + SchemaGetter.transformOptionalEffect((input: Option.Option) => + Effect.succeed(Option.map(input, Number)) + ), + String + )._tag, + "TransformOptionalEffect" + ) + }) + + it.effect("compose", () => + Effect.gen(function*() { + const first = SchemaGetter.transform(Number) + const second = SchemaGetter.transform((value: number) => value * 2) + const composed = SchemaGetter.compose(first, second) + + assert.strictEqual(composed._tag, "Transform") + assertSome(yield* SchemaGetter.run(composed, Option.some("2"), {}), 4) + assert.strictEqual(SchemaGetter.compose(SchemaGetter.passthrough(), first), first) + assert.strictEqual(SchemaGetter.compose(first, SchemaGetter.passthrough()), first) + })) + + it("compose preserves the specialized execution mode", () => { + const transform = SchemaGetter.transform(Number) + const transformNumber = SchemaGetter.transform((value: number) => value + 1) + const transformOptional = SchemaGetter.transformOptional(Option.map((value) => value + 1)) + const transformEffect = SchemaGetter.transformEffect((value: number) => Effect.succeed(value + 1)) + const transformOptionalEffect = SchemaGetter.transformOptionalEffect((input: Option.Option) => + Effect.succeed(Option.map(input, (value) => value + 1)) + ) + + assert.strictEqual(SchemaGetter.compose(transform, transformOptional)._tag, "TransformOptional") + assert.strictEqual(SchemaGetter.compose(transform, transformEffect)._tag, "TransformEffect") + assert.strictEqual(SchemaGetter.compose(transformOptional, transformNumber)._tag, "TransformOptional") + assert.strictEqual(SchemaGetter.compose(transformEffect, transformNumber)._tag, "TransformEffect") + assert.strictEqual(SchemaGetter.compose(transformEffect, transformEffect)._tag, "TransformEffect") + assert.strictEqual(SchemaGetter.compose(transformOptional, transformEffect)._tag, "TransformOptionalEffect") + assert.strictEqual(SchemaGetter.compose(transformEffect, transformOptional)._tag, "TransformOptionalEffect") + assert.strictEqual(SchemaGetter.compose(transformOptionalEffect, transformNumber)._tag, "TransformOptionalEffect") }) it("dateTimeUtcFromInput", async () => { diff --git a/packages/effect/test/schema/SchemaJITCompiler.test.ts b/packages/effect/test/schema/SchemaJITCompiler.test.ts new file mode 100644 index 00000000000..7ebcd99e59b --- /dev/null +++ b/packages/effect/test/schema/SchemaJITCompiler.test.ts @@ -0,0 +1,817 @@ +import { assert, describe, it } from "@effect/vitest" +import { + Cause, + Deferred, + Effect, + Fiber, + 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.effect("preserves product concurrency", () => + Effect.gen(function*() { + const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const item = Schema.String.pipe(Schema.decode({ + decode: SchemaGetter.transformEffect((value) => { + const index = value.charCodeAt(0) - 97 + return Deferred.succeed(started[index], undefined).pipe( + Effect.andThen(Deferred.await(releases[index])), + Effect.as(value) + ) + }), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Struct({ a: item, b: item, c: item }) + const fiber = yield* SchemaParser.decodeUnknownEffect(schema)( + { a: "a", b: "b", c: "c" }, + { concurrency: 2 } + ).pipe(Effect.forkChild) + + yield* Deferred.await(started[0]) + yield* Deferred.await(started[1]) + strictEqual(yield* Deferred.isDone(started[2]), false) + + yield* Deferred.succeed(releases[0], undefined) + yield* Deferred.await(started[2]) + yield* Deferred.succeed(releases[1], undefined) + yield* Deferred.succeed(releases[2], undefined) + + deepStrictEqual(yield* Fiber.join(fiber), { a: "a", b: "b", c: "c" }) + })) + + 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("accepts the invalid sentinel inside a checked composite", () => { + const schema = Schema.Struct({ value: Schema.Union([Schema.Symbol, Schema.String]) }).check( + Schema.makeFilter(() => true) + ) + const input = { value: SchemaCompiler.invalid } + strictEqual(SchemaParser.is(schema)(input), true) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + }) + + 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("preserves diagnostics with shared parser helpers", () => { + const schema = Schema.Struct({ value: Schema.String }) + 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("executes transformations and middleware once", () => { + 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 + }) + ) + ) + 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 + }) + ) + + deepStrictEqual(SchemaParser.decodeUnknownSync(middleware)({ value: "valid" }), { value: "valid" }) + strictEqual(middlewareRuns, 1) + }) + + it("decodes transformed Struct properties with runtime options", () => { + const schema = Schema.Struct({ + first: Schema.FiniteFromString, + second: Schema.FiniteFromString + }) + + 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("does not replay inlined Struct transformations", () => { + let transformations = 0 + const transformed = Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return value === "invalid" ? "invalid" as any : Number(value) + }, + encode: String + }) + ) + ) + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ value: transformed })) + + deepStrictEqual(decode({ value: "1" }), { value: 1 }) + strictEqual(transformations, 1) + throws(() => decode({ value: false })) + strictEqual(transformations, 1) + throws(() => decode({ value: "invalid" })) + strictEqual(transformations, 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 + ) + 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) + + deepStrictEqual(decode({ value: "-1" }, { disableChecks: true }), { value: -1 }) + strictEqual(checks, 2) + throws(() => decode({ value: "-1" }, { errors: "all" })) + strictEqual(transformations, 4) + strictEqual(checks, 3) + throws(() => decode({ value: "01" })) + strictEqual(transformations, 5) + strictEqual(checks, 3) + }) + + 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: SchemaGetter.transformOptionalEffect(() => 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..2ff42e7826c --- /dev/null +++ b/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts @@ -0,0 +1,178 @@ +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 ["generate"] 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 generate = Codegen.generate + const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { + if (ast === schema.ast) throw new Error("composed decoder generation failed") + return generate(ast, operation) + }) + 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.filter(([ast]) => ast === schema.ast).length, 1) + } finally { + failure.mockRestore() + } + }) + + it("recovers decoder generation 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.generate + let attempts = 0 + let transformationsAtFailure = 0 + const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { + if (ast === schema.ast) { + attempts++ + transformationsAtFailure = transformations + throw new Error("local checkpoint generation failed") + } + return emit(ast, operation) + }) + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(" valid "), { value: "valid" }) + strictEqual(transformationsAtFailure, 0) + 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/SchemaParser.test.ts b/packages/effect/test/schema/SchemaParser.test.ts index f9f3e9f1caa..f37d78abac8 100644 --- a/packages/effect/test/schema/SchemaParser.test.ts +++ b/packages/effect/test/schema/SchemaParser.test.ts @@ -530,12 +530,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause contains both an Issue and a defect", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) throws(() => SchemaParser.decodeUnknownSync(decodeSchema)("a"), (e) => { @@ -564,12 +564,12 @@ describe("SchemaParser", () => { it("should reject with an error when the cause contains both an Issue and a defect", async () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) const r1 = await SchemaParser.decodeUnknownPromise(decodeSchema)("a").then(Result.succeed, Result.fail) @@ -597,12 +597,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause is not an Issue", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.die(new Error("decode defect"))), + decode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("decode defect"))), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.die(new Error("encode defect"))) + encode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("encode defect"))) })) throws(() => SchemaParser.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -619,12 +619,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause contains both an Issue and a defect", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) throws(() => SchemaParser.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -705,12 +705,12 @@ describe("SchemaParser", () => { describe("decodeUnknownResult / encodeUnknownResult", () => { it("should throw an error when the cause is not an Issue", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.die(new Error("decode defect"))), + decode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("decode defect"))), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.die(new Error("encode defect"))) + encode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("encode defect"))) })) throws(() => SchemaParser.decodeUnknownResult(decodeSchema)("a"), (e) => { @@ -727,12 +727,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause contains both an Issue and a defect", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) throws(() => SchemaParser.decodeUnknownResult(decodeSchema)("a"), (e) => { @@ -833,7 +833,7 @@ describe("SchemaParser", () => { let rejectedReads = 0 const rejectedKey = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.fail(new SchemaIssue.InvalidValue())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.fail(new SchemaIssue.InvalidValue())), encode: SchemaGetter.passthrough() })) const rejectedInput = { @@ -856,7 +856,7 @@ describe("SchemaParser", () => { it("rejects a missing root output", () => { const schema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.succeedNone), + decode: SchemaGetter.transformOptionalEffect(() => Effect.succeedNone), encode: SchemaGetter.passthrough() })) const result = SchemaParser.decodeUnknownExit(schema)("value") @@ -865,7 +865,7 @@ describe("SchemaParser", () => { }) it("rejects a missing root output after parsing structural schemas", () => { - const missing = new SchemaGetter.Getter(() => Effect.succeedNone) + const missing = SchemaGetter.transformOptionalEffect(() => Effect.succeedNone) const array = Schema.Array(Schema.String).pipe(Schema.decode({ decode: missing, encode: missing @@ -929,7 +929,7 @@ describe("SchemaParser", () => { it.effect("wraps an asynchronous failure from a uniquely selected union member", () => Effect.gen(function*() { const failing = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => + decode: SchemaGetter.transformOptionalEffect(() => Effect.yieldNow.pipe( Effect.andThen(Effect.fail(new SchemaIssue.InvalidValue())) ) @@ -953,7 +953,7 @@ describe("SchemaParser", () => { 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(() => + decode: SchemaGetter.transformOptionalEffect(() => Effect.yieldNow.pipe( Effect.andThen(Effect.fail(new SchemaIssue.InvalidValue())) ) @@ -972,7 +972,7 @@ describe("SchemaParser", () => { const calls: Array = [] const field = (name: string, suspended = false) => Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((input) => { + decode: SchemaGetter.transformOptionalEffect((input) => { calls.push(name) return suspended ? Effect.suspend(() => Effect.succeed(input)) : Effect.succeed(input) }), @@ -1153,7 +1153,7 @@ describe("SchemaParser", () => { it("should preserve mixed causes in union candidates instead of trying later candidates", () => { const failure = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const schema = Schema.Union([ diff --git a/packages/effect/test/schema/fixtures/aot-build.ts b/packages/effect/test/schema/fixtures/aot-build.ts new file mode 100644 index 00000000000..b0e7363d955 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-build.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect" + +export const Port = Schema.NumberFromString + +export const User = Schema.Struct({ + name: Schema.String, + port: Port +}) + +export const ignored = "not a Schema" 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..011dfaf5979 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-import-guard.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import * as Module from "node:module" + +if (Module.registerHooks !== undefined) { + 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 + } + }) +} else { + Object.defineProperty(globalThis, "Function", { + value: function() { + throw new EvalError("Code generation from strings disallowed") + } + }) +} 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..3869dade495 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-runner.ts @@ -0,0 +1,166 @@ +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) + +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", + "pureTransformedStruct", + "checkedTransformedStruct", + "encodingCheckedTransformedStruct", + "asynchronous", + "middleware" + ] +) { + assert.equal(CompilerRegistry.resolve(schemas[name].ast).source !== undefined, true, 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).source !== undefined, true, 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..a760e955a9a --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot.ts @@ -0,0 +1,218 @@ +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 pureTransformed = Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ + decode: (input) => { + events.push("pure transform") + return input === "invalid" ? "invalid" as any : 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: SchemaGetter.transformOptionalEffect((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] + }, + checkedSentinel: { + schema: Schema.Struct({ value: Schema.Union([Schema.Symbol, Schema.String]) }).check( + Schema.makeFilter(() => true) + ), + inputs: [{ value: 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" } + ] + }, + pureTransformedStruct: { + schema: Schema.Struct({ value: pureTransformed }), + inputs: [{ value: "2" }, { value: false }, { value: "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/toStandardSchemaV1.test.ts b/packages/effect/test/schema/toStandardSchemaV1.test.ts index 88ee43577fc..5521a68ec7f 100644 --- a/packages/effect/test/schema/toStandardSchemaV1.test.ts +++ b/packages/effect/test/schema/toStandardSchemaV1.test.ts @@ -1,5 +1,5 @@ import { assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils" -import { Context, Effect, Option, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect" +import { Context, Effect, type Option, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect" import type { StandardSchemaV1 } from "effect/StandardSchema" import { describe, it } from "vitest" @@ -90,7 +90,7 @@ const expectAsyncFailure = async ( } const AsyncString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((os: Option.Option) => + decode: SchemaGetter.transformOptionalEffect((os: Option.Option) => Effect.gen(function*() { yield* Effect.sleep("10 millis") return os @@ -155,10 +155,10 @@ describe("toStandardSchemaV1", () => { it("sync decoding should throw", () => { const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() @@ -175,11 +175,11 @@ describe("toStandardSchemaV1", () => { it("async decoding should report a missing dependency", () => { const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber yield* Effect.sleep("10 millis") - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() diff --git a/packages/effect/typetest/schema/Schema.tst.ts b/packages/effect/typetest/schema/Schema.tst.ts index 6f2c585830f..68f639327e6 100644 --- a/packages/effect/typetest/schema/Schema.tst.ts +++ b/packages/effect/typetest/schema/Schema.tst.ts @@ -1804,10 +1804,10 @@ describe("Schema", () => { it("asStandardSchemaV1 should not be callable with a schema with DecodingServices", () => { class MagicNumber extends Context.Service()("MagicNumber") {} const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() diff --git a/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts new file mode 100644 index 00000000000..6fb337a8695 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts @@ -0,0 +1,24 @@ +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([{ ast: Schema.String.ast, operations: ["decode"] }])).type.toBe() + expect(SchemaAOTCompiler.compile).type.toBeCallableWith( + [ + { ast: SchemaAST.string, operations: ["decode"] }, + { ast: SchemaAST.number, operations: ["is", "make"] } + ] 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([SchemaAST.string]) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith( + [ + { ast: SchemaAST.string, operations: ["encode"] } + ] as const + ) + }) +}) diff --git a/packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts b/packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts new file mode 100644 index 00000000000..55b2ea189d1 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts @@ -0,0 +1,34 @@ +import type { Effect, FileSystem, Path, PlatformError } from "effect" +import * as SchemaAOTCompilerBuild from "effect/unstable/schema/SchemaAOTCompiler/Build" +import { describe, expect, it } from "tstyche" + +describe("SchemaAOTCompilerBuild", () => { + it("build", () => { + const modules: Record Promise> = { + "./schema.js": () => Promise.resolve({}) + } + const result = SchemaAOTCompilerBuild.build({ + modules, + baseUrl: import.meta.url, + outFile: "./schema-aot.js" + }) + + expect(result).type.toBe< + Effect.Effect< + SchemaAOTCompilerBuild.BuildResult, + SchemaAOTCompilerBuild.BuildError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path + > + >() + expect(SchemaAOTCompilerBuild.build).type.not.toBeCallableWith({ + modules: {}, + outFile: "./schema-aot.js" + }) + expect(SchemaAOTCompilerBuild.build).type.not.toBeCallableWith({ + modules: {}, + baseUrl: import.meta.url, + outFile: "./schema-aot.js", + operations: ["parse"] + }) + }) +}) diff --git a/packages/effect/typetest/schema/SchemaCompiler.tst.ts b/packages/effect/typetest/schema/SchemaCompiler.tst.ts new file mode 100644 index 00000000000..55035ab43e0 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaCompiler.tst.ts @@ -0,0 +1,24 @@ +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", + decode: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, + make: (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/SchemaGetter.tst.ts b/packages/effect/typetest/schema/SchemaGetter.tst.ts new file mode 100644 index 00000000000..90f527a6cd1 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaGetter.tst.ts @@ -0,0 +1,46 @@ +import type { Effect, Option, SchemaIssue } from "effect" +import { SchemaGetter, SchemaTransformation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("SchemaGetter", () => { + it("map", () => { + const getter = null as unknown as SchemaGetter.Getter + + expect(SchemaGetter.map(getter, String)).type.toBe>() + expect(SchemaGetter.map(String)(getter)).type.toBe>() + }) + + it("compose", () => { + const first = null as unknown as SchemaGetter.Getter + const second = null as unknown as SchemaGetter.Getter + + expect(SchemaGetter.compose(first, second)).type.toBe>() + expect(SchemaGetter.compose(second)(first)).type.toBe>() + }) + + it("run", () => { + const getter = null as unknown as SchemaGetter.Getter + const input = null as unknown as Option.Option + + expect(SchemaGetter.run(getter, input, {})).type.toBe< + Effect.Effect, SchemaIssue.Issue, "R"> + >() + expect(SchemaGetter.run(input, {})(getter)).type.toBe< + Effect.Effect, SchemaIssue.Issue, "R"> + >() + }) +}) + +describe("SchemaTransformation", () => { + it("compose", () => { + const first = null as unknown as SchemaTransformation.Transformation + const second = null as unknown as SchemaTransformation.Transformation + + expect(SchemaTransformation.compose(first, second)).type.toBe< + SchemaTransformation.Transformation + >() + expect(SchemaTransformation.compose(second)(first)).type.toBe< + SchemaTransformation.Transformation + >() + }) +})