Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
32 changes: 31 additions & 1 deletion src/impl/ddl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, "''")}'`;
Expand All @@ -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
// ============================================================================
Expand Down
5 changes: 4 additions & 1 deletion src/impl/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ export function validateWithStandardSchema<T = unknown>(
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.",
);
}

Expand Down
57 changes: 56 additions & 1 deletion test/ddl.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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");
});
});
});
Loading