diff --git a/bun.lock b/bun.lock index 8552a83..a49c01e 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,6 @@ "workspaces": { "": { "name": "@zealot/monorepo", - "dependencies": { - "zod": "^4.0.0", - }, "devDependencies": { "@b9g/libuild": "^0.1.21", "@eslint/js": "^9.39.2", @@ -22,6 +19,7 @@ "postgres": "^3.4.0", "tsx": "^4.21.0", "typescript": "^5.7.3", + "zod": "^4.0.0", }, "peerDependencies": { "better-sqlite3": "^11.0.0", diff --git a/package.json b/package.json index dd20579..98a5bf1 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,6 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "prepublishOnly": "echo 'ERROR: Cannot publish from root directory. Use libuild publish instead.' && exit 1" }, - "dependencies": { - "zod": "^4.0.0" - }, "devDependencies": { "@b9g/libuild": "^0.1.21", "@eslint/js": "^9.39.2", @@ -76,7 +73,8 @@ "mysql2": "^3.12.0", "postgres": "^3.4.0", "tsx": "^4.21.0", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "zod": "^4.0.0" }, "peerDependencies": { "better-sqlite3": "^11.0.0", diff --git a/src/impl/ddl.ts b/src/impl/ddl.ts index c312119..aef898f 100644 --- a/src/impl/ddl.ts +++ b/src/impl/ddl.ts @@ -8,6 +8,7 @@ import {z} from "zod"; import type {Table, View} from "./table.js"; import {getTableMeta, getViewMeta} from "./table.js"; +import {TableDefinitionError} from "./errors.js"; import { ident, makeTemplate, @@ -185,8 +186,19 @@ function mapZodToSQL( if (hasDefault && defaultValue !== undefined) { sqlDefault = `'${JSON.stringify(defaultValue).replace(/'/g, "''")}'`; } + } else if (isForeignZodSchema(core)) { + // Every instanceof check above failed, yet this *is* a Zod schema — it just + // came from a different copy of Zod than the one zen imported. Falling + // through would silently type every column as TEXT, so refuse instead. + throw new TableDefinitionError( + "Schema came from a different copy of Zod than the one @b9g/zen is using, " + + "so its type could not be recognized. This usually means two versions of " + + "Zod are installed. zen declares zod as a peer dependency: make sure your " + + "project resolves exactly one zod (try `npm ls zod` / `bun pm ls zod`). " + + "Without this, every column would silently be created as TEXT.", + ); } else { - // Fallback for unknown types + // Fallback for genuinely unhandled (but ours) Zod types, e.g. ZodUnion. sqlType = "TEXT"; if (hasDefault && defaultValue !== undefined) { sqlDefault = `'${String(defaultValue).replace(/'/g, "''")}'`; @@ -196,6 +208,24 @@ function mapZodToSQL( return {sqlType, defaultValue: sqlDefault}; } +/** + * Detect a Zod schema that originates from a *different* Zod instance. + * + * Zod schemas from our own copy always pass `instanceof z.ZodType` — including + * types we don't map explicitly — so those still take the TEXT fallback. A + * schema that advertises itself as Zod via Standard Schema but fails the + * instanceof check can only be from a duplicate install (the dual package + * hazard), which would otherwise make every instanceof in this file fail + * silently. + */ +function isForeignZodSchema(value: unknown): boolean { + if (value instanceof z.ZodType) { + return false; + } + const vendor = (value as any)?.["~standard"]?.vendor; + return vendor === "zod"; +} + // ============================================================================ // DDL Generation // ============================================================================ diff --git a/src/impl/table.ts b/src/impl/table.ts index 3236b23..0f7e9c5 100644 --- a/src/impl/table.ts +++ b/src/impl/table.ts @@ -78,7 +78,10 @@ export function validateWithStandardSchema( if (!standard?.validate) { throw new Error( "Schema does not implement Standard Schema (~standard.validate). " + - "Ensure you're using Zod v3.23+ or another Standard Schema-compliant library.", + "@b9g/zen requires Zod v4, which it declares as a peer dependency. " + + "Note that other Standard Schema libraries are not interchangeable here: " + + "validation goes through the Standard Schema interface, but table " + + "definitions are introspected with Zod-specific checks to generate DDL.", ); } diff --git a/test/ddl.test.ts b/test/ddl.test.ts index d894816..b002247 100644 --- a/test/ddl.test.ts +++ b/test/ddl.test.ts @@ -1,8 +1,13 @@ import {test, expect, describe} from "bun:test"; import {z} from "zod"; import {table, extendZod} from "../src/impl/table.js"; -import {generateDDL, type SQLDialect} from "../src/impl/ddl.js"; +import { + generateDDL, + generateColumnDDL, + type SQLDialect, +} from "../src/impl/ddl.js"; import {renderDDL} from "../src/impl/sql.js"; +import {TableDefinitionError} from "../src/impl/errors.js"; // Extend Zod once before tests extendZod(z); @@ -662,4 +667,54 @@ describe("DDL generation", () => { expect(pgDdl).not.toContain("DEFAULT"); }); }); + + describe("duplicate Zod install (dual package hazard)", () => { + // A schema from a *second* copy of Zod fails every `instanceof z.ZodX` + // check in ddl.ts. Without a guard it would fall through to the "unknown + // type" branch and silently create every column as TEXT. + // Mimics a `z.string()` that came from a *different* copy of Zod: it has + // Zod's public surface (isOptional/isNullable, no wrapper methods) and + // advertises vendor "zod" via Standard Schema, but its prototype chain does + // not include *our* z.ZodType — so every `instanceof` in ddl.ts fails. + function foreignZodSchema(): z.ZodType { + const real = z.string(); + const foreign: any = { + "~standard": (real as any)["~standard"], + isOptional: () => false, + isNullable: () => false, + // no removeDefault/unwrap/innerType, same as a plain ZodString + }; + return foreign as z.ZodType; + } + + test("throws instead of silently typing the column TEXT", () => { + expect(foreignZodSchema() instanceof z.ZodType).toBe(false); + + expect(() => + generateColumnDDL("email", foreignZodSchema(), {}, "postgresql"), + ).toThrow(TableDefinitionError); + }); + + test("error explains the cause rather than the symptom", () => { + try { + generateColumnDDL("email", foreignZodSchema(), {}, "sqlite"); + throw new Error("expected generateColumnDDL to throw"); + } catch (err: any) { + expect(err).toBeInstanceOf(TableDefinitionError); + expect(err.message).toContain("different copy of Zod"); + expect(err.message).toContain("peer dependency"); + } + }); + + test("a real Zod type we don't map explicitly still falls back to TEXT", () => { + // ZodUnion isn't handled by name, but it IS our Zod, so it must keep + // taking the TEXT fallback — the guard must not fire here. + const union = z.union([z.string(), z.number()]); + expect(union instanceof z.ZodType).toBe(true); + + const template = generateColumnDDL("payload", union, {}, "sqlite"); + const sql = renderDDL(template[0], template.slice(1), "sqlite"); + expect(sql).toContain("TEXT"); + }); + }); });