diff --git a/.changeset/quiet-rockets-juggle.md b/.changeset/quiet-rockets-juggle.md new file mode 100644 index 0000000..193cb82 --- /dev/null +++ b/.changeset/quiet-rockets-juggle.md @@ -0,0 +1,6 @@ +--- +'typesync-cli': minor +--- + +Add `generate-zod`, a new generator that emits Zod schemas from Typesync definitions with support for Zod v3 and v4 output. +Generated schemas include `.describe(...)` metadata, Firestore SDK target handling shared with `generate-ts`, optional `z.infer` type emission, and integration coverage for runtime validation. diff --git a/docs/cli/generate-ts.mdx b/docs/cli/generate-ts.mdx index 837a1c0..54a68b2 100644 --- a/docs/cli/generate-ts.mdx +++ b/docs/cli/generate-ts.mdx @@ -8,6 +8,7 @@ import DefinitionOption from '/snippets/cli-option-definition.mdx'; import IndentationOption from '/snippets/cli-option-indentation.mdx'; import OutFileOption from '/snippets/cli-option-out-file.mdx'; import TargetOption from '/snippets/cli-option-target.mdx'; +import TSTargetsList from '/snippets/ts-targets-list.mdx'; Generates TypeScript type definitions for the specified schema and writes them to the specified file. @@ -36,13 +37,4 @@ Controls how objects are defined in the TypeScript output. Object types can be r ## Targets -- `firebase-admin@13`: For backend projects that use [Firebase Admin Node.js SDK (v13)](https://www.npmjs.com/package/firebase-admin). -- `firebase-admin@12`: For backend projects that use [Firebase Admin Node.js SDK (v12)](https://www.npmjs.com/package/firebase-admin). -- `firebase-admin@11`: For backend projects that use [Firebase Admin Node.js SDK (v11)](https://www.npmjs.com/package/firebase-admin). -- `firebase-admin@10`: For backend projects that use [Firebase Admin Node.js SDK (v10)](https://www.npmjs.com/package/firebase-admin). -- `firebase@11`: For frontend projects that use [Firebase Javascript SDK (v11)](https://www.npmjs.com/package/firebase). -- `firebase@10`: For frontend projects that use [Firebase Javascript SDK (v10)](https://www.npmjs.com/package/firebase). -- `firebase@9`: For frontend projects that use [Firebase Javascript SDK (v9)](https://www.npmjs.com/package/firebase). -- `react-native-firebase@21`: For React Native projects that use [React Native Firebase (v21)](https://www.npmjs.com/package/@react-native-firebase/firestore). -- `react-native-firebase@20`: For React Native projects that use [React Native Firebase (v20)](https://www.npmjs.com/package/@react-native-firebase/firestore). -- `react-native-firebase@19`: For React Native projects that use [React Native Firebase (v19)](https://www.npmjs.com/package/@react-native-firebase/firestore). + diff --git a/docs/cli/generate-zod.mdx b/docs/cli/generate-zod.mdx new file mode 100644 index 0000000..0db7b01 --- /dev/null +++ b/docs/cli/generate-zod.mdx @@ -0,0 +1,99 @@ +--- +title: 'generate-zod' +icon: 'rectangle-terminal' +--- + +import DebugOption from '/snippets/cli-option-debug.mdx'; +import DefinitionOption from '/snippets/cli-option-definition.mdx'; +import IndentationOption from '/snippets/cli-option-indentation.mdx'; +import OutFileOption from '/snippets/cli-option-out-file.mdx'; +import TargetOption from '/snippets/cli-option-target.mdx'; +import TSTargetsList from '/snippets/ts-targets-list.mdx'; + +Generates [Zod](https://zod.dev) schemas for the specified Typesync schema and writes them to the specified file. The +generated schemas mirror the same mapping rules used internally by `typesync validate-data`, so the structure and +constraints stay in lockstep across runtime validation and code generation. + +Documentation captured in your schema definition is preserved in the output: model-level docs are emitted as JSDoc +comments and as `.describe(...)` calls on the schema, and field-level docs are emitted as `.describe(...)` calls on the +field's value schema. + +## Usage + +```bash +typesync generate-zod --definition --target --outFile --variant --schemaNamePattern --emitInferredTypes --inferredTypeNamePattern --indentation --debug +``` + +## Options + + + + + + + +Which Zod major release the generated code should target. Pick the variant that matches the `zod` version installed in +the consuming project. The two majors differ in a couple of API surface points (the `z.record` key argument and the +strict / loose object factories) and the generator emits the right shape for each. + + + + + +The pattern that controls how the generated Zod schema constants are named. The pattern must be a string that contains +the `"{modelName}"` substring (this is a literal value). + +Example values: + +- `"{modelName}Schema"` → produces exports like `UserSchema`, `PostSchema`, `AccountSchema` etc. +- `"z{modelName}"` → produces exports like `zUser`, `zPost`, `zAccount` etc. + + + + + +When enabled, the generator emits an inferred TypeScript type alongside each Zod schema. For a model named `User` with +the default patterns, the output becomes: + +```ts +export const UserSchema = z.strictObject({ + /* ... */ +}); +export type User = z.infer; +``` + + + + + +The pattern that controls how the inferred TypeScript types are named when `--emitInferredTypes` is set. The pattern +must contain the literal substring `"{modelName}"`. Ignored when `--emitInferredTypes` is not set. + +Example values: + +- `"{modelName}"` → produces types like `User`, `Post`, `Account` etc. +- `"{modelName}Type"` → produces types like `UserType`, `PostType`, `AccountType` etc. +- `"I{modelName}"` → produces types like `IUser`, `IPost`, `IAccount` etc. + + + + + + +## Targets + +`generate-zod` reuses the `generate-ts` target list. The `--target` option determines the runtime classes that the +generated `z.instanceof(...)` checks resolve to — pick the target that matches the Firestore SDK you decode documents +with. + + + +## Notes + +- References between models are emitted as `z.lazy(() => OtherSchema)`. Using `z.lazy` defers identifier resolution until + validation time, which keeps mutually recursive schemas working regardless of declaration order in the generated file. +- Strict / loose objects map to `z.strictObject` / `z.looseObject` for v4 and to `z.object(...).strict()` / + `z.object(...).passthrough()` for v3. Whether an object accepts additional fields is controlled by the + `additionalFields` flag in the schema definition. +- The Firestore SDK is only imported when at least one model uses `timestamp` or `bytes`. For the admin targets, `bytes` + resolves to the Node global `Buffer` and does not trigger an SDK import on its own. diff --git a/docs/docs.json b/docs/docs.json index c429a65..aa04912 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -55,6 +55,7 @@ "cli/generate-ts", "cli/generate-swift", "cli/generate-py", + "cli/generate-zod", "cli/generate-rules", "cli/generate-graph", "cli/validate-data", diff --git a/docs/snippets/ts-targets-list.mdx b/docs/snippets/ts-targets-list.mdx new file mode 100644 index 0000000..bc475ac --- /dev/null +++ b/docs/snippets/ts-targets-list.mdx @@ -0,0 +1,10 @@ +- `firebase-admin@13`: For backend projects that use [Firebase Admin Node.js SDK (v13)](https://www.npmjs.com/package/firebase-admin). +- `firebase-admin@12`: For backend projects that use [Firebase Admin Node.js SDK (v12)](https://www.npmjs.com/package/firebase-admin). +- `firebase-admin@11`: For backend projects that use [Firebase Admin Node.js SDK (v11)](https://www.npmjs.com/package/firebase-admin). +- `firebase-admin@10`: For backend projects that use [Firebase Admin Node.js SDK (v10)](https://www.npmjs.com/package/firebase-admin). +- `firebase@11`: For frontend projects that use [Firebase Javascript SDK (v11)](https://www.npmjs.com/package/firebase). +- `firebase@10`: For frontend projects that use [Firebase Javascript SDK (v10)](https://www.npmjs.com/package/firebase). +- `firebase@9`: For frontend projects that use [Firebase Javascript SDK (v9)](https://www.npmjs.com/package/firebase). +- `react-native-firebase@21`: For React Native projects that use [React Native Firebase (v21)](https://www.npmjs.com/package/@react-native-firebase/firestore). +- `react-native-firebase@20`: For React Native projects that use [React Native Firebase (v20)](https://www.npmjs.com/package/@react-native-firebase/firestore). +- `react-native-firebase@19`: For React Native projects that use [React Native Firebase (v19)](https://www.npmjs.com/package/@react-native-firebase/firestore). diff --git a/package.json b/package.json index a7dcae2..a5e695d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:integration:python": "tsx scripts/integration-test.ts python", "test:integration:swift": "tsx scripts/integration-test.ts swift", "test:integration:typescript": "tsx scripts/integration-test.ts typescript", + "test:integration:zod": "tsx scripts/integration-test.ts zod", "test:rules": "tsx src/cli/index.tsx generate-rules --definition tests/security/definition.yml --outFile tests/security/firestore.rules && firebase emulators:exec --project demo-rules --only firestore 'vitest run --config vitest.security.config.ts'", "test:src": "vitest run", "test:watch": "vitest" @@ -99,6 +100,8 @@ "tsup": "^8.0.2", "tsx": "^4.7.2", "typescript": "5.7.2", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "zod-v3": "npm:zod@^3.23.8", + "zod-v4": "npm:zod@^4.3.6" } } diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts index 67f5e56..4fe6809 100644 --- a/scripts/integration-test.ts +++ b/scripts/integration-test.ts @@ -18,17 +18,18 @@ * yarn tsx scripts/integration-test.ts all */ import { spawnSync } from 'node:child_process'; -import { existsSync, readdirSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { basename, extname, resolve } from 'node:path'; import { argv, exit } from 'node:process'; import { typesync } from '../src/api/index.js'; -type Platform = 'python' | 'swift' | 'typescript'; +type Platform = 'python' | 'swift' | 'typescript' | 'zod'; const REPO_ROOT = resolve(import.meta.dirname, '..'); const FIXTURES_ROOT = resolve(REPO_ROOT, 'tests/integration/_fixtures'); const FIXTURES_SCHEMAS = resolve(FIXTURES_ROOT, 'schemas'); +const ZOD_FIXTURES_SCHEMAS = resolve(REPO_ROOT, 'tests/integration/zod/_fixtures/schemas'); const FIREBASE_PROJECT_ID = 'demo-integration'; // Suites that don't run from the repo root (notably Swift, which runs from @@ -36,32 +37,73 @@ const FIREBASE_PROJECT_ID = 'demo-integration'; process.env.TYPESYNC_INTEGRATION_FIXTURES_ROOT ??= FIXTURES_ROOT; interface PlatformConfig { + /** + * Optional list of additional schema-fixture dirs that this platform + * should pull schemas from in addition to the shared + * `tests/integration/_fixtures/schemas/`. Used by the Zod suite to pick + * up zod-only fixtures (e.g. discriminated unions, simple unions, etc.) + * that are not safe to add to the shared dir without forcing every other + * platform to add round-trip coverage for them. + */ + extraFixturesSchemasDirs?: string[]; generatedDir: string; generatedExtension: string; - generate: (definitionPath: string, outFile: string) => Promise; /** - * Optional follow-up generation passes. Used by TypeScript to additionally - * emit code against alternative SDK targets (e.g. the web SDK) so that - * cross-target features like the `bytes` primitive can be round-tripped - * with each target's native representation. + * Primary generation pass. Writes to `generatedDir/.`. May + * be omitted by platforms whose generations are *all* in subdirs (e.g. + * `zod`, which emits `generated/{v3,v4,v4-web}/.ts`). + */ + generate?: (definitionPath: string, outFile: string) => Promise; + /** + * See `ExtraGeneration`. The TypeScript suite uses this to also emit code + * against the web SDK so the `bytes` primitive can be round-tripped with + * each target's native representation; the Zod suite uses it to emit each + * fixture against both Zod v3 and v4 (and the v4 web SDK). */ - extraGenerations?: { - /** - * Subdirectory under `generatedDir` that the extra pass writes to. The - * platform's `tsconfig.json` / `Package.swift` etc. must already include - * files in this directory. - */ - subdir: string; - /** - * Restricts the pass to a subset of fixtures (matched by basename, e.g. - * `'secrets'`). Omit to apply to every fixture. - */ - onlyFixtures?: string[]; - generate: (definitionPath: string, outFile: string) => Promise; - }[]; + extraGenerations?: ExtraGeneration[]; + /** + * Optional post-write hook applied to every file written by the *primary* + * `generate` pass. Run after the file is written to disk and before any + * test runs. + */ + postProcessFile?: (filePath: string) => void; runs: { description: string; cwd: string; cmd: string; args: string[]; underEmulator: boolean }[]; } +interface ExtraGeneration { + /** + * Subdirectory under `generatedDir` that the extra pass writes to. The + * platform's `tsconfig.json` / `Package.swift` etc. must already include + * files in this directory. + */ + subdir: string; + /** + * Restricts the pass to a subset of fixtures (matched by basename, e.g. + * `'secrets'`). Omit to apply to every fixture. + */ + onlyFixtures?: string[]; + generate: (definitionPath: string, outFile: string) => Promise; + /** + * Optional post-write hook. Receives the path of every generated file and + * may rewrite its contents in place. Used by the Zod platform to rewrite + * `from 'zod'` imports to a versioned alias so v3 and v4 can coexist in a + * single Node project. + */ + postProcessFile?: (filePath: string) => void; +} + +/** + * Rewrites bare `from 'zod'` and `require('zod')` references to a versioned + * alias (e.g. `zod-v3`). Used by the Zod integration suite so we can keep + * `zod@3` and `zod@4` installed simultaneously and load each generator's + * output against the correct Zod major. + */ +function rewriteZodImport(filePath: string, alias: string): void { + const original = readFileSync(filePath, 'utf8'); + const rewritten = original.replaceAll(`from 'zod'`, `from '${alias}'`).replaceAll(`from "zod"`, `from "${alias}"`); + if (rewritten !== original) writeFileSync(filePath, rewritten); +} + const PLATFORMS: Record = { python: { generatedDir: resolve(REPO_ROOT, 'tests/integration/python/generated'), @@ -155,20 +197,112 @@ const PLATFORMS: Record = { }, ], }, + // Zod integration suite. Each shared + zod-only fixture is emitted three + // times: Zod v3 against firebase-admin (primary `generate`), Zod v4 against + // firebase-admin (extra `v4`), and Zod v4 against the web SDK (extra + // `v4-web`, only for the `bytes` fixture). The primary pass writes to + // `generated/v3/.ts`; the post-process step rewrites every + // `from 'zod'` to `from 'zod-v3'` so a single Node package can keep both + // Zod majors installed side-by-side via npm aliases. + zod: { + extraFixturesSchemasDirs: [ZOD_FIXTURES_SCHEMAS], + generatedDir: resolve(REPO_ROOT, 'tests/integration/zod/generated'), + generatedExtension: '.ts', + extraGenerations: [ + { + subdir: 'v3', + async generate(definition, outFile) { + await typesync.generateZod({ + definition, + outFile, + target: 'firebase-admin@13', + variant: 'v3', + emitInferredTypes: true, + }); + }, + postProcessFile(filePath) { + rewriteZodImport(filePath, 'zod-v3'); + }, + }, + { + subdir: 'v4', + async generate(definition, outFile) { + await typesync.generateZod({ + definition, + outFile, + target: 'firebase-admin@13', + variant: 'v4', + emitInferredTypes: true, + }); + }, + postProcessFile(filePath) { + rewriteZodImport(filePath, 'zod-v4'); + }, + }, + { + subdir: 'v4-web', + onlyFixtures: ['secrets'], + async generate(definition, outFile) { + await typesync.generateZod({ + definition, + outFile, + target: 'firebase@10', + variant: 'v4', + emitInferredTypes: true, + }); + }, + postProcessFile(filePath) { + rewriteZodImport(filePath, 'zod-v4'); + }, + }, + ], + runs: [ + { + description: 'tsc --noEmit (compile-time check)', + cwd: resolve(REPO_ROOT, 'tests/integration/zod'), + cmd: 'yarn', + args: ['tsc', '--noEmit', '-p', '.'], + underEmulator: false, + }, + { + description: 'vitest (Zod round-trip + Firestore emulator)', + cwd: REPO_ROOT, + cmd: 'yarn', + args: ['vitest', 'run', '-c', 'tests/integration/zod/vitest.config.ts'], + underEmulator: true, + }, + ], + }, }; -function listSchemaFixtures(): { path: string; name: string }[] { - if (!existsSync(FIXTURES_SCHEMAS)) { - throw new Error(`Schema fixtures directory does not exist: ${FIXTURES_SCHEMAS}`); +function listSchemaFixtures(extraDirs: string[] = []): { path: string; name: string }[] { + const dirs = [FIXTURES_SCHEMAS, ...extraDirs]; + const fixtures: { path: string; name: string }[] = []; + for (const dir of dirs) { + if (!existsSync(dir)) { + throw new Error(`Schema fixtures directory does not exist: ${dir}`); + } + const entries = readdirSync(dir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')); + for (const file of entries) { + fixtures.push({ + path: resolve(dir, file), + name: basename(file, extname(file)), + }); + } + } + if (fixtures.length === 0) { + throw new Error(`No schema fixtures found under ${dirs.join(', ')}`); } - const entries = readdirSync(FIXTURES_SCHEMAS).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')); - if (entries.length === 0) { - throw new Error(`No schema fixtures found under ${FIXTURES_SCHEMAS}`); + // Defensive: a name collision between dirs would silently overwrite + // generated files. Easier to surface it loudly than to debug a flaky test. + const seen = new Set(); + for (const fixture of fixtures) { + if (seen.has(fixture.name)) { + throw new Error(`Duplicate schema fixture name '${fixture.name}' across fixture dirs`); + } + seen.add(fixture.name); } - return entries.map(file => ({ - path: resolve(FIXTURES_SCHEMAS, file), - name: basename(file, extname(file)), - })); + return fixtures; } function clearGeneratedDir(generatedDir: string, extension: string): void { @@ -188,17 +322,21 @@ function clearGeneratedDir(generatedDir: string, extension: string): void { async function generateAll(platform: Platform, config: PlatformConfig): Promise { console.log(`\n[${platform}] generating fixtures…`); clearGeneratedDir(config.generatedDir, config.generatedExtension); - const fixtures = listSchemaFixtures(); - for (const fixture of fixtures) { - const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`); - await config.generate(fixture.path, outFile); - console.log(` → ${fixture.name} -> ${outFile}`); + const fixtures = listSchemaFixtures(config.extraFixturesSchemasDirs); + if (config.generate) { + for (const fixture of fixtures) { + const outFile = resolve(config.generatedDir, `${fixture.name}${config.generatedExtension}`); + await config.generate(fixture.path, outFile); + config.postProcessFile?.(outFile); + console.log(` → ${fixture.name} -> ${outFile}`); + } } for (const extra of config.extraGenerations ?? []) { const targets = extra.onlyFixtures ? fixtures.filter(f => extra.onlyFixtures!.includes(f.name)) : fixtures; for (const fixture of targets) { const outFile = resolve(config.generatedDir, extra.subdir, `${fixture.name}${config.generatedExtension}`); await extra.generate(fixture.path, outFile); + extra.postProcessFile?.(outFile); console.log(` → [${extra.subdir}] ${fixture.name} -> ${outFile}`); } } @@ -243,11 +381,12 @@ async function runPlatform(platform: Platform): Promise { async function main(): Promise { const arg = (argv[2] ?? '').toLowerCase(); - const targets: Platform[] = arg === 'all' || arg === '' ? ['python', 'swift', 'typescript'] : [arg as Platform]; + const targets: Platform[] = + arg === 'all' || arg === '' ? ['python', 'swift', 'typescript', 'zod'] : [arg as Platform]; for (const platform of targets) { if (!(platform in PLATFORMS)) { - throw new Error(`Unknown platform "${platform}". Expected one of: python, swift, typescript, all.`); + throw new Error(`Unknown platform "${platform}". Expected one of: python, swift, typescript, zod, all.`); } } diff --git a/src/api/index.ts b/src/api/index.ts index 3eeecec..4fd09e6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -42,6 +42,15 @@ export { type TSObjectTypeFormat, getTSTargets, } from './ts.js'; +export { + type GenerateZodOption, + type GenerateZodOptions, + type GenerateZodRepresentationOptions, + type GenerateZodRepresentationResult, + type GenerateZodResult, + type ZodVariant, + getZodVariants, +} from './zod.js'; export type { ValidateDataFailure, ValidateDataModelReport, diff --git a/src/api/typesync.ts b/src/api/typesync.ts index ec94272..9eb9b72 100644 --- a/src/api/typesync.ts +++ b/src/api/typesync.ts @@ -32,6 +32,12 @@ import type { GenerateTsResult, } from './ts.js'; import type { ValidateDataOptions, ValidateDataResult } from './validate-data.js'; +import type { + GenerateZodOptions, + GenerateZodRepresentationOptions, + GenerateZodRepresentationResult, + GenerateZodResult, +} from './zod.js'; export interface GenerateRepresentationOptions { definition: string; @@ -101,6 +107,20 @@ export interface Typesync { */ generatePyRepresentation(opts: GeneratePythonRepresentationOptions): Promise; + /** + * Generates Zod schemas for the specified schema and writes them to the specified file. + * + * @remarks + * + * This is the programmatic API for the `typesync generate-zod` command. + */ + generateZod(opts: GenerateZodOptions): Promise; + + /** + * Generates Zod schemas for the specified schema and returns the generation and the internal representation of the schema without writing anything to the filesystem. + */ + generateZodRepresentation(opts: GenerateZodRepresentationOptions): Promise; + /** * Generates type validator functions for Firestore Security Rules and injects them into the specified file. * @@ -160,6 +180,7 @@ export type GenerationResult = | GenerateTsResult | GenerateSwiftResult | GeneratePythonResult + | GenerateZodResult | GenerateRulesResult | GenerateGraphResult; diff --git a/src/api/zod.ts b/src/api/zod.ts new file mode 100644 index 0000000..0f29a3a --- /dev/null +++ b/src/api/zod.ts @@ -0,0 +1,71 @@ +import { ZodGeneration } from '../generators/zod/index.js'; +import { objectKeys } from '../util/object-keys.js'; +import { GenerateRepresentationResult } from './_common.js'; +import type { TSGenerationTarget } from './ts.js'; + +const ZOD_VARIANTS = { + v3: true, + v4: true, +}; + +/** + * Which Zod major release the generated code should target. The two majors + * differ in a handful of API surface points (record key argument, strict/loose + * object factories) and the generator picks the right shape based on this. + */ +export type ZodVariant = keyof typeof ZOD_VARIANTS; + +export function getZodVariants() { + return objectKeys(ZOD_VARIANTS); +} + +export interface GenerateZodRepresentationOptions { + definition: string; + /** + * Which Firestore SDK the generated Zod schemas validate values against. + * Reuses the `generate-ts` target list — the SDK choice only affects the + * runtime class checked by `z.instanceof(...)` for the `timestamp` and + * `bytes` primitives (e.g. `Buffer` for the Node admin SDK, `firestore.Bytes` + * for the web SDK, `firestore.Blob` for `react-native-firebase`). + */ + target: TSGenerationTarget; + variant?: ZodVariant; + /** + * Pattern that controls how the generated Zod schema constants are named. + * Must contain the literal substring `{modelName}`. For example, with the + * default `'{modelName}Schema'` a model named `User` becomes `UserSchema`. + */ + schemaNamePattern?: string; + /** + * When `true`, the generator emits an inferred TypeScript type alongside + * each Zod schema, e.g. `export type User = z.infer;`. + * Defaults to `false`. + */ + emitInferredTypes?: boolean; + /** + * Pattern that controls how the inferred TypeScript types are named when + * `emitInferredTypes` is `true`. Must contain the literal substring + * `{modelName}`. For example, with the default `'{modelName}'` a model + * named `User` becomes `User`. Ignored when `emitInferredTypes` is `false`. + */ + inferredTypeNamePattern?: string; + debug?: boolean; +} + +export interface GenerateZodOptions extends GenerateZodRepresentationOptions { + outFile: string; + indentation?: number; +} + +export type GenerateZodOption = keyof GenerateZodOptions; + +export interface GenerateZodRepresentationResult extends GenerateRepresentationResult { + type: 'zod'; + + /** + * A structured representation of the generated Zod schemas. + */ + generation: ZodGeneration; +} + +export interface GenerateZodResult extends GenerateZodRepresentationResult {} diff --git a/src/cli/components/GenerationSuccessful.tsx b/src/cli/components/GenerationSuccessful.tsx index dd667c2..728fb41 100644 --- a/src/cli/components/GenerationSuccessful.tsx +++ b/src/cli/components/GenerationSuccessful.tsx @@ -18,6 +18,8 @@ function getMessageForResult(result: GenerationResult) { return 'Successfully generated Swift type definitions for the specified schema.'; case 'python': return 'Successfully generated Python/Pydantic type definitions for the specified schema.'; + case 'zod': + return 'Successfully generated Zod schemas for the specified schema.'; case 'rules': return 'Successfully generated validator functions for Firestore Security Rules.'; case 'graph': diff --git a/src/cli/index.tsx b/src/cli/index.tsx index d649ea6..1af2362 100755 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -5,7 +5,14 @@ import React from 'react'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { getPythonTargets, getSchemaGraphOrientations, getSwiftTargets, getTSTargets, typesync } from '../api/index.js'; +import { + getPythonTargets, + getSchemaGraphOrientations, + getSwiftTargets, + getTSTargets, + getZodVariants, + typesync, +} from '../api/index.js'; import type { ValidateDataProgressEvent } from '../api/index.js'; import { getObjectTypeFormats } from '../api/ts.js'; import { @@ -36,8 +43,16 @@ import { DEFAULT_VALIDATE_DATA_JSON, DEFAULT_VALIDATE_DATA_MAX_RETRIES, DEFAULT_VALIDATE_DEBUG, + DEFAULT_ZOD_DEBUG, + DEFAULT_ZOD_EMIT_INFERRED_TYPES, + DEFAULT_ZOD_INDENTATION, + DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN, + DEFAULT_ZOD_SCHEMA_NAME_PATTERN, + DEFAULT_ZOD_VARIANT, RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM, RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM, + ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, + ZOD_SCHEMA_NAME_PATTERN_PARAM, } from '../constants.js'; import { extractErrorMessage } from '../util/extract-error-message.js'; import { extractPackageJsonVersion } from '../util/extract-package-json-version.js'; @@ -245,6 +260,103 @@ await yargs(hideBin(process.argv)) } } ) + .command( + 'generate-zod', + 'Generates Zod schemas for the specified schema and writes them to the specified file.', + y => + y + .option('definition', { + describe: + 'The exact path or a Glob pattern to the schema definition file or files. Each definition file must be a YAML file containing model definitions.', + type: 'string', + demandOption: true, + }) + .option('target', { + describe: + 'The target Firestore SDK that the generated Zod schemas will validate data against. This determines the runtime classes used for `timestamp` and `bytes` (e.g. `Buffer` for the Node admin SDK, `firestore.Bytes` for the web SDK). Shares the `generate-ts` target list.', + type: 'string', + demandOption: true, + choices: getTSTargets(), + }) + .option('outFile', { + describe: 'The path to the output file.', + type: 'string', + demandOption: true, + }) + .option('variant', { + describe: + 'Which Zod major release the generated code should target. Pick the variant that matches the `zod` version installed in the consuming project.', + type: 'string', + demandOption: false, + choices: getZodVariants(), + default: DEFAULT_ZOD_VARIANT, + }) + .option('schemaNamePattern', { + describe: `The pattern that controls how the generated Zod schema constants are named. The pattern must contain the literal substring '${ZOD_SCHEMA_NAME_PATTERN_PARAM}'. For example, providing '${ZOD_SCHEMA_NAME_PATTERN_PARAM}Schema' (the default) ensures that schemas are exported as 'UserSchema', 'PostSchema', etc.`, + type: 'string', + demandOption: false, + default: DEFAULT_ZOD_SCHEMA_NAME_PATTERN, + }) + .option('emitInferredTypes', { + describe: + 'When enabled, the generator emits an inferred TypeScript type alongside each Zod schema (e.g. `export type User = z.infer;`). Disabled by default to avoid colliding with type names produced by `generate-ts`.', + type: 'boolean', + demandOption: false, + default: DEFAULT_ZOD_EMIT_INFERRED_TYPES, + }) + .option('inferredTypeNamePattern', { + describe: `The pattern that controls how the inferred TypeScript types are named when '--emitInferredTypes' is set. The pattern must contain the literal substring '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}'. For example, providing '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}' (the default) emits 'User', 'Post', etc.; providing '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}Type' emits 'UserType', 'PostType', etc.`, + type: 'string', + demandOption: false, + default: DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN, + }) + .option('indentation', { + describe: 'Indentation or tab width for the generated code.', + type: 'number', + demandOption: false, + default: DEFAULT_ZOD_INDENTATION, + }) + .option('debug', { + describe: 'Whether to enable debug logs.', + type: 'boolean', + demandOption: false, + default: DEFAULT_ZOD_DEBUG, + }), + async args => { + const { + definition, + target, + outFile, + variant, + schemaNamePattern, + emitInferredTypes, + inferredTypeNamePattern, + indentation, + debug, + } = args; + + const pathToOutputFile = resolve(process.cwd(), outFile); + try { + const result = await typesync.generateZod({ + definition: resolve(process.cwd(), definition), + target, + outFile: pathToOutputFile, + variant, + schemaNamePattern, + emitInferredTypes, + inferredTypeNamePattern, + indentation, + debug, + }); + + render(); + } catch (e) { + const message = extractErrorMessage(e); + render(); + yargs().exit(1, new Error(message)); + } + } + ) .command( 'generate-rules', 'Generates type validator functions for Firestore Security Rules and injects them into the specified file.', diff --git a/src/constants.ts b/src/constants.ts index c3ee020..228a610 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,9 +1,12 @@ import type { SchemaGraphOrientation } from './api/graph.js'; import type { TSObjectTypeFormat } from './api/ts.js'; +import type { ZodVariant } from './api/zod.js'; export const PYTHON_UNDEFINED_SENTINEL_CLASS = 'TypesyncUndefined'; export const RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM = '{modelName}'; export const RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM = '{modelName}'; +export const ZOD_SCHEMA_NAME_PATTERN_PARAM = '{modelName}'; +export const ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM = '{modelName}'; /* * Default values @@ -15,6 +18,13 @@ export const DEFAULT_TS_DEBUG = false; export const DEFAULT_SWIFT_INDENTATION = 2; export const DEFAULT_SWIFT_DEBUG = false; +export const DEFAULT_ZOD_VARIANT: ZodVariant = 'v4'; +export const DEFAULT_ZOD_SCHEMA_NAME_PATTERN = `${ZOD_SCHEMA_NAME_PATTERN_PARAM}Schema`; +export const DEFAULT_ZOD_EMIT_INFERRED_TYPES = false; +export const DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN = ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM; +export const DEFAULT_ZOD_INDENTATION = 2; +export const DEFAULT_ZOD_DEBUG = false; + export const DEFAULT_PY_CUSTOM_PYDANTIC_BASE = undefined; export const DEFAULT_PY_UNDEFINED_SENTINEL_NAME = 'UNDEFINED'; export const DEFAULT_PY_INDENTATION = 2; diff --git a/src/core/typesync.ts b/src/core/typesync.ts index c26ea63..7894c9d 100644 --- a/src/core/typesync.ts +++ b/src/core/typesync.ts @@ -21,6 +21,10 @@ import type { GenerateTsRepresentationOptions, GenerateTsRepresentationResult, GenerateTsResult, + GenerateZodOptions, + GenerateZodRepresentationOptions, + GenerateZodRepresentationResult, + GenerateZodResult, PythonGenerationTarget, SchemaGraphOrientation, SwiftGenerationTarget, @@ -31,6 +35,7 @@ import type { ValidateDataResult, ValidateOptions, ValidateResult, + ZodVariant, } from '../api/index.js'; import { GenerateRepresentationOptions, GenerateRepresentationResult } from '../api/typesync.js'; import { @@ -56,8 +61,16 @@ import { DEFAULT_TS_DEBUG, DEFAULT_TS_INDENTATION, DEFAULT_VALIDATE_DEBUG, + DEFAULT_ZOD_DEBUG, + DEFAULT_ZOD_EMIT_INFERRED_TYPES, + DEFAULT_ZOD_INDENTATION, + DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN, + DEFAULT_ZOD_SCHEMA_NAME_PATTERN, + DEFAULT_ZOD_VARIANT, RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM, RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM, + ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, + ZOD_SCHEMA_NAME_PATTERN_PARAM, } from '../constants.js'; import { DefinitionFilesNotFoundError } from '../errors/invalid-def.js'; import { @@ -77,6 +90,9 @@ import { InvalidTypeValidatorNamePatternOptionError, InvalidTypeValidatorParamNameOptionError, InvalidUndefinedSentinelNameOptionError, + InvalidZodIndentationOptionError, + InvalidZodInferredTypeNamePatternOptionError, + InvalidZodSchemaNamePatternOptionError, RulesMarkerOptionsNotDistinctError, ValidatorNamePatternsNotDistinctError, } from '../errors/invalid-opts.js'; @@ -85,6 +101,7 @@ import { createPythonGenerator } from '../generators/python/index.js'; import { createRulesGenerator } from '../generators/rules/index.js'; import { createSwiftGenerator } from '../generators/swift/index.js'; import { createTSGenerator } from '../generators/ts/index.js'; +import { createZodGenerator } from '../generators/zod/index.js'; import { renderers } from '../renderers/index.js'; import { createSchemaGraphFromSchema } from '../schema-graph/create-from-schema.js'; import { schema } from '../schema/index.js'; @@ -118,6 +135,21 @@ interface NormalizedGenerateSwiftOptions extends NormalizedGenerateSwiftRepresen indentation: number; } +interface NormalizedGenerateZodRepresentationOptions { + definitionGlobPattern: string; + target: TSGenerationTarget; + variant: ZodVariant; + schemaNamePattern: string; + emitInferredTypes: boolean; + inferredTypeNamePattern: string; + debug: boolean; +} + +interface NormalizedGenerateZodOptions extends NormalizedGenerateZodRepresentationOptions { + pathToOutputFile: string; + indentation: number; +} + interface NormalizedGeneratePythonRepresentationOptions { definitionGlobPattern: string; target: PythonGenerationTarget; @@ -247,6 +279,88 @@ class TypesyncImpl implements Typesync { }; } + public async generateZod(rawOpts: GenerateZodOptions): Promise { + const opts = this.normalizeGenerateZodOpts(rawOpts); + const { schema: s, generation } = await this.generateZodRepresentation(rawOpts); + const renderer = renderers.createZodRenderer({ + target: opts.target, + variant: opts.variant, + indentation: opts.indentation, + }); + const file = await renderer.render(generation); + await writeFile(opts.pathToOutputFile, file.content); + return { type: 'zod', schema: s, generation }; + } + + public async generateZodRepresentation( + rawOpts: GenerateZodRepresentationOptions + ): Promise { + const opts = this.normalizeGenerateZodRepresentationOpts(rawOpts); + const { + definitionGlobPattern, + target, + variant, + schemaNamePattern, + emitInferredTypes, + inferredTypeNamePattern, + debug, + } = opts; + const { schema: s } = this.createCoreObjects(definitionGlobPattern, debug); + const generator = createZodGenerator({ + target, + variant, + schemaNamePattern, + emitInferredTypes, + inferredTypeNamePattern, + }); + const generation = generator.generate(s); + return { type: 'zod', schema: s, generation }; + } + + private normalizeGenerateZodOpts(opts: GenerateZodOptions): NormalizedGenerateZodOptions { + const { outFile, indentation = DEFAULT_ZOD_INDENTATION, ...rest } = opts; + if (!Number.isSafeInteger(indentation) || indentation < 1) { + throw new InvalidZodIndentationOptionError(indentation); + } + return { + ...this.normalizeGenerateZodRepresentationOpts(rest), + pathToOutputFile: outFile, + indentation, + }; + } + + private normalizeGenerateZodRepresentationOpts( + opts: GenerateZodRepresentationOptions + ): NormalizedGenerateZodRepresentationOptions { + const { + definition, + target, + variant = DEFAULT_ZOD_VARIANT, + schemaNamePattern = DEFAULT_ZOD_SCHEMA_NAME_PATTERN, + emitInferredTypes = DEFAULT_ZOD_EMIT_INFERRED_TYPES, + inferredTypeNamePattern = DEFAULT_ZOD_INFERRED_TYPE_NAME_PATTERN, + debug = DEFAULT_ZOD_DEBUG, + } = opts; + + if (!schemaNamePattern.includes(ZOD_SCHEMA_NAME_PATTERN_PARAM)) { + throw new InvalidZodSchemaNamePatternOptionError(schemaNamePattern); + } + + if (!inferredTypeNamePattern.includes(ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM)) { + throw new InvalidZodInferredTypeNamePatternOptionError(inferredTypeNamePattern); + } + + return { + definitionGlobPattern: definition, + target, + variant, + schemaNamePattern, + emitInferredTypes, + inferredTypeNamePattern, + debug, + }; + } + public async generatePy(rawOpts: GeneratePythonOptions): Promise { const opts = this.normalizeGeneratePyOpts(rawOpts); const { schema: s, generation } = await this.generatePyRepresentation(rawOpts); diff --git a/src/core/zod/__tests__/codegen-emitter.test.ts b/src/core/zod/__tests__/codegen-emitter.test.ts new file mode 100644 index 0000000..ad968d5 --- /dev/null +++ b/src/core/zod/__tests__/codegen-emitter.test.ts @@ -0,0 +1,189 @@ +import { schema } from '../../../schema/index.js'; +import { createCodegenZodEmitter } from '../_codegen-emitter.js'; +import { buildZodFromType } from '../build-zod-schema.js'; + +function emit( + type: schema.types.Type, + overrides: { variant?: 'v3' | 'v4'; target?: 'firebase-admin@13' | 'firebase@10' | 'react-native-firebase@21' } = {} +) { + const emitter = createCodegenZodEmitter({ + variant: overrides.variant ?? 'v4', + target: overrides.target ?? 'firebase-admin@13', + getSchemaIdentifierForModel: name => `${name}Schema`, + }); + return buildZodFromType(type, emitter); +} + +describe('createCodegenZodEmitter()', () => { + describe('primitives, literals, and enums', () => { + it('emits the canonical zod constructor for each primitive type', () => { + expect(emit({ type: 'any' })).toBe('z.any()'); + expect(emit({ type: 'unknown' })).toBe('z.unknown()'); + expect(emit({ type: 'nil' })).toBe('z.null()'); + expect(emit({ type: 'string' })).toBe('z.string()'); + expect(emit({ type: 'boolean' })).toBe('z.boolean()'); + expect(emit({ type: 'int' })).toBe('z.number().int()'); + expect(emit({ type: 'double' })).toBe('z.number()'); + }); + + it('emits literals with JSON-encoded values so strings keep their quotes', () => { + expect(emit({ type: 'string-literal', value: "it's" })).toBe(`z.literal("it's")`); + expect(emit({ type: 'int-literal', value: 42 })).toBe('z.literal(42)'); + expect(emit({ type: 'boolean-literal', value: true })).toBe('z.literal(true)'); + }); + + it('emits string enums as z.enum([...]) regardless of member count', () => { + const single = emit({ type: 'string-enum', members: [{ label: 'A', value: 'a' }] }); + expect(single).toBe(`z.enum(["a"])`); + + const multi = emit({ + type: 'string-enum', + members: [ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + ], + }); + expect(multi).toBe(`z.enum(["red", "blue"])`); + }); + + it('emits int enums as a literal union (z.enum is string-only in Zod)', () => { + const single = emit({ type: 'int-enum', members: [{ label: 'One', value: 1 }] }); + expect(single).toBe('z.union([z.literal(1)])'); + + const multi = emit({ + type: 'int-enum', + members: [ + { label: 'Lo', value: 1 }, + { label: 'Hi', value: 2 }, + ], + }); + expect(multi).toBe('z.union([z.literal(1), z.literal(2)])'); + }); + }); + + describe('Firestore-bound primitives', () => { + it('uses Buffer for bytes on the admin target and casts firestore.Bytes/Blob (private constructors) for the others', () => { + expect(emit({ type: 'bytes' }, { target: 'firebase-admin@13' })).toBe('z.instanceof(Buffer)'); + // The web SDK's `firestore.Bytes` declares a private constructor, which + // does not satisfy `z.instanceof`'s `new (...args: any[]) => any` + // constraint. The codegen emits a constructor-shape cast so the + // generated source type-checks under strict TypeScript. + expect(emit({ type: 'bytes' }, { target: 'firebase@10' })).toBe( + 'z.instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes)' + ); + expect(emit({ type: 'bytes' }, { target: 'react-native-firebase@21' })).toBe( + 'z.instanceof(firestore.Blob as unknown as new (...args: never[]) => firestore.Blob)' + ); + }); + + it('uses firestore.Timestamp regardless of target so the runtime check matches the SDK class', () => { + expect(emit({ type: 'timestamp' }, { target: 'firebase-admin@13' })).toBe('z.instanceof(firestore.Timestamp)'); + expect(emit({ type: 'timestamp' }, { target: 'firebase@10' })).toBe('z.instanceof(firestore.Timestamp)'); + }); + }); + + describe('records, arrays, and tuples', () => { + it('emits z.record without a key schema for v3 and with z.string() for v4', () => { + const type: schema.types.Map = { type: 'map', valueType: { type: 'string' } }; + expect(emit(type, { variant: 'v3' })).toBe('z.record(z.string())'); + expect(emit(type, { variant: 'v4' })).toBe('z.record(z.string(), z.string())'); + }); + + it('emits z.array(value) and z.tuple([...]) consistently across variants', () => { + expect(emit({ type: 'list', elementType: { type: 'string' } })).toBe('z.array(z.string())'); + expect(emit({ type: 'tuple', elements: [{ type: 'string' }, { type: 'int' }] })).toBe( + 'z.tuple([z.string(), z.number().int()])' + ); + expect(emit({ type: 'tuple', elements: [] })).toBe('z.tuple([])'); + }); + }); + + describe('object types', () => { + function makeField( + overrides: Partial & { name: string; type: schema.types.Type } + ): schema.types.ObjectField { + return { + optional: false, + readonly: false, + docs: null, + ...overrides, + }; + } + + const objectType: schema.types.Object = { + type: 'object', + additionalFields: false, + fields: [ + makeField({ name: 'id', type: { type: 'string' }, docs: 'The id' }), + makeField({ name: 'age', type: { type: 'int' }, optional: true }), + ], + }; + + it('uses z.object().strict()/.passthrough() for v3 and z.strictObject/z.looseObject for v4', () => { + const v3Strict = emit(objectType, { variant: 'v3' }); + expect(v3Strict).toContain(`.strict()`); + expect(v3Strict.startsWith('z.object(')).toBe(true); + + const v3Loose = emit({ ...objectType, additionalFields: true }, { variant: 'v3' }); + expect(v3Loose).toContain(`.passthrough()`); + + const v4Strict = emit(objectType, { variant: 'v4' }); + expect(v4Strict.startsWith('z.strictObject(')).toBe(true); + + const v4Loose = emit({ ...objectType, additionalFields: true }, { variant: 'v4' }); + expect(v4Loose.startsWith('z.looseObject(')).toBe(true); + }); + + it('attaches .describe(...) to the inner value for fields with docs and wraps optional fields with .optional()', () => { + const out = emit(objectType, { variant: 'v4' }); + expect(out).toContain(`id: z.string().describe("The id")`); + expect(out).toContain(`age: z.number().int().optional()`); + }); + + it('quotes property names that are not valid JS identifiers', () => { + const trickyObject: schema.types.Object = { + type: 'object', + additionalFields: false, + fields: [ + makeField({ name: 'kebab-key', type: { type: 'string' } }), + makeField({ name: '1startsWithDigit', type: { type: 'string' } }), + ], + }; + const out = emit(trickyObject); + expect(out).toContain(`"kebab-key":`); + expect(out).toContain(`"1startsWithDigit":`); + }); + }); + + describe('unions', () => { + it('emits z.union([...]) for simple unions and unwraps single-variant unions', () => { + const single = emit({ type: 'simple-union', variants: [{ type: 'string' }] }); + expect(single).toBe('z.string()'); + + const multi = emit({ + type: 'simple-union', + variants: [{ type: 'string' }, { type: 'int' }], + }); + expect(multi).toBe('z.union([z.string(), z.number().int()])'); + }); + + it('emits z.discriminatedUnion(, [...]) for discriminated unions', () => { + const out = emit({ + type: 'discriminated-union', + discriminant: 'kind', + variants: [ + { type: 'alias', name: 'Cat' }, + { type: 'alias', name: 'Dog' }, + ], + }); + expect(out).toBe(`z.discriminatedUnion("kind", [z.lazy(() => CatSchema), z.lazy(() => DogSchema)])`); + }); + }); + + describe('alias references', () => { + it('emits z.lazy(() => Identifier) using the configured identifier mapper', () => { + const out = emit({ type: 'alias', name: 'User' }); + expect(out).toBe('z.lazy(() => UserSchema)'); + }); + }); +}); diff --git a/src/core/zod/_codegen-emitter.ts b/src/core/zod/_codegen-emitter.ts new file mode 100644 index 0000000..b1febe2 --- /dev/null +++ b/src/core/zod/_codegen-emitter.ts @@ -0,0 +1,180 @@ +import type { TSGenerationTarget } from '../../api/ts.js'; +import { assertNever } from '../../util/assert.js'; +import type { ZodEmitter } from './_emitter.js'; + +/** + * Which Zod major API the emitter should target. The two majors differ in a + * handful of places that matter to codegen: + * + * - records: v3 takes a single argument `z.record(value)`; v4 requires the key + * schema as the first argument: `z.record(z.string(), value)`. + * - object passthrough/strict: v3 expresses these as chainable methods on + * `z.object(...)` (`.strict()`, `.passthrough()`); v4 ships dedicated + * factories (`z.strictObject(...)`, `z.looseObject(...)`). + */ +export type ZodVariant = 'v3' | 'v4'; + +export interface ZodCodegenEmitterConfig { + variant: ZodVariant; + /** + * Identifies the Firestore SDK that the generated Zod schemas will be + * validating data against. Shares the `generate-ts` target list — the SDK + * identity only matters for picking the right runtime class for the + * `timestamp` and `bytes` primitives (e.g. `Buffer` vs `firestore.Bytes` + * vs `firestore.Blob`). + */ + target: TSGenerationTarget; + /** + * Maps a Typesync model name (e.g. `User`) to the identifier under which the + * corresponding Zod schema will be exported in the generated file + * (e.g. `UserSchema`). The codegen emitter uses this to wire references + * between schemas. + */ + getSchemaIdentifierForModel: (modelName: string) => string; +} + +/** + * Emitter that produces Zod **source code** (as plain strings) instead of live + * `ZodType` instances. Used by the `generate-zod` command. The runtime emitter + * (`./_runtime-emitter.ts`) and this codegen emitter are driven by the same + * `buildZodFromType` traversal so the schema-to-Zod mapping rules live in + * exactly one place. + */ +export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmitter { + const { variant, target, getSchemaIdentifierForModel } = config; + + const timestampExpression = expressionForTimestampInstanceCheck(target); + const bytesExpression = expressionForBytesInstanceCheck(target); + + return { + any: () => 'z.any()', + unknown: () => 'z.unknown()', + nullType: () => 'z.null()', + string: () => 'z.string()', + boolean: () => 'z.boolean()', + int: () => 'z.number().int()', + double: () => 'z.number()', + timestamp: () => `z.instanceof(${timestampExpression})`, + bytes: () => `z.instanceof(${bytesExpression})`, + + stringLiteral: value => `z.literal(${JSON.stringify(value)})`, + intLiteral: value => `z.literal(${value})`, + booleanLiteral: value => `z.literal(${value})`, + + // String enums use the canonical `z.enum([...])` form. It works in Zod + // v3 and v4 alike (including with a single member) and gives much better + // error messages than a literal union. Int enums fall back to a literal + // union because `z.enum([...])` is string-only in Zod and the v4-only + // `z.literal([1, 2])` form does not accept single-member arrays. + stringEnum: values => `z.enum([${values.map(v => JSON.stringify(v)).join(', ')}])`, + intEnum: values => `z.union([${values.map(v => `z.literal(${v})`).join(', ')}])`, + + tuple: elements => `z.tuple([${elements.join(', ')}])`, + array: element => `z.array(${element})`, + record: value => (variant === 'v3' ? `z.record(${value})` : `z.record(z.string(), ${value})`), + + object: (fields, additionalFields) => { + const propertyEntries = fields.map(field => { + let valueExpression = field.value; + if (field.docs !== null && field.docs.length > 0) { + valueExpression = `${valueExpression}.describe(${JSON.stringify(field.docs)})`; + } + if (field.optional) { + valueExpression = `${valueExpression}.optional()`; + } + return `${propertyKey(field.name)}: ${valueExpression}`; + }); + const objectLiteral = propertyEntries.length > 0 ? `{ ${propertyEntries.join(', ')} }` : '{}'; + return objectFactoryExpression(variant, additionalFields, objectLiteral); + }, + + // Mirrors the runtime emitter: a degenerate 0/1-variant union collapses to + // its sole variant (or `z.never()` if empty) rather than emitting a literal + // `z.union([single])`. Reasons: (1) it keeps both emitters semantically + // equivalent; (2) it sidesteps Zod v3's `[A, A, ...A[]]` tuple type for + // `z.union`, which would otherwise require an `as`-cast in the generated + // file; (3) the output is cleaner. + simpleUnion: unionVariants => { + if (unionVariants.length < 2) return unionVariants[0] ?? 'z.never()'; + return `z.union([${unionVariants.join(', ')}])`; + }, + + discriminatedUnion: (discriminant, unionVariants) => { + if (unionVariants.length < 2) return unionVariants[0] ?? 'z.never()'; + return `z.discriminatedUnion(${JSON.stringify(discriminant)}, [${unionVariants.join(', ')}])`; + }, + + reference: modelName => { + const identifier = getSchemaIdentifierForModel(modelName); + // `z.lazy` defers identifier access to validation time, which is what + // makes mutually recursive schemas in the generated file work regardless + // of declaration order. + return `z.lazy(() => ${identifier})`; + }, + }; +} + +function objectFactoryExpression(variant: ZodVariant, additionalFields: boolean, objectLiteral: string): string { + switch (variant) { + case 'v3': + return additionalFields ? `z.object(${objectLiteral}).passthrough()` : `z.object(${objectLiteral}).strict()`; + case 'v4': + return additionalFields ? `z.looseObject(${objectLiteral})` : `z.strictObject(${objectLiteral})`; + default: + assertNever(variant); + } +} + +const VALID_IDENTIFIER_REGEX = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function propertyKey(name: string): string { + return VALID_IDENTIFIER_REGEX.test(name) ? name : JSON.stringify(name); +} + +function expressionForTimestampInstanceCheck(target: TSGenerationTarget): string { + switch (target) { + case 'firebase-admin@13': + case 'firebase-admin@12': + case 'firebase-admin@11': + case 'firebase-admin@10': + case 'firebase@11': + case 'firebase@10': + case 'firebase@9': + case 'react-native-firebase@21': + case 'react-native-firebase@20': + case 'react-native-firebase@19': + return 'firestore.Timestamp'; + default: + assertNever(target); + } +} + +function expressionForBytesInstanceCheck(target: TSGenerationTarget): string { + switch (target) { + case 'firebase-admin@13': + case 'firebase-admin@12': + case 'firebase-admin@11': + case 'firebase-admin@10': + // Firestore bytes are represented as Node `Buffer` in admin. Buffer + // has a public constructor so it satisfies `z.instanceof` directly. + return 'Buffer'; + case 'firebase@11': + case 'firebase@10': + case 'firebase@9': + // `firestore.Bytes` declares a private constructor, which violates the + // `new (...args: any[]) => any` constraint enforced by `z.instanceof`. + // The cast has to go through `unknown` because TypeScript otherwise + // refuses the conversion ("private vs public constructor"). The cast + // is erased at runtime; the actual instance check still runs against + // the real class object. + return 'firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes'; + case 'react-native-firebase@21': + case 'react-native-firebase@20': + case 'react-native-firebase@19': + // Same reason as `firestore.Bytes` above — `firestore.Blob` has a + // private constructor in the `@react-native-firebase/firestore` types. + return 'firestore.Blob as unknown as new (...args: never[]) => firestore.Blob'; + default: + assertNever(target); + } +} diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts index 4fd6444..f51c273 100644 --- a/src/core/zod/_emitter.ts +++ b/src/core/zod/_emitter.ts @@ -41,6 +41,13 @@ export interface ZodEmitter { name: string; value: TOut; optional: boolean; + /** + * Documentation attached to the field in the source schema, if any. + * The runtime emitter ignores this; the codegen emitter folds it into + * a `.describe(...)` call so the generated Zod schema preserves the + * field-level docs from the original definition. + */ + docs: string | null; }>, additionalFields: boolean ): TOut; diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts index 1c78f8a..d778994 100644 --- a/src/core/zod/_runtime-emitter.ts +++ b/src/core/zod/_runtime-emitter.ts @@ -33,7 +33,7 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte intLiteral: value => z.literal(value), booleanLiteral: value => z.literal(value), - stringEnum: values => z.union(values.map(v => z.literal(v)) as [z.ZodLiteral, ...z.ZodLiteral[]]), + stringEnum: values => z.enum(values as [string, ...string[]]), intEnum: values => z.union(values.map(v => z.literal(v)) as [z.ZodLiteral, ...z.ZodLiteral[]]), tuple: elements => { diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts index fa9e9fe..8580e13 100644 --- a/src/core/zod/build-zod-schema.ts +++ b/src/core/zod/build-zod-schema.ts @@ -53,6 +53,7 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit name: field.name, value: buildZodFromType(field.type, emitter), optional: field.optional, + docs: field.docs, })), type.additionalFields ); diff --git a/src/core/zod/index.ts b/src/core/zod/index.ts index c6b9c46..d9eb8fe 100644 --- a/src/core/zod/index.ts +++ b/src/core/zod/index.ts @@ -1,2 +1,3 @@ export { buildZodSchemaMap, buildZodFromType, type ZodSchemaMap } from './build-zod-schema.js'; export type { ZodEmitter } from './_emitter.js'; +export { createCodegenZodEmitter, type ZodCodegenEmitterConfig, type ZodVariant } from './_codegen-emitter.js'; diff --git a/src/errors/invalid-opts.ts b/src/errors/invalid-opts.ts index 31143d5..f0f1188 100644 --- a/src/errors/invalid-opts.ts +++ b/src/errors/invalid-opts.ts @@ -4,11 +4,14 @@ import type { GenerateRulesOption, GenerateSwiftOption, GenerateTsOption, + GenerateZodOption, ValidateDataOption, } from '../api/index.js'; import { RULES_READONLY_FIELD_VALIDATOR_NAME_PATTERN_PARAM, RULES_TYPE_VALIDATOR_NAME_PATTERN_PARAM, + ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, + ZOD_SCHEMA_NAME_PATTERN_PARAM, } from '../constants.js'; export class InvalidOptionsError extends Error { @@ -38,6 +41,31 @@ export class InvalidPyIndentationOptionError extends InvalidOptionsError { } } +export class InvalidZodIndentationOptionError extends InvalidOptionsError { + public constructor(indentation: number) { + const option: GenerateZodOption = 'indentation'; + super(`Expected '${option}' to be a positive integer. Received ${indentation}`); + } +} + +export class InvalidZodSchemaNamePatternOptionError extends InvalidOptionsError { + public constructor(pattern: string) { + const option: GenerateZodOption = 'schemaNamePattern'; + super( + `Expected '${option}' to be a string that contains a '${ZOD_SCHEMA_NAME_PATTERN_PARAM}' substring. Received '${pattern}'` + ); + } +} + +export class InvalidZodInferredTypeNamePatternOptionError extends InvalidOptionsError { + public constructor(pattern: string) { + const option: GenerateZodOption = 'inferredTypeNamePattern'; + super( + `Expected '${option}' to be a string that contains a '${ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM}' substring. Received '${pattern}'` + ); + } +} + export class InvalidRulesStartMarkerOptionError extends InvalidOptionsError { public constructor() { const option: GenerateRulesOption = 'startMarker'; diff --git a/src/generators/zod/__tests__/generator.test.ts b/src/generators/zod/__tests__/generator.test.ts new file mode 100644 index 0000000..4054d86 --- /dev/null +++ b/src/generators/zod/__tests__/generator.test.ts @@ -0,0 +1,235 @@ +import type { TSGenerationTarget, ZodVariant } from '../../../api/index.js'; +import { schema } from '../../../schema/index.js'; +import { createZodGenerator } from '../_impl.js'; + +function createGenerator( + overrides: { + target?: TSGenerationTarget; + variant?: ZodVariant; + schemaNamePattern?: string; + emitInferredTypes?: boolean; + inferredTypeNamePattern?: string; + } = {} +) { + return createZodGenerator({ + target: overrides.target ?? 'firebase-admin@13', + variant: overrides.variant ?? 'v4', + schemaNamePattern: overrides.schemaNamePattern ?? '{modelName}Schema', + emitInferredTypes: overrides.emitInferredTypes ?? false, + inferredTypeNamePattern: overrides.inferredTypeNamePattern ?? '{modelName}', + }); +} + +describe('ZodGeneratorImpl', () => { + it('produces an empty generation for an empty schema and reports no Firestore-typed fields', () => { + const generation = createGenerator().generate(schema.createSchema()); + expect(generation).toEqual({ + type: 'zod', + declarations: [], + usesTimestamp: false, + usesBytes: false, + }); + }); + + it('emits one declaration per alias and document model with the right schemaName/modelKind', () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string' }, + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'Username' } } }, + }, + }); + + const generation = createGenerator().generate(s); + expect(generation.declarations).toHaveLength(2); + + const username = generation.declarations.find(d => d.modelName === 'Username'); + expect(username).toMatchObject({ + type: 'schema', + schemaName: 'UsernameSchema', + inferredTypeName: null, + modelKind: 'alias', + modelDocs: null, + }); + + const user = generation.declarations.find(d => d.modelName === 'User'); + expect(user).toMatchObject({ + schemaName: 'UserSchema', + inferredTypeName: null, + modelKind: 'document', + }); + }); + + describe('inferred types', () => { + it('leaves inferredTypeName null for every declaration when emitInferredTypes is false', () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string' }, + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'Username' } } }, + }, + }); + + const generation = createGenerator({ emitInferredTypes: false }).generate(s); + generation.declarations.forEach(d => { + expect(d.inferredTypeName).toBeNull(); + }); + }); + + it('populates inferredTypeName using the default pattern when emitInferredTypes is true', () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string' }, + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'Username' } } }, + }, + }); + + const generation = createGenerator({ emitInferredTypes: true }).generate(s); + + expect(generation.declarations.find(d => d.modelName === 'Username')?.inferredTypeName).toBe('Username'); + expect(generation.declarations.find(d => d.modelName === 'User')?.inferredTypeName).toBe('User'); + }); + + it('honours a custom inferredTypeNamePattern', () => { + const s = schema.createSchemaFromDefinition({ + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'string' } } }, + }, + }); + + const generation = createGenerator({ + emitInferredTypes: true, + inferredTypeNamePattern: 'I{modelName}', + }).generate(s); + + expect(generation.declarations.find(d => d.modelName === 'User')?.inferredTypeName).toBe('IUser'); + }); + }); + + it('honours a custom schemaNamePattern when generating identifiers and reference expressions', () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string' }, + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'Username' } } }, + }, + }); + + const generation = createGenerator({ schemaNamePattern: 'z{modelName}' }).generate(s); + const userDecl = generation.declarations.find(d => d.modelName === 'User'); + expect(userDecl?.schemaName).toBe('zUser'); + // Reference into Username should use the same pattern + expect(userDecl?.expression).toContain('z.lazy(() => zUsername)'); + }); + + it('appends .describe(...) to the right-hand expression when the model has docs', () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string', docs: 'A unique handle.' }, + Plain: { model: 'alias', type: 'string' }, + }); + + const generation = createGenerator().generate(s); + const documented = generation.declarations.find(d => d.modelName === 'Username'); + const undocumented = generation.declarations.find(d => d.modelName === 'Plain'); + + expect(documented?.expression).toBe(`z.string().describe("A unique handle.")`); + expect(undocumented?.expression).toBe('z.string()'); + }); + + it('threads field-level docs through as .describe(...) on the field value', () => { + const s = schema.createSchemaFromDefinition({ + Profile: { + model: 'alias', + type: { + type: 'object', + fields: { + id: { type: 'string', docs: 'The profile ID' }, + bio: { type: 'string', optional: true }, + }, + }, + }, + }); + + const generation = createGenerator({ variant: 'v4' }).generate(s); + const profile = generation.declarations.find(d => d.modelName === 'Profile'); + expect(profile?.expression).toContain(`id: z.string().describe("The profile ID")`); + expect(profile?.expression).toContain(`bio: z.string().optional()`); + }); + + it('reports usesTimestamp/usesBytes accurately by walking every model type', () => { + const sBoth = schema.createSchemaFromDefinition({ + Stamp: { model: 'alias', type: 'timestamp' }, + Blob: { model: 'alias', type: 'bytes' }, + }); + const gBoth = createGenerator().generate(sBoth); + expect(gBoth.usesTimestamp).toBe(true); + expect(gBoth.usesBytes).toBe(true); + + const sNone = schema.createSchemaFromDefinition({ Name: { model: 'alias', type: 'string' } }); + const gNone = createGenerator().generate(sNone); + expect(gNone.usesTimestamp).toBe(false); + expect(gNone.usesBytes).toBe(false); + }); + + it('detects bytes/timestamp usage even when nested inside lists/maps/objects', () => { + const s = schema.createSchemaFromDefinition({ + Doc: { + model: 'document', + path: 'docs/{docId}', + type: { + type: 'object', + fields: { + createdAt: { type: 'timestamp' }, + attachments: { type: { type: 'list', elementType: 'bytes' } }, + }, + }, + }, + }); + const generation = createGenerator().generate(s); + expect(generation.usesTimestamp).toBe(true); + expect(generation.usesBytes).toBe(true); + }); + + it('does not mutate the input schema', () => { + const s = schema.createSchemaFromDefinition({ + Profile: { + model: 'document', + path: 'profiles/{profileId}', + type: { type: 'object', fields: { name: { type: 'string' } } }, + }, + }); + const snapshot = s.clone(); + createGenerator().generate(s); + expect(s.aliasModels).toEqual(snapshot.aliasModels); + expect(s.documentModels).toEqual(snapshot.documentModels); + }); + + it('emits matching expressions for both v3 and v4 except for object factory and record key', () => { + const s = schema.createSchemaFromDefinition({ + Lookup: { model: 'alias', type: { type: 'map', valueType: 'int' } }, + Box: { + model: 'alias', + type: { type: 'object', fields: { id: { type: 'string' } }, additionalFields: true }, + }, + }); + const v3 = createGenerator({ variant: 'v3' }).generate(s); + const v4 = createGenerator({ variant: 'v4' }).generate(s); + + const lookupV3 = v3.declarations.find(d => d.modelName === 'Lookup')?.expression; + const lookupV4 = v4.declarations.find(d => d.modelName === 'Lookup')?.expression; + expect(lookupV3).toBe('z.record(z.number().int())'); + expect(lookupV4).toBe('z.record(z.string(), z.number().int())'); + + const boxV3 = v3.declarations.find(d => d.modelName === 'Box')?.expression; + const boxV4 = v4.declarations.find(d => d.modelName === 'Box')?.expression; + expect(boxV3).toContain(`.passthrough()`); + expect(boxV4?.startsWith('z.looseObject(')).toBe(true); + }); +}); diff --git a/src/generators/zod/_impl.ts b/src/generators/zod/_impl.ts new file mode 100644 index 0000000..bf3f535 --- /dev/null +++ b/src/generators/zod/_impl.ts @@ -0,0 +1,75 @@ +import { ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, ZOD_SCHEMA_NAME_PATTERN_PARAM } from '../../constants.js'; +import { buildZodFromType, createCodegenZodEmitter } from '../../core/zod/index.js'; +import { schema } from '../../schema/index.js'; +import { typeUsesBytes, typeUsesTimestamp } from './_type-traversal.js'; +import type { ZodGeneration, ZodGenerator, ZodGeneratorConfig, ZodSchemaDeclaration } from './_types.js'; + +class ZodGeneratorImpl implements ZodGenerator { + public constructor(private readonly config: ZodGeneratorConfig) {} + + public generate(s: schema.Schema): ZodGeneration { + const emitter = createCodegenZodEmitter({ + variant: this.config.variant, + target: this.config.target, + getSchemaIdentifierForModel: name => this.getSchemaIdentifierForModel(name), + }); + + const declarations: ZodSchemaDeclaration[] = []; + + s.aliasModels.forEach(model => { + declarations.push(this.createDeclaration(model, 'alias', emitter)); + }); + + s.documentModels.forEach(model => { + declarations.push(this.createDeclaration(model, 'document', emitter)); + }); + + const usesTimestamp = this.schemaUses(s, typeUsesTimestamp); + const usesBytes = this.schemaUses(s, typeUsesBytes); + + return { + type: 'zod', + declarations, + usesTimestamp, + usesBytes, + }; + } + + private createDeclaration( + model: { name: string; docs: string | null; type: schema.types.Type }, + modelKind: 'alias' | 'document', + emitter: ReturnType + ): ZodSchemaDeclaration { + const expression = this.attachModelDocs(buildZodFromType(model.type, emitter), model.docs); + return { + type: 'schema', + modelName: model.name, + schemaName: this.getSchemaIdentifierForModel(model.name), + inferredTypeName: this.config.emitInferredTypes ? this.getInferredTypeIdentifierForModel(model.name) : null, + modelDocs: model.docs, + expression, + modelKind, + }; + } + + private attachModelDocs(expression: string, docs: string | null): string { + if (docs === null || docs.length === 0) return expression; + return `${expression}.describe(${JSON.stringify(docs)})`; + } + + private getSchemaIdentifierForModel(modelName: string): string { + return this.config.schemaNamePattern.replace(ZOD_SCHEMA_NAME_PATTERN_PARAM, modelName); + } + + private getInferredTypeIdentifierForModel(modelName: string): string { + return this.config.inferredTypeNamePattern.replace(ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, modelName); + } + + private schemaUses(s: schema.Schema, predicate: (t: schema.types.Type) => boolean): boolean { + return s.aliasModels.some(m => predicate(m.type)) || s.documentModels.some(m => predicate(m.type)); + } +} + +export function createZodGenerator(config: ZodGeneratorConfig): ZodGenerator { + return new ZodGeneratorImpl(config); +} diff --git a/src/generators/zod/_type-traversal.ts b/src/generators/zod/_type-traversal.ts new file mode 100644 index 0000000..80c6f3f --- /dev/null +++ b/src/generators/zod/_type-traversal.ts @@ -0,0 +1,57 @@ +import { schema } from '../../schema/index.js'; +import { assertNever } from '../../util/assert.js'; + +/** + * Recursively checks whether the given Typesync type tree contains a node of + * the specified primitive `kind`. Used by the Zod generator to decide whether + * the rendered file needs to import the Firestore SDK at all. + * + * Alias references are intentionally not followed: each alias model's own type + * is walked separately by the caller, so any `timestamp`/`bytes` usage is + * eventually visited at its definition site regardless of how many references + * point at it. + */ +function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes'): boolean { + switch (type.type) { + case 'any': + case 'unknown': + case 'nil': + case 'string': + case 'boolean': + case 'int': + case 'double': + case 'string-literal': + case 'int-literal': + case 'boolean-literal': + case 'string-enum': + case 'int-enum': + case 'alias': + return false; + case 'timestamp': + return kind === 'timestamp'; + case 'bytes': + return kind === 'bytes'; + case 'tuple': + return type.elements.some(el => typeContains(el, kind)); + case 'list': + return typeContains(type.elementType, kind); + case 'map': + return typeContains(type.valueType, kind); + case 'object': + return type.fields.some(f => typeContains(f.type, kind)); + case 'simple-union': + return type.variants.some(v => typeContains(v, kind)); + case 'discriminated-union': + return type.variants.some(v => typeContains(v, kind)); + default: + assertNever(type); + } +} + +export function typeUsesTimestamp(type: schema.types.Type): boolean { + return typeContains(type, 'timestamp'); +} + +export function typeUsesBytes(type: schema.types.Type): boolean { + return typeContains(type, 'bytes'); +} diff --git a/src/generators/zod/_types.ts b/src/generators/zod/_types.ts new file mode 100644 index 0000000..2095884 --- /dev/null +++ b/src/generators/zod/_types.ts @@ -0,0 +1,59 @@ +import type { TSGenerationTarget, ZodVariant } from '../../api/index.js'; +import type { schema } from '../../schema/index.js'; + +export interface ZodSchemaDeclaration { + type: 'schema'; + /** Original Typesync model name (e.g. `User`). */ + modelName: string; + /** + * Identifier under which the schema is exported in the generated file + * (e.g. `UserSchema`). Derived from the schema name pattern. + */ + schemaName: string; + /** + * Identifier under which the inferred TypeScript type is exported, if any + * (e.g. `User`). `null` when `emitInferredTypes` is disabled — in that case + * the renderer skips the inferred-type export entirely. + */ + inferredTypeName: string | null; + /** Documentation attached to the model in the source schema, if any. */ + modelDocs: string | null; + /** + * Pre-emitted Zod source code for the right-hand side of the export, e.g. + * `z.strictObject({ ... })` or `z.string().describe('A user name')`. Already + * includes any `.describe(...)` for the model-level docs. + */ + expression: string; + /** Whether the underlying model is an alias model or a document model. */ + modelKind: 'alias' | 'document'; +} + +export type ZodDeclaration = ZodSchemaDeclaration; + +export interface ZodGeneration { + type: 'zod'; + declarations: ZodDeclaration[]; + /** + * Whether the generated file references the Firestore `Timestamp` class + * anywhere. The renderer uses this to decide whether to emit the Firestore + * SDK import. + */ + usesTimestamp: boolean; + /** + * Whether the generated file references the Firestore bytes class anywhere + * (`Buffer` for the admin SDK; `firestore.Bytes`/`firestore.Blob` otherwise). + */ + usesBytes: boolean; +} + +export interface ZodGeneratorConfig { + target: TSGenerationTarget; + variant: ZodVariant; + schemaNamePattern: string; + emitInferredTypes: boolean; + inferredTypeNamePattern: string; +} + +export interface ZodGenerator { + generate(s: schema.Schema): ZodGeneration; +} diff --git a/src/generators/zod/index.ts b/src/generators/zod/index.ts new file mode 100644 index 0000000..32818b6 --- /dev/null +++ b/src/generators/zod/index.ts @@ -0,0 +1,2 @@ +export { createZodGenerator } from './_impl.js'; +export type * from './_types.js'; diff --git a/src/renderers/_namespace.ts b/src/renderers/_namespace.ts index afec54b..e07dc19 100644 --- a/src/renderers/_namespace.ts +++ b/src/renderers/_namespace.ts @@ -3,4 +3,5 @@ export * from './python/index.js'; export * from './rules/index.js'; export * from './ts/index.js'; export * from './swift/index.js'; +export * from './zod/index.js'; export type * from './_types.js'; diff --git a/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-inferred-types.ts b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-inferred-types.ts new file mode 100644 index 0000000..3deb8d6 --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-inferred-types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +/** Unique handle. */ +export const UsernameSchema = z.string().describe('Unique handle.'); +export type Username = z.infer; + +export const UserSchema = z.strictObject({ name: z.lazy(() => UsernameSchema), age: z.number().int().optional() }); +export type User = z.infer; diff --git a/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v3.ts b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v3.ts new file mode 100644 index 0000000..fbe16cb --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v3.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const LookupSchema = z.record(z.number().int()); + +export const LooseSchema = z.object({ id: z.string() }).passthrough(); diff --git a/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v4.ts b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v4.ts new file mode 100644 index 0000000..690a1dc --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/end-to-end-v4.ts @@ -0,0 +1,29 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod'; + +/** A user-interaction event. */ +export const EventSchema = z + .discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('click'), x: z.number().int() }), + z.strictObject({ kind: z.literal('scroll'), dy: z.number().int() }), + ]) + .describe('A user-interaction event.'); + +export const RoleSchema = z.enum(['admin', 'user']); + +export const TagSchema = z.array(z.string()); + +/** Unique user handle. */ +export const UsernameSchema = z.string().describe('Unique user handle.'); + +/** A user document. */ +export const UserSchema = z + .strictObject({ + username: z.lazy(() => UsernameSchema).describe("The user's chosen handle."), + role: z.lazy(() => RoleSchema), + tags: z.lazy(() => TagSchema).optional(), + createdAt: z.instanceof(firestore.Timestamp).describe('When the user signed up.'), + avatar: z.instanceof(Buffer).optional(), + bio: z.record(z.string(), z.string()).optional(), + }) + .describe('A user document.'); diff --git a/src/renderers/zod/__tests__/__file_snapshots__/indentation-4.ts b/src/renderers/zod/__tests__/__file_snapshots__/indentation-4.ts new file mode 100644 index 0000000..ffe4973 --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/indentation-4.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const ProfileSchema = z.strictObject({ + id: z.string().describe('The profile ID'), + bio: z.string().optional().describe('A short bio about the user'), + avatarUrl: z.string().optional(), +}); diff --git a/src/renderers/zod/__tests__/__file_snapshots__/v4-with-docs.ts b/src/renderers/zod/__tests__/__file_snapshots__/v4-with-docs.ts new file mode 100644 index 0000000..c5caa09 --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/v4-with-docs.ts @@ -0,0 +1,13 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod'; + +/** A unique handle. */ +export const UsernameSchema = z.string().describe('A unique handle.'); + +/** A user document. */ +export const UserSchema = z + .strictObject({ + name: z.lazy(() => UsernameSchema).describe('The user name'), + createdAt: z.instanceof(firestore.Timestamp), + }) + .describe('A user document.'); diff --git a/src/renderers/zod/__tests__/__file_snapshots__/with-inferred-types.ts b/src/renderers/zod/__tests__/__file_snapshots__/with-inferred-types.ts new file mode 100644 index 0000000..5930f41 --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/with-inferred-types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +/** A unique handle. */ +export const UsernameSchema = z.string().describe('A unique handle.'); +export type Username = z.infer; + +export const UserSchema = z.strictObject({ name: z.lazy(() => UsernameSchema) }); +export type User = z.infer; diff --git a/src/renderers/zod/__tests__/end-to-end.test.ts b/src/renderers/zod/__tests__/end-to-end.test.ts new file mode 100644 index 0000000..8caca55 --- /dev/null +++ b/src/renderers/zod/__tests__/end-to-end.test.ts @@ -0,0 +1,118 @@ +import type { TSGenerationTarget, ZodVariant } from '../../../api/index.js'; +import { createZodGenerator } from '../../../generators/zod/index.js'; +import { schema } from '../../../schema/index.js'; +import { createZodRenderer } from '../_impl.js'; + +async function generateAndRender( + s: schema.Schema, + config: { + target?: TSGenerationTarget; + variant?: ZodVariant; + indentation?: number; + emitInferredTypes?: boolean; + inferredTypeNamePattern?: string; + } = {} +) { + const target = config.target ?? 'firebase-admin@13'; + const variant = config.variant ?? 'v4'; + const indentation = config.indentation ?? 2; + const generation = createZodGenerator({ + target, + variant, + schemaNamePattern: '{modelName}Schema', + emitInferredTypes: config.emitInferredTypes ?? false, + inferredTypeNamePattern: config.inferredTypeNamePattern ?? '{modelName}', + }).generate(s); + const file = await createZodRenderer({ target, variant, indentation }).render(generation); + return file.content; +} + +describe('Zod generator + renderer end-to-end', () => { + it('produces a representative v4 file from a real schema', async () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string', docs: 'Unique user handle.' }, + Role: { + model: 'alias', + type: { + type: 'enum', + members: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + }, + Tag: { model: 'alias', type: { type: 'list', elementType: 'string' } }, + Event: { + model: 'alias', + type: { + type: 'union', + discriminant: 'kind', + variants: [ + { + type: 'object', + fields: { + kind: { type: { type: 'literal', value: 'click' } }, + x: { type: 'int' }, + }, + }, + { + type: 'object', + fields: { + kind: { type: { type: 'literal', value: 'scroll' } }, + dy: { type: 'int' }, + }, + }, + ], + }, + docs: 'A user-interaction event.', + }, + User: { + model: 'document', + path: 'users/{userId}', + docs: 'A user document.', + type: { + type: 'object', + additionalFields: false, + fields: { + username: { type: 'Username', docs: "The user's chosen handle." }, + role: { type: 'Role' }, + tags: { type: 'Tag', optional: true }, + createdAt: { type: 'timestamp', docs: 'When the user signed up.' }, + avatar: { type: 'bytes', optional: true }, + bio: { type: { type: 'map', valueType: 'string' }, optional: true }, + }, + }, + }, + }); + + const content = await generateAndRender(s); + await expect(content).toMatchFileSnapshot('./__file_snapshots__/end-to-end-v4.ts'); + }); + + it('produces a v3 file with .strict()/.passthrough() and the v3 z.record signature', async () => { + const s = schema.createSchemaFromDefinition({ + Lookup: { model: 'alias', type: { type: 'map', valueType: 'int' } }, + Loose: { + model: 'alias', + type: { type: 'object', additionalFields: true, fields: { id: { type: 'string' } } }, + }, + }); + + const content = await generateAndRender(s, { variant: 'v3' }); + await expect(content).toMatchFileSnapshot('./__file_snapshots__/end-to-end-v3.ts'); + }); + + it('emits `export type X = z.infer;` after each schema when emitInferredTypes is true', async () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string', docs: 'Unique handle.' }, + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'Username' }, age: { type: 'int', optional: true } } }, + }, + }); + + const content = await generateAndRender(s, { emitInferredTypes: true }); + await expect(content).toMatchFileSnapshot('./__file_snapshots__/end-to-end-inferred-types.ts'); + }); +}); diff --git a/src/renderers/zod/__tests__/renderer.test.ts b/src/renderers/zod/__tests__/renderer.test.ts new file mode 100644 index 0000000..3866c5f --- /dev/null +++ b/src/renderers/zod/__tests__/renderer.test.ts @@ -0,0 +1,180 @@ +import type { TSGenerationTarget, ZodVariant } from '../../../api/index.js'; +import type { ZodGeneration } from '../../../generators/zod/index.js'; +import { createZodRenderer } from '../_impl.js'; + +function createRenderer(overrides: { indentation?: number; target?: TSGenerationTarget; variant?: ZodVariant } = {}) { + return createZodRenderer({ + indentation: overrides.indentation ?? 2, + target: overrides.target ?? 'firebase-admin@13', + variant: overrides.variant ?? 'v4', + }); +} + +describe('ZodRendererImpl', () => { + it('renders a v4 schema file with Firestore SDK + zod imports, model docs, and field-level describe()', async () => { + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: true, + usesBytes: false, + declarations: [ + { + type: 'schema', + modelName: 'Username', + schemaName: 'UsernameSchema', + inferredTypeName: null, + modelDocs: 'A unique handle.', + modelKind: 'alias', + expression: `z.string().describe("A unique handle.")`, + }, + { + type: 'schema', + modelName: 'User', + schemaName: 'UserSchema', + inferredTypeName: null, + modelDocs: 'A user document.', + modelKind: 'document', + expression: `z.strictObject({ name: z.lazy(() => UsernameSchema).describe("The user name"), createdAt: z.instanceof(firestore.Timestamp) }).describe("A user document.")`, + }, + ], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/v4-with-docs.ts'); + }); + + it('emits an inferred TS type after each schema export when inferredTypeName is set', async () => { + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: false, + usesBytes: false, + declarations: [ + { + type: 'schema', + modelName: 'Username', + schemaName: 'UsernameSchema', + inferredTypeName: 'Username', + modelDocs: 'A unique handle.', + modelKind: 'alias', + expression: `z.string().describe("A unique handle.")`, + }, + { + type: 'schema', + modelName: 'User', + schemaName: 'UserSchema', + inferredTypeName: 'User', + modelDocs: null, + modelKind: 'document', + expression: `z.strictObject({ name: z.lazy(() => UsernameSchema) })`, + }, + ], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/with-inferred-types.ts'); + }); + + it('omits the Firestore SDK import when no model uses timestamp or bytes', async () => { + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: false, + usesBytes: false, + declarations: [ + { + type: 'schema', + modelName: 'Username', + schemaName: 'UsernameSchema', + inferredTypeName: null, + modelDocs: null, + modelKind: 'alias', + expression: 'z.string()', + }, + ], + }; + + const result = await createRenderer().render(generation); + expect(result.content).not.toContain('firestore'); + expect(result.content).toContain(`import { z } from 'zod';`); + }); + + it('skips the Firestore SDK import for the admin target when only bytes are used (Buffer is a Node global)', async () => { + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: false, + usesBytes: true, + declarations: [ + { + type: 'schema', + modelName: 'Avatar', + schemaName: 'AvatarSchema', + inferredTypeName: null, + modelDocs: null, + modelKind: 'alias', + expression: 'z.instanceof(Buffer)', + }, + ], + }; + + const result = await createRenderer({ target: 'firebase-admin@13' }).render(generation); + expect(result.content).not.toContain(`firebase-admin/firestore`); + }); + + it('emits the right Firestore SDK import for each target family', async () => { + const targetsByExpectedImport: Record = { + [`import * as firestore from 'firebase-admin/firestore';`]: ['firebase-admin@13', 'firebase-admin@12'], + [`import { firestore } from 'firebase-admin';`]: ['firebase-admin@11', 'firebase-admin@10'], + [`import * as firestore from 'firebase/firestore';`]: ['firebase@11', 'firebase@10', 'firebase@9'], + [`import * as firestore from '@react-native-firebase/firestore';`]: [ + 'react-native-firebase@21', + 'react-native-firebase@20', + 'react-native-firebase@19', + ], + }; + + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: true, + usesBytes: false, + declarations: [ + { + type: 'schema', + modelName: 'Stamp', + schemaName: 'StampSchema', + inferredTypeName: null, + modelDocs: null, + modelKind: 'alias', + expression: 'z.instanceof(firestore.Timestamp)', + }, + ], + }; + + for (const [expectedImport, targets] of Object.entries(targetsByExpectedImport)) { + for (const target of targets) { + const result = await createRenderer({ target }).render(generation); + expect(result.content.split('\n')[0]).toBe(expectedImport); + } + } + }); + + it('respects the configured indentation', async () => { + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: false, + usesBytes: false, + declarations: [ + { + type: 'schema', + modelName: 'Profile', + schemaName: 'ProfileSchema', + inferredTypeName: null, + modelDocs: null, + modelKind: 'alias', + // Long expression to force prettier to wrap it across multiple lines. + expression: `z.strictObject({ id: z.string().describe("The profile ID"), bio: z.string().optional().describe("A short bio about the user"), avatarUrl: z.string().optional() })`, + }, + ], + }; + + const result = await createRenderer({ indentation: 4 }).render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/indentation-4.ts'); + }); +}); diff --git a/src/renderers/zod/__tests__/runtime-roundtrip.test.ts b/src/renderers/zod/__tests__/runtime-roundtrip.test.ts new file mode 100644 index 0000000..b8a237f --- /dev/null +++ b/src/renderers/zod/__tests__/runtime-roundtrip.test.ts @@ -0,0 +1,122 @@ +import { Timestamp } from 'firebase-admin/firestore'; +import { z } from 'zod'; + +import { createZodGenerator } from '../../../generators/zod/index.js'; +import { schema } from '../../../schema/index.js'; +import { createZodRenderer } from '../_impl.js'; + +/** + * Loads the rendered Zod source as actual JavaScript and returns the named + * schema export. We strip the import statements (we already have `z` and + * `firestore` in scope) and evaluate the rest in a Function so that the + * generated module has access to whatever bindings we provide. This catches + * regressions where the rendered source is well-formed text but doesn't + * actually compose into a valid Zod schema at runtime. + */ +function loadGeneratedSchema(source: string, schemaName: string): z.ZodTypeAny { + // The generator may emit `import` statements (we already have `z` and + // `firestore` in scope) and TypeScript-only `export type` aliases (not valid + // JS). Strip both so the rest can be eval'd as plain JS. + const stripped = source + .split('\n') + .filter(line => !line.startsWith('import ') && !line.startsWith('export type ')) + .join('\n') + .replace(/^export const /gm, 'const '); + + const factory = new Function('z', 'firestore', 'Buffer', `${stripped}\nreturn ${schemaName};`) as ( + zArg: unknown, + firestoreArg: unknown, + BufferArg: unknown + ) => z.ZodTypeAny; + + return factory(z, { Timestamp }, Buffer); +} + +describe('runtime round-trip of generated Zod source', () => { + it('the v4 source parses valid documents and rejects invalid ones', async () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string' }, + User: { + model: 'document', + path: 'users/{userId}', + type: { + type: 'object', + fields: { + username: { type: 'Username' }, + createdAt: { type: 'timestamp' }, + age: { type: 'int', optional: true }, + }, + }, + }, + }); + + const generation = createZodGenerator({ + target: 'firebase-admin@13', + variant: 'v4', + schemaNamePattern: '{modelName}Schema', + emitInferredTypes: false, + inferredTypeNamePattern: '{modelName}', + }).generate(s); + const file = await createZodRenderer({ + target: 'firebase-admin@13', + variant: 'v4', + indentation: 2, + }).render(generation); + + const UserSchema = loadGeneratedSchema(file.content, 'UserSchema'); + const ts = new Timestamp(1_700_000_000, 0); + + expect(UserSchema.safeParse({ username: 'alice', createdAt: ts }).success).toBe(true); + expect(UserSchema.safeParse({ username: 'alice', createdAt: ts, age: 30 }).success).toBe(true); + expect(UserSchema.safeParse({ username: 'alice', createdAt: ts, extra: 'oops' }).success).toBe(false); + expect(UserSchema.safeParse({ username: 1, createdAt: ts }).success).toBe(false); + expect(UserSchema.safeParse({ username: 'alice' }).success).toBe(false); + }); + + it('the v4 source preserves model docs in the schema description metadata', async () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string', docs: 'Unique handle.' }, + }); + const generation = createZodGenerator({ + target: 'firebase-admin@13', + variant: 'v4', + schemaNamePattern: '{modelName}Schema', + emitInferredTypes: false, + inferredTypeNamePattern: '{modelName}', + }).generate(s); + const file = await createZodRenderer({ + target: 'firebase-admin@13', + variant: 'v4', + indentation: 2, + }).render(generation); + + const UsernameSchema = loadGeneratedSchema(file.content, 'UsernameSchema'); + expect(UsernameSchema.description).toBe('Unique handle.'); + }); + + it('emits a `type` export that strips at runtime but keeps the schema parseable when emitInferredTypes is true', async () => { + const s = schema.createSchemaFromDefinition({ + Username: { model: 'alias', type: 'string' }, + }); + const generation = createZodGenerator({ + target: 'firebase-admin@13', + variant: 'v4', + schemaNamePattern: '{modelName}Schema', + emitInferredTypes: true, + inferredTypeNamePattern: '{modelName}', + }).generate(s); + const file = await createZodRenderer({ + target: 'firebase-admin@13', + variant: 'v4', + indentation: 2, + }).render(generation); + + // Sanity check: the rendered source contains both the schema const and the type alias. + expect(file.content).toContain('export const UsernameSchema ='); + expect(file.content).toContain('export type Username = z.infer;'); + + const UsernameSchema = loadGeneratedSchema(file.content, 'UsernameSchema'); + expect(UsernameSchema.safeParse('alice').success).toBe(true); + expect(UsernameSchema.safeParse(42).success).toBe(false); + }); +}); diff --git a/src/renderers/zod/_impl.ts b/src/renderers/zod/_impl.ts new file mode 100644 index 0000000..b9d34c8 --- /dev/null +++ b/src/renderers/zod/_impl.ts @@ -0,0 +1,120 @@ +import { StringBuilder } from '@proficient/ds'; +import { format } from 'prettier'; + +import type { TSGenerationTarget } from '../../api/index.js'; +import type { ZodGeneration, ZodSchemaDeclaration } from '../../generators/zod/index.js'; +import { assertNever } from '../../util/assert.js'; +import type { RenderedFile } from '../_types.js'; +import type { ZodRenderer, ZodRendererConfig } from './_types.js'; + +class ZodRendererImpl implements ZodRenderer { + public constructor(private readonly config: ZodRendererConfig) {} + + public async render(g: ZodGeneration): Promise { + const b = new StringBuilder(); + + const imports = this.renderImports(g); + if (imports.length > 0) { + b.append(`${imports}\n\n`); + } + + g.declarations.forEach(declaration => { + b.append(`${this.renderDeclaration(declaration)}\n\n`); + }); + + const content = b.toString(); + const formattedContent = await format(content, { + parser: 'typescript', + tabWidth: this.config.indentation, + trailingComma: 'es5', + printWidth: 120, + singleQuote: true, + }); + + return { content: formattedContent }; + } + + private renderDeclaration(declaration: ZodSchemaDeclaration): string { + let output = ''; + if (declaration.modelDocs !== null) { + output += `/** ${declaration.modelDocs} */\n`; + } + output += `export const ${declaration.schemaName} = ${declaration.expression};`; + if (declaration.inferredTypeName !== null) { + output += `\nexport type ${declaration.inferredTypeName} = z.infer;`; + } + return output; + } + + private renderImports(g: ZodGeneration): string { + const lines: string[] = []; + + const firestoreImport = this.getFirestoreImportStatement(g); + if (firestoreImport !== null) { + lines.push(firestoreImport); + } + + lines.push(`import { z } from 'zod';`); + return lines.join('\n'); + } + + /** + * Computes the Firestore SDK import (if any) needed by the rendered file. We + * intentionally key off the generation's `usesTimestamp` / `usesBytes` flags + * rather than scanning the rendered string so that the renderer stays in lock + * step with the generator's intent and emits no dead imports. + */ + private getFirestoreImportStatement(g: ZodGeneration): string | null { + const needsTimestampImport = g.usesTimestamp; + const needsBytesImport = g.usesBytes && bytesRequiresFirestoreImport(this.config.target); + + if (!needsTimestampImport && !needsBytesImport) return null; + + return getFirestoreImportForTarget(this.config.target); + } +} + +function bytesRequiresFirestoreImport(target: TSGenerationTarget): boolean { + switch (target) { + case 'firebase-admin@13': + case 'firebase-admin@12': + case 'firebase-admin@11': + case 'firebase-admin@10': + // Bytes use the global Node `Buffer` on admin, so no SDK import is needed. + return false; + case 'firebase@11': + case 'firebase@10': + case 'firebase@9': + case 'react-native-firebase@21': + case 'react-native-firebase@20': + case 'react-native-firebase@19': + return true; + default: + assertNever(target); + } +} + +function getFirestoreImportForTarget(target: TSGenerationTarget): string { + switch (target) { + case 'firebase-admin@13': + case 'firebase-admin@12': + return `import * as firestore from 'firebase-admin/firestore';`; + case 'firebase-admin@11': + case 'firebase-admin@10': + return `import { firestore } from 'firebase-admin';`; + case 'firebase@11': + case 'firebase@10': + case 'firebase@9': + return `import * as firestore from 'firebase/firestore';`; + case 'react-native-firebase@21': + case 'react-native-firebase@20': + case 'react-native-firebase@19': + return `import * as firestore from '@react-native-firebase/firestore';`; + default: + assertNever(target); + } +} + +export function createZodRenderer(config: ZodRendererConfig): ZodRenderer { + return new ZodRendererImpl(config); +} diff --git a/src/renderers/zod/_namespace.ts b/src/renderers/zod/_namespace.ts new file mode 100644 index 0000000..12296d1 --- /dev/null +++ b/src/renderers/zod/_namespace.ts @@ -0,0 +1,2 @@ +export * from './_impl.js'; +export type * from './_types.js'; diff --git a/src/renderers/zod/_types.ts b/src/renderers/zod/_types.ts new file mode 100644 index 0000000..c3d5778 --- /dev/null +++ b/src/renderers/zod/_types.ts @@ -0,0 +1,13 @@ +import type { TSGenerationTarget, ZodVariant } from '../../api/index.js'; +import type { ZodGeneration } from '../../generators/zod/index.js'; +import type { RenderedFile } from '../_types.js'; + +export interface ZodRendererConfig { + target: TSGenerationTarget; + variant: ZodVariant; + indentation: number; +} + +export interface ZodRenderer { + render(g: ZodGeneration): Promise; +} diff --git a/src/renderers/zod/index.ts b/src/renderers/zod/index.ts new file mode 100644 index 0000000..6b1def7 --- /dev/null +++ b/src/renderers/zod/index.ts @@ -0,0 +1 @@ +export * from './_namespace.js'; diff --git a/tests/integration/zod/README.md b/tests/integration/zod/README.md new file mode 100644 index 0000000..1af8bf0 --- /dev/null +++ b/tests/integration/zod/README.md @@ -0,0 +1,70 @@ +# Zod integration suite + +Integration tests for `typesync generate-zod`. Verifies that the generated Zod schemas: + +1. **Compile cleanly** under both Zod v3 and v4 with strict TypeScript (`tsc --noEmit`). +2. **Parse the right wire shapes** in pure-Zod tests (no emulator) — both positive and negative cases for primitives, + enums, lists, records, nested objects, optionals, additional fields, simple unions, and discriminated unions. +3. **Round-trip through the Firestore emulator** when the underlying types involve Firestore-native values + (`Timestamp`, `Bytes`/`Buffer`). Each generated schema is exercised against the same emulator-backed document the + `generate-ts` integration suite uses, so we know the schema accepts the actual wire output of the SDK rather than + what we *think* the wire shape is. + +## Layout + +``` +tests/integration/zod/ + _fixtures/schemas/ Zod-only fixtures (e.g. discriminated unions) that + other generators don't have round-trip coverage for. + generated/ + v3/.ts Schemas emitted with --variant v3 (admin SDK). + v4/.ts Schemas emitted with --variant v4 (admin SDK). + v4-web/.ts Schemas emitted with --variant v4 (firebase web SDK). + Only emitted for the `secrets` fixture today. + tests/ + *.v3.test.ts Pure-Zod parsing tests for the v3 outputs. + *.v4.test.ts Pure-Zod parsing tests for the v4 outputs. + *.admin.test.ts Firestore emulator round-trips parsed with the + firebase-admin v4 schemas. + *.web.test.ts Same, for the firebase web SDK v4 schemas. +``` + +The shared fixtures (`_fixtures/schemas/users.yml`, `projects.yml`, `secrets.yml` at the parent level) are pulled in +automatically alongside the zod-only fixtures. + +## How v3 and v4 coexist in one Node project + +Zod v3 and Zod v4 are incompatible at the import level. To exercise both in one place we install them as separate +aliased packages (`zod-v3`, `zod-v4`) at the repo root, and the integration orchestrator +(`scripts/integration-test.ts`) rewrites every `from 'zod'` in the generated files to either `from 'zod-v3'` or +`from 'zod-v4'` depending on the subdir. The generated files thus look like: + +```ts +// generated/v3/users.ts +import { z } from 'zod-v3'; // ← rewritten by the orchestrator +// ... + +// generated/v4/users.ts +import { z } from 'zod-v4'; // ← rewritten by the orchestrator +// ... +``` + +The post-processing is integration-only — every other consumer (`yarn build`, the published CLI, etc.) emits the +canonical `from 'zod'` form. + +## Running + +From the repo root: + +```bash +yarn test:integration:zod +``` + +That script: + +1. Generates v3, v4, and v4-web Zod schemas for each fixture under + `tests/integration/_fixtures/schemas/` and `tests/integration/zod/_fixtures/schemas/`. +2. Rewrites the `zod` import in each generated file to its versioned alias. +3. Runs `tsc --noEmit` against the suite's tsconfig. +4. Boots the Firestore emulator via `firebase emulators:exec`. +5. Runs `vitest` against every test under `tests/`. diff --git a/tests/integration/zod/_fixtures/samples/catalog/ebook-digital.json b/tests/integration/zod/_fixtures/samples/catalog/ebook-digital.json new file mode 100644 index 0000000..e05db35 --- /dev/null +++ b/tests/integration/zod/_fixtures/samples/catalog/ebook-digital.json @@ -0,0 +1,18 @@ +{ + "id": "prod_ebook_42", + "name": "Mastering Firestore", + "price": { + "amount_minor": 1299, + "currency": "EUR" + }, + "tags": ["ebook", "tech"], + "attributes": { + "language": "en" + }, + "details": { + "kind": "digital", + "download_url": "https://example.com/files/ebook-42.epub", + "file_size_bytes": 2345678 + }, + "created_at": "2024-08-02T08:00:00.000Z" +} diff --git a/tests/integration/zod/_fixtures/samples/catalog/widget-physical.json b/tests/integration/zod/_fixtures/samples/catalog/widget-physical.json new file mode 100644 index 0000000..4bc00c7 --- /dev/null +++ b/tests/integration/zod/_fixtures/samples/catalog/widget-physical.json @@ -0,0 +1,25 @@ +{ + "id": "prod_widget_001", + "name": "Industrial Widget", + "price": { + "amount_minor": 4999, + "currency": "USD" + }, + "rating": 4, + "tags": ["industrial", "imperial", "heavy-duty"], + "attributes": { + "color": "graphite", + "warranty_years": 5, + "fragile": false + }, + "details": { + "kind": "physical", + "weight_grams": 1500, + "inventory": [ + { "sku": "WID-A", "quantity": 12 }, + { "sku": "WID-B", "quantity": 4 } + ], + "is_featured": true + }, + "created_at": "2024-08-01T12:34:56.000Z" +} diff --git a/tests/integration/zod/_fixtures/schemas/catalog.yml b/tests/integration/zod/_fixtures/schemas/catalog.yml new file mode 100644 index 0000000..d37331c --- /dev/null +++ b/tests/integration/zod/_fixtures/schemas/catalog.yml @@ -0,0 +1,165 @@ +# yaml-language-server: $schema=../../../../../schema.local.json +# +# Zod-only integration fixture. Designed to exercise as much of the schema +# surface as is meaningful to validate at runtime through a Zod schema: +# +# * string + int enums +# * alias references (with z.lazy roundtrips) +# * discriminated unions over a literal field +# * simple unions (any-of without a tag) +# * lists of primitives, lists of aliases, lists of objects +# * records (maps) with primitive values +# * nested objects +# * optional fields +# * top-level `additionalFields: true` (passthrough / loose object) +# * literal value fields (string, int, boolean) +# +# This file lives under the Zod-only fixtures dir so that the Python / Swift / +# TypeScript integration suites do not have to add round-trip coverage for +# these shapes (they already cover the simpler shared fixtures under +# tests/integration/_fixtures/schemas/). + +ProductId: + model: alias + docs: Stable identifier for a catalog product. + type: string + +Currency: + model: alias + docs: ISO-4217 currency code. + type: + type: enum + members: + - { label: USD, value: USD } + - { label: EUR, value: EUR } + - { label: GBP, value: GBP } + +Money: + model: alias + docs: A monetary amount expressed in a given currency. + type: + type: object + fields: + amount_minor: + type: int + docs: Amount in the currency's minor unit (e.g. cents for USD). + currency: + type: Currency + +Rating: + model: alias + docs: Customer rating expressed as an integer 1..5. + type: + type: enum + members: + - { label: One, value: 1 } + - { label: Two, value: 2 } + - { label: Three, value: 3 } + - { label: Four, value: 4 } + - { label: Five, value: 5 } + +InventoryEntry: + model: alias + type: + type: object + fields: + sku: + type: string + quantity: + type: int + +Tag: + model: alias + type: string + +# Simple (non-discriminated) union: a per-product attribute can be any of a +# handful of primitives. Exercises the codegen for `z.union([...])`. +Attribute: + model: alias + type: + type: union + variants: + - string + - int + - boolean + +# Discriminated union — the centerpiece of this fixture. The discriminant is +# `kind` and the two variants share several fields by hand to mirror the +# canonical "polymorphic record" pattern users hit in practice. Lives as an +# alias because document models must be top-level objects. +ProductDetails: + model: alias + type: + type: union + discriminant: kind + variants: + - type: object + fields: + kind: + type: + type: literal + value: physical + weight_grams: + type: int + inventory: + type: + type: list + elementType: InventoryEntry + is_featured: + type: + type: literal + value: true + optional: true + - type: object + fields: + kind: + type: + type: literal + value: digital + download_url: + type: string + file_size_bytes: + type: int + +Product: + model: document + path: products/{productId} + docs: A product in the store catalog. + type: + type: object + fields: + id: + type: ProductId + name: + type: string + price: + type: Money + rating: + type: Rating + optional: true + tags: + type: + type: list + elementType: Tag + attributes: + type: + type: map + valueType: Attribute + details: + type: ProductDetails + created_at: + type: timestamp + +# A document with `additionalFields: true` so we exercise the v3 +# `.passthrough()` and v4 `z.looseObject(...)` codepaths under runtime data. +RawEvent: + model: document + path: raw_events/{eventId} + type: + type: object + additionalFields: true + fields: + type: + type: string + occurred_at: + type: timestamp diff --git a/tests/integration/zod/generated/v3/catalog.ts b/tests/integration/zod/generated/v3/catalog.ts new file mode 100644 index 0000000..ccf0f2d --- /dev/null +++ b/tests/integration/zod/generated/v3/catalog.ts @@ -0,0 +1,69 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v3'; + +export const AttributeSchema = z.union([z.string(), z.number().int(), z.boolean()]); +export type Attribute = z.infer; + +/** ISO-4217 currency code. */ +export const CurrencySchema = z.enum(['USD', 'EUR', 'GBP']).describe('ISO-4217 currency code.'); +export type Currency = z.infer; + +export const InventoryEntrySchema = z.object({ sku: z.string(), quantity: z.number().int() }).strict(); +export type InventoryEntry = z.infer; + +/** A monetary amount expressed in a given currency. */ +export const MoneySchema = z + .object({ + amount_minor: z.number().int().describe("Amount in the currency's minor unit (e.g. cents for USD)."), + currency: z.lazy(() => CurrencySchema), + }) + .strict() + .describe('A monetary amount expressed in a given currency.'); +export type Money = z.infer; + +export const ProductDetailsSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('physical'), + weight_grams: z.number().int(), + inventory: z.array(z.lazy(() => InventoryEntrySchema)), + is_featured: z.literal(true).optional(), + }) + .strict(), + z.object({ kind: z.literal('digital'), download_url: z.string(), file_size_bytes: z.number().int() }).strict(), +]); +export type ProductDetails = z.infer; + +/** Stable identifier for a catalog product. */ +export const ProductIdSchema = z.string().describe('Stable identifier for a catalog product.'); +export type ProductId = z.infer; + +/** Customer rating expressed as an integer 1..5. */ +export const RatingSchema = z + .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]) + .describe('Customer rating expressed as an integer 1..5.'); +export type Rating = z.infer; + +export const TagSchema = z.string(); +export type Tag = z.infer; + +/** A product in the store catalog. */ +export const ProductSchema = z + .object({ + id: z.lazy(() => ProductIdSchema), + name: z.string(), + price: z.lazy(() => MoneySchema), + rating: z.lazy(() => RatingSchema).optional(), + tags: z.array(z.lazy(() => TagSchema)), + attributes: z.record(z.lazy(() => AttributeSchema)), + details: z.lazy(() => ProductDetailsSchema), + created_at: z.instanceof(firestore.Timestamp), + }) + .strict() + .describe('A product in the store catalog.'); +export type Product = z.infer; + +export const RawEventSchema = z + .object({ type: z.string(), occurred_at: z.instanceof(firestore.Timestamp) }) + .passthrough(); +export type RawEvent = z.infer; diff --git a/tests/integration/zod/generated/v3/projects.ts b/tests/integration/zod/generated/v3/projects.ts new file mode 100644 index 0000000..6796cbd --- /dev/null +++ b/tests/integration/zod/generated/v3/projects.ts @@ -0,0 +1,13 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v3'; + +/** A project document. */ +export const ProjectSchema = z + .object({ + id: z.string().describe('Caller-supplied identifier preserved verbatim in the document body.'), + display_name: z.string().describe('Human-readable label for the project.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .strict() + .describe('A project document.'); +export type Project = z.infer; diff --git a/tests/integration/zod/generated/v3/secrets.ts b/tests/integration/zod/generated/v3/secrets.ts new file mode 100644 index 0000000..669b1f3 --- /dev/null +++ b/tests/integration/zod/generated/v3/secrets.ts @@ -0,0 +1,17 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v3'; + +/** A document storing opaque binary material alongside metadata. */ +export const SecretSchema = z + .object({ + label: z.string().describe('Human-readable label for the secret.'), + payload: z.instanceof(Buffer).describe('Opaque binary blob (encrypted material, key bytes, etc.).'), + checksum: z.instanceof(Buffer).describe('A second bytes field, e.g. a SHA-256 digest of the payload.'), + shards: z + .array(z.instanceof(Buffer)) + .describe('Additional bytes blobs stored as a list to exercise bytes-in-list.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .strict() + .describe('A document storing opaque binary material alongside metadata.'); +export type Secret = z.infer; diff --git a/tests/integration/zod/generated/v3/users.ts b/tests/integration/zod/generated/v3/users.ts new file mode 100644 index 0000000..b1a27ff --- /dev/null +++ b/tests/integration/zod/generated/v3/users.ts @@ -0,0 +1,19 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v3'; + +/** Represents a user's role within a project. */ +export const UserRoleSchema = z + .enum(['owner', 'admin', 'member']) + .describe("Represents a user's role within a project."); +export type UserRole = z.infer; + +/** Represents a user that belongs to a project. */ +export const UserSchema = z + .object({ + username: z.string().describe('A string that uniquely identifies the user within a project.'), + role: z.lazy(() => UserRoleSchema), + created_at: z.instanceof(firestore.Timestamp), + }) + .strict() + .describe('Represents a user that belongs to a project.'); +export type User = z.infer; diff --git a/tests/integration/zod/generated/v4-web/secrets.ts b/tests/integration/zod/generated/v4-web/secrets.ts new file mode 100644 index 0000000..07034c3 --- /dev/null +++ b/tests/integration/zod/generated/v4-web/secrets.ts @@ -0,0 +1,20 @@ +import * as firestore from 'firebase/firestore'; +import { z } from 'zod-v4'; + +/** A document storing opaque binary material alongside metadata. */ +export const SecretSchema = z + .strictObject({ + label: z.string().describe('Human-readable label for the secret.'), + payload: z + .instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes) + .describe('Opaque binary blob (encrypted material, key bytes, etc.).'), + checksum: z + .instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes) + .describe('A second bytes field, e.g. a SHA-256 digest of the payload.'), + shards: z + .array(z.instanceof(firestore.Bytes as unknown as new (...args: never[]) => firestore.Bytes)) + .describe('Additional bytes blobs stored as a list to exercise bytes-in-list.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A document storing opaque binary material alongside metadata.'); +export type Secret = z.infer; diff --git a/tests/integration/zod/generated/v4/catalog.ts b/tests/integration/zod/generated/v4/catalog.ts new file mode 100644 index 0000000..8b6119c --- /dev/null +++ b/tests/integration/zod/generated/v4/catalog.ts @@ -0,0 +1,66 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v4'; + +export const AttributeSchema = z.union([z.string(), z.number().int(), z.boolean()]); +export type Attribute = z.infer; + +/** ISO-4217 currency code. */ +export const CurrencySchema = z.enum(['USD', 'EUR', 'GBP']).describe('ISO-4217 currency code.'); +export type Currency = z.infer; + +export const InventoryEntrySchema = z.strictObject({ sku: z.string(), quantity: z.number().int() }); +export type InventoryEntry = z.infer; + +/** A monetary amount expressed in a given currency. */ +export const MoneySchema = z + .strictObject({ + amount_minor: z.number().int().describe("Amount in the currency's minor unit (e.g. cents for USD)."), + currency: z.lazy(() => CurrencySchema), + }) + .describe('A monetary amount expressed in a given currency.'); +export type Money = z.infer; + +export const ProductDetailsSchema = z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('physical'), + weight_grams: z.number().int(), + inventory: z.array(z.lazy(() => InventoryEntrySchema)), + is_featured: z.literal(true).optional(), + }), + z.strictObject({ kind: z.literal('digital'), download_url: z.string(), file_size_bytes: z.number().int() }), +]); +export type ProductDetails = z.infer; + +/** Stable identifier for a catalog product. */ +export const ProductIdSchema = z.string().describe('Stable identifier for a catalog product.'); +export type ProductId = z.infer; + +/** Customer rating expressed as an integer 1..5. */ +export const RatingSchema = z + .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]) + .describe('Customer rating expressed as an integer 1..5.'); +export type Rating = z.infer; + +export const TagSchema = z.string(); +export type Tag = z.infer; + +/** A product in the store catalog. */ +export const ProductSchema = z + .strictObject({ + id: z.lazy(() => ProductIdSchema), + name: z.string(), + price: z.lazy(() => MoneySchema), + rating: z.lazy(() => RatingSchema).optional(), + tags: z.array(z.lazy(() => TagSchema)), + attributes: z.record( + z.string(), + z.lazy(() => AttributeSchema) + ), + details: z.lazy(() => ProductDetailsSchema), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A product in the store catalog.'); +export type Product = z.infer; + +export const RawEventSchema = z.looseObject({ type: z.string(), occurred_at: z.instanceof(firestore.Timestamp) }); +export type RawEvent = z.infer; diff --git a/tests/integration/zod/generated/v4/projects.ts b/tests/integration/zod/generated/v4/projects.ts new file mode 100644 index 0000000..b614ac2 --- /dev/null +++ b/tests/integration/zod/generated/v4/projects.ts @@ -0,0 +1,12 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v4'; + +/** A project document. */ +export const ProjectSchema = z + .strictObject({ + id: z.string().describe('Caller-supplied identifier preserved verbatim in the document body.'), + display_name: z.string().describe('Human-readable label for the project.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A project document.'); +export type Project = z.infer; diff --git a/tests/integration/zod/generated/v4/secrets.ts b/tests/integration/zod/generated/v4/secrets.ts new file mode 100644 index 0000000..8b8e6d5 --- /dev/null +++ b/tests/integration/zod/generated/v4/secrets.ts @@ -0,0 +1,16 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v4'; + +/** A document storing opaque binary material alongside metadata. */ +export const SecretSchema = z + .strictObject({ + label: z.string().describe('Human-readable label for the secret.'), + payload: z.instanceof(Buffer).describe('Opaque binary blob (encrypted material, key bytes, etc.).'), + checksum: z.instanceof(Buffer).describe('A second bytes field, e.g. a SHA-256 digest of the payload.'), + shards: z + .array(z.instanceof(Buffer)) + .describe('Additional bytes blobs stored as a list to exercise bytes-in-list.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A document storing opaque binary material alongside metadata.'); +export type Secret = z.infer; diff --git a/tests/integration/zod/generated/v4/users.ts b/tests/integration/zod/generated/v4/users.ts new file mode 100644 index 0000000..579c845 --- /dev/null +++ b/tests/integration/zod/generated/v4/users.ts @@ -0,0 +1,18 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v4'; + +/** Represents a user's role within a project. */ +export const UserRoleSchema = z + .enum(['owner', 'admin', 'member']) + .describe("Represents a user's role within a project."); +export type UserRole = z.infer; + +/** Represents a user that belongs to a project. */ +export const UserSchema = z + .strictObject({ + username: z.string().describe('A string that uniquely identifies the user within a project.'), + role: z.lazy(() => UserRoleSchema), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('Represents a user that belongs to a project.'); +export type User = z.infer; diff --git a/tests/integration/zod/tests/_helpers.ts b/tests/integration/zod/tests/_helpers.ts new file mode 100644 index 0000000..c955df9 --- /dev/null +++ b/tests/integration/zod/tests/_helpers.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const SHARED_FIXTURES_ROOT = resolve(__dirname, '../../_fixtures'); +const ZOD_FIXTURES_ROOT = resolve(__dirname, '../_fixtures'); + +/** + * Loads a JSON fixture by scenario + name. Looks under the Zod-only fixtures + * dir first, then falls back to the shared fixtures dir. The fallback lets + * test files reference shared scenarios (`users`, `secrets`, `projects`) + * the same way as zod-only ones (`catalog`). + */ +export function loadSample(scenario: string, name: string): unknown { + for (const root of [ZOD_FIXTURES_ROOT, SHARED_FIXTURES_ROOT]) { + const candidate = resolve(root, 'samples', scenario, `${name}.json`); + try { + return JSON.parse(readFileSync(candidate, 'utf8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + throw new Error(`Could not find sample '${scenario}/${name}.json' under either Zod-only or shared fixtures.`); +} + +export function ensureEmulatorEnv(): { host: string; port: number } { + const raw = process.env.FIRESTORE_EMULATOR_HOST; + if (!raw) { + throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:zod`.'); + } + const lastColon = raw.lastIndexOf(':'); + if (lastColon < 0) { + throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} is not in host:port form`); + } + const host = raw.slice(0, lastColon); + const port = Number(raw.slice(lastColon + 1)); + if (!Number.isFinite(port) || port <= 0) { + throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} has an invalid port`); + } + process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration'; + process.env.GCLOUD_PROJECT ??= process.env.GOOGLE_CLOUD_PROJECT; + return { host, port }; +} diff --git a/tests/integration/zod/tests/catalog.parsing.test.ts b/tests/integration/zod/tests/catalog.parsing.test.ts new file mode 100644 index 0000000..38c8c3b --- /dev/null +++ b/tests/integration/zod/tests/catalog.parsing.test.ts @@ -0,0 +1,231 @@ +import { Timestamp } from 'firebase-admin/firestore'; +import { describe, expect, it } from 'vitest'; + +import * as v3 from '../generated/v3/catalog.js'; +import * as v4 from '../generated/v4/catalog.js'; +import { loadSample } from './_helpers.js'; + +// Pure-Zod parsing tests for the complex `catalog` fixture. These do NOT +// touch the Firestore emulator — they exercise the generated schemas +// directly against hand-crafted wire-shape inputs. The fixture covers: +// +// - string + int enums (`Currency`, `Rating`) +// - alias references (`ProductId`, `Money`, `Tag`, `InventoryEntry`) +// - simple union (`Attribute`) +// - discriminated union (`ProductDetails` over `kind`) +// - lists of primitives, lists of aliases, lists of objects +// - records (maps) +// - optional fields (`rating`, `is_featured`) +// - top-level `additionalFields: true` (`RawEvent`) +// +// `firebase-admin/firestore`'s `Timestamp` constructor is pure JS — we can +// build instances without booting the SDK or talking to the emulator, which +// keeps these tests self-contained. + +type Variant = 'v3' | 'v4'; + +const VARIANTS: Array<{ name: Variant; mod: typeof v3 | typeof v4 }> = [ + { name: 'v3', mod: v3 }, + { name: 'v4', mod: v4 }, +]; + +const ts = new Timestamp(1_700_000_000, 0); + +interface CatalogSample { + id: string; + name: string; + price: { amount_minor: number; currency: string }; + rating?: number; + tags: string[]; + attributes: Record; + details: Record; + created_at: string; +} + +function withTimestamp(sample: CatalogSample): unknown { + return { ...sample, created_at: ts }; +} + +describe.each(VARIANTS)('catalog ($name)', ({ mod }) => { + describe('Currency (string enum)', () => { + it('accepts every member', () => { + for (const c of ['USD', 'EUR', 'GBP'] as const) { + expect(mod.CurrencySchema.safeParse(c).success).toBe(true); + } + }); + it('rejects unknown codes and non-strings', () => { + expect(mod.CurrencySchema.safeParse('JPY').success).toBe(false); + expect(mod.CurrencySchema.safeParse('').success).toBe(false); + expect(mod.CurrencySchema.safeParse(1).success).toBe(false); + expect(mod.CurrencySchema.safeParse(null).success).toBe(false); + }); + }); + + describe('Rating (int enum)', () => { + it('accepts every numeric member', () => { + for (const r of [1, 2, 3, 4, 5]) { + expect(mod.RatingSchema.safeParse(r).success).toBe(true); + } + }); + it('rejects out-of-range and non-integer values', () => { + expect(mod.RatingSchema.safeParse(0).success).toBe(false); + expect(mod.RatingSchema.safeParse(6).success).toBe(false); + expect(mod.RatingSchema.safeParse(3.5).success).toBe(false); + expect(mod.RatingSchema.safeParse('5').success).toBe(false); + }); + }); + + describe('Attribute (simple union)', () => { + it('accepts every member type', () => { + expect(mod.AttributeSchema.safeParse('graphite').success).toBe(true); + expect(mod.AttributeSchema.safeParse(42).success).toBe(true); + expect(mod.AttributeSchema.safeParse(true).success).toBe(true); + }); + it('rejects values outside the union', () => { + expect(mod.AttributeSchema.safeParse(null).success).toBe(false); + expect(mod.AttributeSchema.safeParse({ x: 1 }).success).toBe(false); + expect(mod.AttributeSchema.safeParse([1, 2]).success).toBe(false); + // Doubles must be rejected: `Attribute` covers `string | int | boolean`, + // and our `int` codegen tightens to `z.number().int()`. + expect(mod.AttributeSchema.safeParse(3.14).success).toBe(false); + }); + }); + + describe('Money (alias-referenced object)', () => { + it('accepts a well-formed money value', () => { + expect(mod.MoneySchema.safeParse({ amount_minor: 4999, currency: 'USD' }).success).toBe(true); + }); + it('rejects the wrong currency', () => { + expect(mod.MoneySchema.safeParse({ amount_minor: 1, currency: 'JPY' }).success).toBe(false); + }); + it('rejects missing required fields', () => { + expect(mod.MoneySchema.safeParse({ currency: 'USD' }).success).toBe(false); + expect(mod.MoneySchema.safeParse({ amount_minor: 1 }).success).toBe(false); + }); + it('rejects extra fields under strict object semantics', () => { + expect(mod.MoneySchema.safeParse({ amount_minor: 1, currency: 'USD', extra: 'x' }).success).toBe(false); + }); + }); + + describe('ProductDetails (discriminated union)', () => { + it('routes to the physical variant on kind=physical', () => { + const ok = mod.ProductDetailsSchema.safeParse({ + kind: 'physical', + weight_grams: 1500, + inventory: [{ sku: 'A', quantity: 1 }], + }); + expect(ok.success).toBe(true); + }); + it('routes to the digital variant on kind=digital', () => { + const ok = mod.ProductDetailsSchema.safeParse({ + kind: 'digital', + download_url: 'https://example.com/x', + file_size_bytes: 1024, + }); + expect(ok.success).toBe(true); + }); + it('rejects an unknown discriminator', () => { + const bad = mod.ProductDetailsSchema.safeParse({ + kind: 'subscription', + plan: 'pro', + }); + expect(bad.success).toBe(false); + }); + it('rejects fields belonging to the *other* variant', () => { + const bad = mod.ProductDetailsSchema.safeParse({ + kind: 'physical', + weight_grams: 1, + inventory: [], + // download_url is digital-only — strict object semantics reject it + download_url: 'https://example.com', + }); + expect(bad.success).toBe(false); + }); + it('treats `is_featured` as optional and only accepts its single literal value when present', () => { + const okPresent = mod.ProductDetailsSchema.safeParse({ + kind: 'physical', + weight_grams: 1, + inventory: [], + is_featured: true, + }); + const okAbsent = mod.ProductDetailsSchema.safeParse({ + kind: 'physical', + weight_grams: 1, + inventory: [], + }); + const badValue = mod.ProductDetailsSchema.safeParse({ + kind: 'physical', + weight_grams: 1, + inventory: [], + is_featured: false, + }); + expect(okPresent.success).toBe(true); + expect(okAbsent.success).toBe(true); + expect(badValue.success).toBe(false); + }); + }); + + describe('Product (top-level document)', () => { + it('accepts the physical hand-written sample', () => { + const sample = loadSample('catalog', 'widget-physical') as CatalogSample; + const result = mod.ProductSchema.safeParse(withTimestamp(sample)); + expect(result.success).toBe(true); + }); + it('accepts the digital hand-written sample', () => { + const sample = loadSample('catalog', 'ebook-digital') as CatalogSample; + const result = mod.ProductSchema.safeParse(withTimestamp(sample)); + expect(result.success).toBe(true); + }); + it('rejects a sample with the wrong details.kind', () => { + const sample = loadSample('catalog', 'widget-physical') as CatalogSample; + const broken = withTimestamp({ + ...sample, + details: { ...sample.details, kind: 'subscription' }, + }); + expect(mod.ProductSchema.safeParse(broken).success).toBe(false); + }); + it('rejects a sample whose tags array contains a non-string', () => { + const sample = loadSample('catalog', 'widget-physical') as CatalogSample; + const broken = withTimestamp({ + ...sample, + tags: [...sample.tags, 42 as unknown as string], + }); + expect(mod.ProductSchema.safeParse(broken).success).toBe(false); + }); + it('rejects a sample whose attributes record contains a disallowed value type', () => { + const sample = loadSample('catalog', 'widget-physical') as CatalogSample; + const broken = withTimestamp({ + ...sample, + attributes: { ...sample.attributes, malformed: { nested: 'object' } as unknown as string }, + }); + expect(mod.ProductSchema.safeParse(broken).success).toBe(false); + }); + it('rejects a sample whose Timestamp slot is a plain object rather than a Timestamp instance', () => { + const sample = loadSample('catalog', 'widget-physical') as CatalogSample; + const broken = { ...sample, created_at: { seconds: 0, nanoseconds: 0 } }; + expect(mod.ProductSchema.safeParse(broken).success).toBe(false); + }); + }); + + describe('RawEvent (loose / passthrough object)', () => { + it('accepts a document with extra unknown keys when the declared keys are valid', () => { + const result = mod.RawEventSchema.safeParse({ + type: 'click', + occurred_at: ts, + page: '/home', + button: 'cta', + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record).page).toBe('/home'); + expect((result.data as Record).button).toBe('cta'); + } + }); + it('still rejects a document missing a required declared key', () => { + expect(mod.RawEventSchema.safeParse({ occurred_at: ts, foo: 'bar' }).success).toBe(false); + }); + it('still rejects a document with the wrong type for a declared key', () => { + expect(mod.RawEventSchema.safeParse({ type: 42, occurred_at: ts }).success).toBe(false); + }); + }); +}); diff --git a/tests/integration/zod/tests/secrets.admin.test.ts b/tests/integration/zod/tests/secrets.admin.test.ts new file mode 100644 index 0000000..6b99374 --- /dev/null +++ b/tests/integration/zod/tests/secrets.admin.test.ts @@ -0,0 +1,57 @@ +import { type App, initializeApp } from 'firebase-admin/app'; +import { type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { SecretSchema } from '../generated/v4/secrets.js'; +import { ensureEmulatorEnv, loadSample } from './_helpers.js'; + +interface SecretSample { + label: string; + payload_base64: string; + checksum_base64: string; + shards_base64: string[]; + created_at: string; +} + +describe('secrets firebase-admin@13 round-trip (v4 zod schema)', () => { + let app: App; + let firestore: Firestore; + + beforeAll(() => { + ensureEmulatorEnv(); + app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'zod-secrets-admin-app'); + firestore = getFirestore(app); + }); + + afterAll(async () => { + await firestore.terminate(); + }); + + it("validates a Secret read from the emulator with the admin SDK's Buffer-shaped bytes", async () => { + const sample = loadSample('secrets', 'api-key') as SecretSample; + const secretIn = { + label: sample.label, + payload: Buffer.from(sample.payload_base64, 'base64'), + checksum: Buffer.from(sample.checksum_base64, 'base64'), + shards: sample.shards_base64.map(s => Buffer.from(s, 'base64')), + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`); + const docRef = collection.doc(crypto.randomUUID()); + await docRef.set(secretIn); + + const snapshot = await docRef.get(); + expect(snapshot.exists).toBe(true); + + const parsed = SecretSchema.safeParse(snapshot.data()); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.label).toBe(secretIn.label); + // The admin SDK returns bytes as Uint8Array (Buffer subclass). + expect(parsed.data.payload).toBeInstanceOf(Uint8Array); + expect(parsed.data.shards).toHaveLength(secretIn.shards.length); + expect(parsed.data.created_at.toMillis()).toBe(secretIn.created_at.toMillis()); + } + }); +}); diff --git a/tests/integration/zod/tests/secrets.parsing.test.ts b/tests/integration/zod/tests/secrets.parsing.test.ts new file mode 100644 index 0000000..4d08bc6 --- /dev/null +++ b/tests/integration/zod/tests/secrets.parsing.test.ts @@ -0,0 +1,56 @@ +import { Timestamp } from 'firebase-admin/firestore'; +import { describe, expect, it } from 'vitest'; + +import * as v3Admin from '../generated/v3/secrets.js'; +import * as v4Web from '../generated/v4-web/secrets.js'; +import * as v4Admin from '../generated/v4/secrets.js'; + +// Pure-Zod parsing tests for the `secrets` fixture (which exercises +// the `bytes` primitive). The bytes representation differs per target: +// +// * firebase-admin: Node `Buffer` (z.instanceof(Buffer)) +// * firebase web SDK: firestore.Bytes (z.instanceof(firestore.Bytes)) +// +// We exercise all three combinations the orchestrator emits today: +// v3 admin, v4 admin, v4 web. + +const ts = new Timestamp(1_700_000_000, 0); + +const adminInput = () => ({ + label: 'API key', + payload: Buffer.from([0x01, 0x02, 0x03]), + checksum: Buffer.from(new Uint8Array(32)), + shards: [Buffer.from([0xff]), Buffer.from([0xfe])], + created_at: ts, +}); + +describe.each([ + { name: 'v3 admin', mod: v3Admin }, + { name: 'v4 admin', mod: v4Admin }, +])('secrets admin ($name)', ({ mod }) => { + it('accepts a Secret built from Buffer values', () => { + expect(mod.SecretSchema.safeParse(adminInput()).success).toBe(true); + }); + it('rejects a Secret whose payload is a base64 string instead of bytes', () => { + expect(mod.SecretSchema.safeParse({ ...adminInput(), payload: 'AQID' }).success).toBe(false); + }); + it('rejects a Secret whose `shards` list contains a non-Buffer entry', () => { + expect(mod.SecretSchema.safeParse({ ...adminInput(), shards: [Buffer.from([1]), 'not bytes'] }).success).toBe( + false + ); + }); + it('accepts an empty `shards` list', () => { + expect(mod.SecretSchema.safeParse({ ...adminInput(), shards: [] }).success).toBe(true); + }); +}); + +describe('secrets web (v4)', () => { + it('rejects Buffer-typed bytes against the firestore.Bytes-bound schema', () => { + // The web schema expects firestore.Bytes (an entirely different class + // from Buffer), so passing a Buffer should fail at parse time. We don't + // construct a `firestore.Bytes` instance here because it requires + // initializing the Firebase web SDK; the dedicated emulator round-trip + // in `secrets.web.test.ts` covers the positive path. + expect(v4Web.SecretSchema.safeParse(adminInput()).success).toBe(false); + }); +}); diff --git a/tests/integration/zod/tests/secrets.web.test.ts b/tests/integration/zod/tests/secrets.web.test.ts new file mode 100644 index 0000000..f09211c --- /dev/null +++ b/tests/integration/zod/tests/secrets.web.test.ts @@ -0,0 +1,74 @@ +import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app'; +import { + Bytes, + type Firestore, + Timestamp, + connectFirestoreEmulator, + doc, + getDoc, + getFirestore, + setDoc, + terminate, +} from 'firebase/firestore'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { SecretSchema } from '../generated/v4-web/secrets.js'; +import { ensureEmulatorEnv, loadSample } from './_helpers.js'; + +interface SecretSample { + label: string; + payload_base64: string; + checksum_base64: string; + shards_base64: string[]; + created_at: string; +} + +describe('secrets firebase web SDK round-trip (v4 zod schema)', () => { + let app: FirebaseApp; + let firestore: Firestore; + + beforeAll(() => { + const { host, port } = ensureEmulatorEnv(); + app = initializeWebApp( + { + apiKey: 'fake-api-key', + projectId: process.env.GOOGLE_CLOUD_PROJECT, + }, + 'zod-secrets-web-app' + ); + firestore = getFirestore(app); + connectFirestoreEmulator(firestore, host, port); + }); + + afterAll(async () => { + await terminate(firestore); + }); + + it("validates a Secret read from the emulator with the web SDK's `firestore.Bytes` shape", async () => { + const sample = loadSample('secrets', 'api-key') as SecretSample; + const secretIn = { + label: sample.label, + payload: Bytes.fromBase64String(sample.payload_base64), + checksum: Bytes.fromBase64String(sample.checksum_base64), + shards: sample.shards_base64.map(s => Bytes.fromBase64String(s)), + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = `test_${crypto.randomUUID().replaceAll('-', '')}`; + const docRef = doc(firestore, collection, crypto.randomUUID()); + await setDoc(docRef, secretIn); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + + const parsed = SecretSchema.safeParse(snapshot.data()); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.label).toBe(secretIn.label); + expect(parsed.data.payload).toBeInstanceOf(Bytes); + expect(parsed.data.payload.isEqual(secretIn.payload)).toBe(true); + expect(parsed.data.shards).toHaveLength(secretIn.shards.length); + expect(parsed.data.created_at.toMillis()).toBe(secretIn.created_at.toMillis()); + } + }); +}); diff --git a/tests/integration/zod/tests/users.admin.test.ts b/tests/integration/zod/tests/users.admin.test.ts new file mode 100644 index 0000000..d79eecb --- /dev/null +++ b/tests/integration/zod/tests/users.admin.test.ts @@ -0,0 +1,64 @@ +import { type App, initializeApp } from 'firebase-admin/app'; +import { type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// Firestore emulator round-trip: write a typed value, read it back, and +// validate the wire-shape result with the v4 Zod schema. This proves the +// schema's `z.instanceof(firestore.Timestamp)` and the strict-object shape +// match what the SDK actually produces. +import { UserRoleSchema, UserSchema } from '../generated/v4/users.js'; +import { ensureEmulatorEnv } from './_helpers.js'; + +describe('users firebase-admin@13 round-trip (v4 zod schema)', () => { + let app: App; + let firestore: Firestore; + + beforeAll(() => { + ensureEmulatorEnv(); + app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'zod-users-admin-app'); + firestore = getFirestore(app); + }); + + afterAll(async () => { + await firestore.terminate(); + }); + + it('round-trips a User document and validates it with UserSchema', async () => { + const userIn = { + username: 'alice', + role: UserRoleSchema.parse('admin'), + created_at: new Timestamp(1_700_000_000, 0), + }; + + const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`); + const docRef = collection.doc(crypto.randomUUID()); + await docRef.set(userIn); + + const snapshot = await docRef.get(); + expect(snapshot.exists).toBe(true); + + const raw = snapshot.data(); + const parsed = UserSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.username).toBe(userIn.username); + expect(parsed.data.role).toBe(userIn.role); + expect(parsed.data.created_at.toMillis()).toBe(userIn.created_at.toMillis()); + } + }); + + it('rejects a doc whose `role` was tampered with at the Firestore layer', async () => { + const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`); + const docRef = collection.doc(crypto.randomUUID()); + // Bypass the schema by writing untyped data straight to Firestore. + await docRef.set({ + username: 'mallory', + role: 'visitor', + created_at: new Timestamp(1_700_000_000, 0), + }); + + const snapshot = await docRef.get(); + const parsed = UserSchema.safeParse(snapshot.data()); + expect(parsed.success).toBe(false); + }); +}); diff --git a/tests/integration/zod/tests/users.parsing.test.ts b/tests/integration/zod/tests/users.parsing.test.ts new file mode 100644 index 0000000..59f0b30 --- /dev/null +++ b/tests/integration/zod/tests/users.parsing.test.ts @@ -0,0 +1,66 @@ +import { Timestamp } from 'firebase-admin/firestore'; +import { describe, expect, it } from 'vitest'; + +import * as v3 from '../generated/v3/users.js'; +import * as v4 from '../generated/v4/users.js'; + +// Pure-Zod parsing tests for the shared `users` fixture (used by every +// generator's integration suite). Exercised against both the v3 and v4 +// generated outputs to confirm that variant-specific surface differences +// (`z.object().strict()` vs `z.strictObject(...)`, `z.record(value)` vs +// `z.record(z.string(), value)`, …) do not change runtime semantics. + +type Variant = 'v3' | 'v4'; +const VARIANTS: Array<{ name: Variant; mod: typeof v3 | typeof v4 }> = [ + { name: 'v3', mod: v3 }, + { name: 'v4', mod: v4 }, +]; + +const ts = new Timestamp(1_700_000_000, 0); + +describe.each(VARIANTS)('users ($name)', ({ mod }) => { + describe('UserRoleSchema', () => { + it('accepts every defined role', () => { + for (const role of ['owner', 'admin', 'member'] as const) { + expect(mod.UserRoleSchema.safeParse(role).success).toBe(true); + } + }); + it('rejects unknown roles and non-strings', () => { + expect(mod.UserRoleSchema.safeParse('visitor').success).toBe(false); + expect(mod.UserRoleSchema.safeParse('').success).toBe(false); + expect(mod.UserRoleSchema.safeParse(0).success).toBe(false); + }); + }); + + describe('UserSchema', () => { + const baseInput = { + username: 'alice', + role: 'admin' as const, + created_at: ts, + }; + + it('accepts a well-formed user document', () => { + expect(mod.UserSchema.safeParse(baseInput).success).toBe(true); + }); + + it('rejects when a required field is missing', () => { + expect(mod.UserSchema.safeParse({ username: 'alice', role: 'admin' }).success).toBe(false); + expect(mod.UserSchema.safeParse({ username: 'alice', created_at: ts }).success).toBe(false); + }); + + it('rejects an extra field under strict semantics', () => { + expect(mod.UserSchema.safeParse({ ...baseInput, extra: 'oops' }).success).toBe(false); + }); + + it('rejects a non-Timestamp `created_at`', () => { + expect(mod.UserSchema.safeParse({ ...baseInput, created_at: '2024-01-01' }).success).toBe(false); + expect(mod.UserSchema.safeParse({ ...baseInput, created_at: { seconds: 0, nanoseconds: 0 } }).success).toBe( + false + ); + }); + + it('rejects a non-string username', () => { + expect(mod.UserSchema.safeParse({ ...baseInput, username: 42 }).success).toBe(false); + }); + }); +}); diff --git a/tests/integration/zod/tsconfig.json b/tests/integration/zod/tsconfig.json new file mode 100644 index 0000000..9ac0cfa --- /dev/null +++ b/tests/integration/zod/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declaration": false, + "esModuleInterop": true, + "module": "Node16", + "moduleResolution": "Node16", + "noEmit": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node", "vitest/globals"] + }, + "include": ["generated/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/tests/integration/zod/vitest.config.ts b/tests/integration/zod/vitest.config.ts new file mode 100644 index 0000000..64d0285 --- /dev/null +++ b/tests/integration/zod/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // Anchor `include` to this config file's directory; otherwise it resolves + // against the cwd of the runner (the repo root) and accidentally picks up + // unrelated tests under `tests/`. + root: import.meta.dirname, + test: { + include: ['tests/**/*.test.ts'], + globals: true, + coverage: { + enabled: false, + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 3d5404f..b8861e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8780,6 +8780,16 @@ zip-stream@^4.1.0: compress-commons "^4.1.2" readable-stream "^3.6.0" +"zod-v3@npm:zod@^3.23.8": + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + +"zod-v4@npm:zod@^4.3.6": + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== + zod@4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"