Skip to content

Commit 24a1777

Browse files
brainkimclaude
andauthored
fix: remove validation on reads, matching documented behavior (#16)
* fix: remove validation on reads, matching documented behavior The README states "Zod validation happens on writes, never on reads" but validation was running during normalize() via buildEntityMap(). This caused ValidationError when reading data written with an older schema version, breaking the forward-only migration pattern. Changes: - Remove validateWithStandardSchema call in buildEntityMap - Update tests to use z.date()/z.boolean()/z.number() instead of z.coerce.* - Zen's decodeData handles DB→JS type conversion (dates, booleans, JSON) - Zod coerce/transform only runs on writes, not reads Fixes #14 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix prettier formatting --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4ed9ca9 commit 24a1777

File tree

2 files changed

+28
-30
lines changed

2 files changed

+28
-30
lines changed

src/impl/query.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,7 @@
44
* Generates SELECT statements with prefixed column aliases for entity normalization.
55
*/
66

7-
import {
8-
type Table,
9-
isTable,
10-
validateWithStandardSchema,
11-
decodeData,
12-
type DriverDecoder,
13-
} from "./table.js";
7+
import {type Table, isTable, decodeData, type DriverDecoder} from "./table.js";
148
import {
159
ident,
1610
makeTemplate,
@@ -482,13 +476,12 @@ export function buildEntityMap(
482476
const key = entityKey(table.name, pk);
483477

484478
if (!entities.has(key)) {
485-
// Decode JSON strings back to objects/arrays, then validate
479+
// Decode JSON strings back to objects/arrays
480+
// No validation on reads - validation happens on writes only
486481
const decoded = decodeData(table, data, driver);
487-
const parsed = validateWithStandardSchema<Record<string, unknown>>(
488-
table.schema,
489-
decoded,
490-
);
491-
entities.set(key, parsed);
482+
if (decoded) {
483+
entities.set(key, decoded);
484+
}
492485
}
493486
}
494487
}

test/normalize.test.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -524,13 +524,17 @@ describe("unregistered table validation", () => {
524524
});
525525
});
526526

527-
describe("type coercion", () => {
528-
test("coerces date strings to Date objects", () => {
527+
describe("type decoding", () => {
528+
// Zen handles DB→JS type conversion via decodeData, not Zod coercion.
529+
// Use z.date(), z.boolean(), z.number() - not z.coerce.*
530+
// Zod transforms/coercion only run on writes, never on reads.
531+
532+
test("decodes date strings to Date objects", () => {
529533
const events = table("events", {
530534
id: z.string().db.primary(),
531535
name: z.string(),
532-
// z.coerce.date() converts string → Date
533-
createdAt: z.coerce.date(),
536+
// Use z.date() - Zen's decodeData handles string→Date conversion
537+
createdAt: z.date(),
534538
});
535539

536540
const rows = [
@@ -547,21 +551,22 @@ describe("type coercion", () => {
547551
expect(results[0].createdAt.toISOString()).toBe("2024-01-15T10:30:00.000Z");
548552
});
549553

550-
test("coerces number strings to numbers", () => {
554+
test("numbers from DB stay as numbers", () => {
551555
const products = table("products", {
552556
id: z.string().db.primary(),
553557
name: z.string(),
554-
// z.coerce.number() converts string → number
555-
price: z.coerce.number(),
556-
quantity: z.coerce.number().int(),
558+
// Use z.number() - DB drivers return numbers as numbers
559+
price: z.number(),
560+
quantity: z.number().int(),
557561
});
558562

563+
// Real DB drivers return numbers, not strings
559564
const rows = [
560565
{
561566
"products.id": "p1",
562567
"products.name": "Widget",
563-
"products.price": "19.99",
564-
"products.quantity": "42",
568+
"products.price": 19.99,
569+
"products.quantity": 42,
565570
},
566571
];
567572

@@ -573,12 +578,12 @@ describe("type coercion", () => {
573578
expect(typeof results[0].quantity).toBe("number");
574579
});
575580

576-
test("coerces boolean strings/numbers to booleans", () => {
581+
test("decodes boolean 0/1 to true/false", () => {
577582
const flags = table("flags", {
578583
id: z.string().db.primary(),
579584
name: z.string(),
580-
// z.coerce.boolean() converts truthy/falsy → boolean
581-
enabled: z.coerce.boolean(),
585+
// Use z.boolean() - Zen's decodeData handles 0/1→boolean conversion
586+
enabled: z.boolean(),
582587
});
583588

584589
const rows = [
@@ -601,7 +606,7 @@ describe("type coercion", () => {
601606
expect(results[1].enabled).toBe(false);
602607
});
603608

604-
test("coercion works with joins", () => {
609+
test("decoding works with joins", () => {
605610
const authors = table("authors", {
606611
id: z.string().db.primary(),
607612
name: z.string(),
@@ -611,8 +616,8 @@ describe("type coercion", () => {
611616
id: z.string().db.primary(),
612617
authorId: z.string().db.references(authors, "author"),
613618
title: z.string(),
614-
publishedAt: z.coerce.date(),
615-
viewCount: z.coerce.number().int(),
619+
publishedAt: z.date(),
620+
viewCount: z.number().int(),
616621
});
617622

618623
const rows = [
@@ -621,7 +626,7 @@ describe("type coercion", () => {
621626
"articles.authorId": "u1",
622627
"articles.title": "Hello World",
623628
"articles.publishedAt": "2024-06-01T00:00:00.000Z",
624-
"articles.viewCount": "1234",
629+
"articles.viewCount": 1234,
625630
"authors.id": "u1",
626631
"authors.name": "Alice",
627632
},

0 commit comments

Comments
 (0)