From 9118ffea7eb85f14ee3cebfeaffe69a7f1fb33de Mon Sep 17 00:00:00 2001 From: samuelho-dev Date: Thu, 7 May 2026 22:48:06 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20DateTime=20maps=20back=20to=20Schema.Dat?= =?UTF-8?q?eFromSelf=20(Date=20=E2=86=94=20Date)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts 5.6.0 DateFromInput Union mapping. DA-layer consumers got hit with Date|string typed columns, no cast-free narrowing path. Prisma+Kysely canonical contract: DateTime columns return native Date instances on SELECT. Effect docs (Rule of Schemas, Doc 10944) prescribe one Type, one Encoded per schema; dual boundaries (DA Date↔Date vs wire string↔Date) handled with two schemas, not one Union — see @effect/sql Model.Class variants (Doc 4312). For RPC wire boundaries: contract-layer Schema.extend with Schema.Date overrides for date columns. Same pattern as @effect/sql json variants. DateFromInput is still exported from the package for explicit use at specific call sites; codegen just no longer auto-emits it. Schema.Schema.Encoded DB interface fix from 5.7.0 stays — still correct for join-table A/B column exposure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/datetime-back-to-datefromself.md | 52 +++++++++++++++++++ .../kysely-native-integration.test.ts | 15 +++--- src/effect/generator.ts | 9 ++-- src/utils/type-mappings.ts | 19 ++++--- 4 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 .changeset/datetime-back-to-datefromself.md diff --git a/.changeset/datetime-back-to-datefromself.md b/.changeset/datetime-back-to-datefromself.md new file mode 100644 index 0000000..86c3206 --- /dev/null +++ b/.changeset/datetime-back-to-datefromself.md @@ -0,0 +1,52 @@ +--- +"prisma-effect-kysely": minor +--- + +fix: DateTime maps back to `Schema.DateFromSelf` (Date ↔ Date) — Prisma+Kysely canonical + +Reverts the 5.6.0 change that mapped `DateTime` to `DateFromInput` (a +`Schema.Union(DateFromSelf, Date)` with `Encoded = Date | string`). + +**Why revert**: the dual-input Union pushed the boundary problem onto +DA-layer consumers. Kysely's pg driver returns native `Date` instances, +but `Selectable.created_at` typed as `Date | string` forced every DA +mapper that copies `result.created_at` into a contract type to either +narrow manually (no cast-free path) or wrap the read in +`Schema.decode(Selectable(X))` (heavy refactor across hundreds of sites). + +**Why DateFromSelf is correct**: + +- **Prisma docs**: *"Prisma Client returns all DateTime values as native + JavaScript Date objects. ... DateTime values must be passed as Date + objects, not strings, to avoid runtime errors."* +- **Kysely docs**: idiomatic DateTime column is + `created_at: ColumnType` — SELECT + yields `Date`. *"TypeScript is a compile-time concept and cannot + alter runtime JavaScript types. If your TypeScript definition for a + column differs from the database's actual return type, the runtime + type will not change automatically."* +- **Effect Schema docs (Doc 10944)**: *"schemas should be defined such + that encode + decode return the original value"* — one Type, one + Encoded per schema. The dual-boundary problem (DA Date ↔ Date vs + RPC string ↔ Date) is solved by **two schemas** (one per boundary), + not one Union. Doc 4312 (`@effect/sql/Model.Class`) shows this + canonical variant pattern (`select`/`insert`/`update` vs + `json`/`jsonCreate`/`jsonUpdate`). + +**For RPC/HTTP wire boundaries**: define a contract-layer schema that +overrides date columns with `Schema.Date` (Encoded = string) before the +RPC framework calls `Schema.decode`. This is the same pattern as +`@effect/sql`'s `json` variants — one schema per boundary. + +**`DateFromInput` is still exported** from the package for consumers that +specifically want the dual-input behavior at a single call site. The +codegen just no longer auto-emits it for every DateTime column. + +**The Schema.Schema.Encoded fix in 5.7.0 stays** — that's still correct +for join-table column exposure (`_product_tags.A`/`B`). + +**Migration**: most consumers benefit immediately (DA mappers stop +seeing `Date | string`). For RPC contracts that previously didn't have +a Date override (because they relied on `DateFromInput`), re-add a +`Schema.extend` with `Schema.Date` overrides for date columns to keep +wire decode working. diff --git a/src/__tests__/kysely-native-integration.test.ts b/src/__tests__/kysely-native-integration.test.ts index feb3524..e4b0e2a 100644 --- a/src/__tests__/kysely-native-integration.test.ts +++ b/src/__tests__/kysely-native-integration.test.ts @@ -155,15 +155,18 @@ describe('Kysely Native Integration', () => { expect(typesContent).toMatch(/author_id:\s*Schema\.UUID/); }); - it('should map DateTime to DateFromInput for Effect schemas', async () => { + it('should map DateTime to Schema.DateFromSelf for Effect schemas', async () => { const typesContent = await fs.readFile(path.join(outputDir, 'types.ts'), 'utf-8'); - // DateTime fields use DateFromInput (dual-boundary Date schema): - // - Encoded = Date | string (Kysely native AND JSON wire) - // - Type = Date (runtime) - expect(typesContent).toContain('generated(DateFromInput)'); + // DateTime fields use Schema.DateFromSelf (Type === Encoded === Date) — + // matches Prisma's contract that DateTime values are native Date instances + // and Kysely's idiomatic ColumnType pattern. + expect(typesContent).toContain('generated(Schema.DateFromSelf)'); + + // Codegen no longer auto-imports DateFromInput. The Union schema is still + // exported from the package for consumers that explicitly want it. expect(typesContent).toContain( - 'import { columnType, generated, JsonValue, DateFromInput } from "prisma-effect-kysely"' + 'import { columnType, generated, JsonValue } from "prisma-effect-kysely"' ); }); diff --git a/src/effect/generator.ts b/src/effect/generator.ts index e220920..ac69fc5 100644 --- a/src/effect/generator.ts +++ b/src/effect/generator.ts @@ -82,12 +82,13 @@ export type ${name} = typeof ${name};`; generateTypesHeader(hasEnums: boolean) { const header = generateFileHeader(); - // Import runtime helpers from prisma-effect-kysely - // columnType and generated are used for field type annotations - // DateFromInput is the dual-boundary Date schema (Date | string ↔ Date) + // Import runtime helpers from prisma-effect-kysely. + // DateTime fields use Schema.DateFromSelf (Date ↔ Date), matching + // Prisma's contract that DateTime values are native Date instances. + // Decode through a Schema.Date contract schema at JSON wire boundaries. const imports = [ `import { Schema } from "effect";`, - `import { columnType, generated, JsonValue, DateFromInput } from "prisma-effect-kysely";`, + `import { columnType, generated, JsonValue } from "prisma-effect-kysely";`, ]; if (hasEnums) { diff --git a/src/utils/type-mappings.ts b/src/utils/type-mappings.ts index 103f15d..6a324f8 100644 --- a/src/utils/type-mappings.ts +++ b/src/utils/type-mappings.ts @@ -12,14 +12,17 @@ * Prisma scalar type mapping to Effect Schema types * Uses const assertion for type safety * - * DateTime uses `DateFromInput` (this package's dual-input Date schema): - * - Type = Date (runtime) - * - Encoded = Date | string (accepts native Date from Kysely AND ISO - * string from JSON wire transport) + * DateTime uses `Schema.DateFromSelf` (Type === Encoded === Date) — matches + * Prisma's contract that Client returns DateTime as native `Date` instances + * (https://www.prisma.io/docs — "Prisma Client returns all DateTime values + * as native JavaScript Date objects"). Also matches Kysely's idiomatic + * `ColumnType` pattern: SELECT yields Date. * - * Single primitive serves both consumer boundaries — DA layer and RPC - * wire — so consumers don't need parallel schemas or boundary-specific - * helpers. Mirrors the JsonValue dual-boundary pattern. + * For RPC/JSON wire boundaries (where dates serialize to ISO strings), + * decode through a `Schema.Date`-typed contract schema at the boundary. + * Effect's Schema is single-pair (Type, Encoded) by design — see Doc 10944 + * "The Rule of Schemas" — and the dual-boundary problem is solved by + * having two schemas (DA-side vs wire-side), not one Union. */ export const PRISMA_TO_EFFECT_SCHEMA = { String: 'Schema.String', @@ -28,7 +31,7 @@ export const PRISMA_TO_EFFECT_SCHEMA = { BigInt: 'Schema.BigInt', Decimal: 'Schema.String', // For precision Boolean: 'Schema.Boolean', - DateTime: 'DateFromInput', // Date | string ↔ Date — dual-boundary safe + DateTime: 'Schema.DateFromSelf', // Date ↔ Date — Prisma+Kysely canonical Json: 'JsonValue', // Recursive JSON type — prevents null absorption in NullOr Bytes: 'Schema.Uint8Array', } as const;