| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Schema, SchemaAST, SchemaParser } from "effect" |
| 3 | import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" |
| 4 | import * as SchemaAOTCompiler from "effect/schema/SchemaAOTCompiler" |
| 5 | import * as SchemaTransformation from "effect/SchemaTransformation" |
| 6 | import { execFileSync } from "node:child_process" |
| 7 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs" |
| 8 | import { join } from "node:path" |
| 9 | import { fileURLToPath, pathToFileURL } from "node:url" |
| 10 | import { roots, schemas, suspendEvaluations } from "./fixtures/aot.ts" |
| 11 | |
| 12 | describe("SchemaAOTCompiler", { concurrent: false }, () => { |
| 13 | it("emits deterministic modules without installing a decoder", () => { |
| 14 | let checks = 0 |
| 15 | const schema = Schema.Struct({ |
| 16 | value: Schema.String.check(Schema.makeFilter(() => { |
| 17 | checks++ |
| 18 | return true |
| 19 | })) |
| 20 | }) |
| 21 | const before = CompilerRegistry.resolve(schema.ast) |
| 22 | const targets = [{ ast: schema.ast, operations: ["decode"] }] as const |
| 23 | const source = SchemaAOTCompiler.compile(targets) |
| 24 | assert.strictEqual(SchemaAOTCompiler.compile(targets), source) |
| 25 | assert.strictEqual(CompilerRegistry.resolve(schema.ast), before) |
| 26 | assert.strictEqual(checks, 0) |
| 27 | assert.include(source, "effect/schema/SchemaCompiler/runtime") |
| 28 | assert.notInclude(source, "new Function") |
| 29 | assert.notInclude(source, "SchemaJITCompiler") |
| 30 | }) |
| 31 | |
| 32 | it("emits only the requested operation family", () => { |
| 33 | const schema = Schema.Struct({ value: Schema.String }) |
| 34 | const decode = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) |
| 35 | assert.include(decode, "case \"decode\":{") |
| 36 | assert.include(decode, "case \"decodeEffect\":{") |
| 37 | assert.notInclude(decode, "case \"is\":{") |
| 38 | assert.notInclude(decode, "case \"make\":{") |
| 39 | assert.notInclude(decode, "case \"makeEffect\":{") |
| 40 | |
| 41 | const make = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["make"] }]) |
| 42 | assert.include(make, "case \"make\":{") |
| 43 | assert.include(make, "case \"makeEffect\":{") |
| 44 | assert.notInclude(make, "case \"is\":{") |
| 45 | assert.notInclude(make, "case \"decode\":{") |
| 46 | }) |
| 47 | |
| 48 | it("allows a target without requested operations", () => { |
| 49 | const schema = Schema.Struct({ value: Schema.String }) |
| 50 | const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: [] }]) |
| 51 | assert.notInclude(source, "runtime.setCompiler(") |
| 52 | }) |
| 53 | |
| 54 | it("omits fast decode operations from diagnostic-only dependencies", () => { |
| 55 | const child = Schema.Struct({ value: Schema.String }) |
| 56 | const schema = Schema.Array(child) |
| 57 | const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) |
| 58 | assert.strictEqual(source.match(/case "decode":\{/g)?.length, 1) |
| 59 | assert.strictEqual(source.match(/case "decodeEffect":\{/g)?.length, 2) |
| 60 | |
| 61 | const targeted = SchemaAOTCompiler.compile([ |
| 62 | { ast: schema.ast, operations: ["decode"] }, |
| 63 | { ast: child.ast, operations: ["decode"] } |
| 64 | ]) |
| 65 | assert.strictEqual(targeted.match(/case "decode":\{/g)?.length, 2) |
| 66 | assert.strictEqual(targeted.match(/case "decodeEffect":\{/g)?.length, 2) |
| 67 | }) |
| 68 | |
| 69 | it("uses registry fallbacks for operations that were not requested", async () => { |
| 70 | const schema = Schema.Struct({ value: Schema.String }) |
| 71 | const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-operations-test-", import.meta.url))) |
| 72 | try { |
| 73 | const file = join(directory, "decode.mjs") |
| 74 | writeFileSync(file, SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }])) |
| 75 | const generated = await import(`${pathToFileURL(file).href}?test=${Date.now()}`) |
| 76 | generated.install([schema.ast]) |
| 77 | |
| 78 | const entry = CompilerRegistry.resolve(schema.ast) |
| 79 | assert.strictEqual(typeof entry.compiled, "function") |
| 80 | assert.isDefined(entry.decode) |
| 81 | assert.isUndefined(entry.is) |
| 82 | assert.isUndefined(entry.make) |
| 83 | assert.strictEqual(SchemaParser.is(schema)({ value: "a" }), true) |
| 84 | assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) |
| 85 | } finally { |
| 86 | rmSync(directory, { recursive: true, force: true }) |
| 87 | } |
| 88 | }) |
| 89 | |
| 90 | it("honors enumerable excess checking directly in generated operations", async () => { |
| 91 | const visible = Symbol("visible") |
| 92 | const hidden = Symbol("hidden") |
| 93 | const schema = Schema.StructWithRest(Schema.Struct({ fixed: Schema.optionalKey(Schema.String) }), [ |
| 94 | Schema.Record(Schema.TemplateLiteral(["field-", Schema.Number]), Schema.Number), |
| 95 | Schema.Record(Schema.Symbol, Schema.Number) |
| 96 | ]) |
| 97 | const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-excess-test-", import.meta.url))) |
| 98 | try { |
| 99 | const file = join(directory, "decode.mjs") |
| 100 | writeFileSync(file, SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode", "is"] }])) |
| 101 | const generated = await import(pathToFileURL(file).href) |
| 102 | generated.install([schema.ast]) |
| 103 | const { decode, is } = CompilerRegistry.resolve(schema.ast) |
| 104 | assert.ok(decode, "Expected a compiled decoder") |
| 105 | assert.ok(is, "Expected a compiled type guard") |
| 106 | const strict = { onExcessProperty: "error" } as const |
| 107 | const expected = { "field-1": 1, [visible]: 2 } |
| 108 | const input = Object.defineProperties({ ...expected }, { |
| 109 | fixed: { value: "declared", enumerable: false }, |
| 110 | "field-2": { value: "invalid", enumerable: false }, |
| 111 | [hidden]: { |
| 112 | enumerable: false, |
| 113 | get() { |
| 114 | throw new Error("Non-enumerable properties must not be read") |
| 115 | } |
| 116 | } |
| 117 | }) |
| 118 | for (const options of [SchemaAST.defaultParseOptions, strict]) { |
| 119 | assert.deepStrictEqual(decode(input, options), { ...expected, fixed: "declared" }) |
| 120 | assert.isTrue(is(input, options)) |
| 121 | assert.strictEqual(decode({ [visible]: "invalid" }, options), CompilerRegistry.invalid) |
| 122 | } |
| 123 | assert.strictEqual(decode({ ...expected, extra: 1 }, strict), CompilerRegistry.invalid) |
| 124 | assert.isFalse(is({ ...expected, extra: 1 }, strict)) |
| 125 | assert.deepStrictEqual(decode({}, strict), {}) |
| 126 | } finally { |
| 127 | rmSync(directory, { recursive: true, force: true }) |
| 128 | } |
| 129 | }) |
| 130 | |
| 131 | it("initializes dependency operations on first use", async () => { |
| 132 | const child = Schema.String.pipe( |
| 133 | Schema.decodeTo( |
| 134 | Schema.Number, |
| 135 | SchemaTransformation.transform({ decode: Number, encode: String }) |
| 136 | ), |
| 137 | Schema.annotate({ title: "lazy AOT dependency" }) |
| 138 | ) |
| 139 | const schema = Schema.Struct({ child }) |
| 140 | const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-lazy-test-", import.meta.url))) |
| 141 | try { |
| 142 | const file = join(directory, "decode.mjs") |
| 143 | writeFileSync(file, SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }])) |
| 144 | const generated = await import(`${pathToFileURL(file).href}?test=${Date.now()}`) |
| 145 | generated.install([schema.ast]) |
| 146 | |
| 147 | const dependency = CompilerRegistry.resolve(child.ast) |
| 148 | assert.isFalse(Object.hasOwn(dependency, "decodeEffect")) |
| 149 | |
| 150 | const decode = SchemaParser.decodeUnknownSync(schema) |
| 151 | assert.deepStrictEqual(decode({ child: "1" }), { child: 1 }) |
| 152 | assert.isFalse(Object.hasOwn(dependency, "decodeEffect")) |
| 153 | |
| 154 | assert.throws(() => decode({ child: false })) |
| 155 | assert.isTrue(Object.hasOwn(dependency, "decodeEffect")) |
| 156 | } finally { |
| 157 | rmSync(directory, { recursive: true, force: true }) |
| 158 | } |
| 159 | }) |
| 160 | |
| 161 | it("deduplicates repeated roots and dependencies shared across roots", () => { |
| 162 | const child = Schema.Struct({ value: Schema.String }) |
| 163 | const first = Schema.Struct({ child }) |
| 164 | const second = Schema.Array(child) |
| 165 | const source = SchemaAOTCompiler.compile([ |
| 166 | { ast: first.ast, operations: ["decode"] }, |
| 167 | { ast: second.ast, operations: ["decode"] } |
| 168 | ]) |
| 169 | assert.strictEqual( |
| 170 | SchemaAOTCompiler.compile([ |
| 171 | { ast: first.ast, operations: ["decode"] }, |
| 172 | { ast: second.ast, operations: ["decode"] }, |
| 173 | { ast: first.ast, operations: ["decode"] }, |
| 174 | { ast: second.ast, operations: ["decode"] } |
| 175 | ]), |
| 176 | source |
| 177 | ) |
| 178 | assert.strictEqual(source.match(/runtime\.setCompiler\(/g)?.length, 3) |
| 179 | }) |
| 180 | |
| 181 | it("reuses identical decoder factories", () => { |
| 182 | const schema = Schema.Union( |
| 183 | Array.from({ length: 8 }, (_, tag) => Schema.Struct({ tag: Schema.Literal(tag), value: Schema.Number })) |
| 184 | ) |
| 185 | const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) |
| 186 | assert.strictEqual(source.match(/function d\d+\(ast,resolve,operation\)/g)?.length, 2) |
| 187 | }) |
| 188 | |
| 189 | it("preserves required, optional, and __proto__ property presence checks", () => { |
| 190 | const schema = Schema.Struct({ |
| 191 | required: Schema.UndefinedOr(Schema.String), |
| 192 | plain: Schema.String, |
| 193 | optional: Schema.optionalKey(Schema.String), |
| 194 | ["__proto__"]: Schema.String |
| 195 | }) |
| 196 | const decode = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) |
| 197 | assert.include(decode, "if(!(\"required\" in i))return I") |
| 198 | assert.notInclude(decode, "if(!(\"plain\" in i))") |
| 199 | assert.notInclude(decode, "if(!(\"optional\" in i))") |
| 200 | assert.include(decode, "if(\"optional\" in i){") |
| 201 | assert.include(decode, "if(!(Object.hasOwn(i,\"__proto__\")))return I") |
| 202 | |
| 203 | const guard = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["is"] }]) |
| 204 | assert.include(guard, "if(!(\"required\" in i))return false") |
| 205 | }) |
| 206 | |
| 207 | it("runs generated decoders without dynamic code generation", () => { |
| 208 | const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-test-", import.meta.url))) |
| 209 | try { |
| 210 | for (const [name, schema] of Object.entries(schemas)) { |
| 211 | writeFileSync( |
| 212 | join(directory, `${name}.mjs`), |
| 213 | SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode", "is", "make"] }]) |
| 214 | ) |
| 215 | } |
| 216 | writeFileSync( |
| 217 | join(directory, "all.mjs"), |
| 218 | SchemaAOTCompiler.compile(roots.map((ast) => ({ ast, operations: ["decode", "is", "make"] }))) |
| 219 | ) |
| 220 | writeFileSync(join(directory, "empty.mjs"), SchemaAOTCompiler.compile([])) |
| 221 | assert.strictEqual(suspendEvaluations, 0) |
| 222 | for (const mode of ["single", "multiple"]) { |
| 223 | const output = execFileSync(process.execPath, [ |
| 224 | "--disallow-code-generation-from-strings", |
| 225 | "--import", |
| 226 | fileURLToPath(new URL("./fixtures/aot-import-guard.ts", import.meta.url)), |
| 227 | fileURLToPath(new URL("./fixtures/aot-runner.ts", import.meta.url)), |
| 228 | directory, |
| 229 | mode |
| 230 | ], { encoding: "utf8" }) |
| 231 | assert.include(output, "AOT integration passed") |
| 232 | } |
| 233 | } finally { |
| 234 | rmSync(directory, { recursive: true, force: true }) |
| 235 | } |
| 236 | }, 20_000) |
| 237 | }) |