Skip to content

Commit ded938b

Browse files
samuelho-devclaude
andcommitted
feat: warn on Effect 3 syntax in @customType; docs + cleanup
The cascade of "GeneratedSchema not assignable to Schema.Top" errors a consumer hit was NOT a helper-type defect — it was a version-resolution trap ('prisma-effect-kysely': '*' resolves to the stable 5.x line, not the 6.0.0-next prerelease, because npm/pnpm exclude prereleases from ranges) plus v3-syntax @customType annotations passed through verbatim. The helper types were correct as published in 6.0.0-next.1. Generator-side response (warn-only, never rewrite — transforming arbitrary user TS is unsafe): - detectLegacyEffectV3Syntax() in src/utils/annotations.ts: matches known v3 @customType patterns (filters now under .check(Schema.is*); variadic Union/Tuple/multi-Literal now take arrays; removed Schema.UUID/DateFromSelf; optionalWith). The orchestrator scans every field's @customType at generate time and prints field-level v4 upgrade hints to stderr. - README: v3->v4 @customType table + the '*'-resolves-to-stable gotcha (pin exact 6.0.0-next.x or use overrides/resolutions). Docs accuracy: DB interface is Schema.Schema.Type<typeof Model> (not Selectable<Model>); Json maps to recursive JsonValue (not Schema.Unknown); Type-safety note scopes the load-bearing helper casts vs cast-free output. Cleanup: remove dead internal type aliases (MutableInsert/MutableUpdate == CustomInsertable/CustomUpdateable; ExtractInsertBaseType == ExtractInsertType). Bump dev/test effect pin to 4.0.0-beta.70. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a02a05b commit ded938b

9 files changed

Lines changed: 349 additions & 58 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'prisma-effect-kysely': patch
3+
---
4+
5+
Warn on Effect 3 syntax in `@customType` annotations.
6+
7+
`@customType(...)` expressions are emitted verbatim, so an Effect 3 expression
8+
(e.g. `Schema.Number.pipe(Schema.int(), Schema.between(1, 5))` or the variadic
9+
`Schema.Union(A, B)`) compiles against Effect 3 but breaks against Effect 4. The
10+
generator now scans `@customType` strings at `prisma generate` time and prints a
11+
warning that names the field and the v4 replacement — for filters
12+
(`Schema.int()``Schema.check(Schema.isInt())`, `Schema.between(a, b)`
13+
`Schema.check(Schema.isBetween({ minimum, maximum }))`, etc.), variadic
14+
combinators (`Schema.Union(a, b)``Schema.Union([a, b])`), and removed schemas
15+
(`Schema.UUID`, `Schema.DateFromSelf`). It only warns; it never rewrites the
16+
expression (regex-transforming arbitrary user TypeScript is unsafe).
17+
18+
Also bumps the dev/test Effect pin to `4.0.0-beta.70` and documents that
19+
consumers must pin the exact `6.0.0-next.x` version — a `"*"` range resolves to
20+
the stable `5.x` line because npm/pnpm exclude prereleases from version ranges.

CLAUDE.md

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Three files in the configured output directory:
5050
- **types.ts** — direct exports (no underscore prefix, no wrapper functions):
5151
- Branded ID schema + type per model
5252
- Model `Schema.Struct` + type alias
53-
- `DB` interface using `Selectable<Model>` per table (respects `@@map`)
53+
- `DB` interface using `Schema.Schema.Type<typeof Model>` per table (preserves the `__select__/__insert__/__update__` brands Kysely needs; respects `@@map`)
5454
- **index.ts** — re-exports
5555

5656
## Generated shape
@@ -67,7 +67,7 @@ export const User = Schema.Struct({
6767
export type User = typeof User;
6868

6969
export interface DB {
70-
User: Selectable<User>;
70+
User: Schema.Schema.Type<typeof User>;
7171
}
7272
```
7373

@@ -97,36 +97,37 @@ Prisma stores `A`/`B` columns; we emit semantic snake_case fields on the struct,
9797

9898
## Type mappings
9999

100-
| Prisma | Effect | Notes |
101-
| ----------- | -------------------- | --------------------------------------------- |
102-
| String | `Schema.String` | UUID → `Schema.String.check(Schema.isUUID())` |
103-
| Int / Float | `Schema.Number` | |
104-
| BigInt | `Schema.BigInt` | native bigint encode (no string coercion) |
105-
| Decimal | `Schema.String` | precision |
106-
| Boolean | `Schema.Boolean` | |
107-
| DateTime | `Schema.Date` | native Date both sides (Effect 4) |
108-
| Json | `Schema.Unknown` | |
109-
| Bytes | `Schema.Uint8Array` | |
110-
| Enum | imported enum schema | `Schema.Enum(...)` |
100+
| Prisma | Effect | Notes |
101+
| ----------- | --------------------- | --------------------------------------------- |
102+
| String | `Schema.String` | UUID → `Schema.String.check(Schema.isUUID())` |
103+
| Int / Float | `Schema.Number` | |
104+
| BigInt | `Schema.BigInt` | native bigint encode (no string coercion) |
105+
| Decimal | `Schema.String` | precision |
106+
| Boolean | `Schema.Boolean` | |
107+
| DateTime | `Schema.Date` | native Date both sides (Effect 4) |
108+
| Json | recursive `JsonValue` | from `prisma-effect-kysely` |
109+
| Bytes | `Schema.Uint8Array` | |
110+
| Enum | imported enum schema | `Schema.Enum(...)` |
111111

112112
Arrays → `Schema.Array(t)`. Nullable → `Schema.NullOr(t)`.
113113

114114
## Type safety principles
115115

116-
- Zero coercion — exact DMMF types, no `as` casts
116+
- Zero coercion in DMMF→type mapping — exact DMMF types, no string parsing of types
117+
- The runtime `Selectable`/`Insertable`/`Updateable` helpers use `as unknown as` casts (load-bearing: a dynamically-rebuilt `Schema.Struct` has no statically-checkable shape — see the note above the Schema Functions section in `helpers.ts`); generated OUTPUT contains no casts
117118
- UUID detection from DMMF, not string parsing
118119
- Field defaults validated via DMMF structure
119120
- Strict mode (tsconfig)
120121

121122
## Package exports
122123

123-
| Entry | Contents |
124-
| ------------- | ------------------------------------------------- |
125-
| `.` | `Selectable`, `Insertable`, `Updateable`, helpers |
126-
| `./generator` | Prisma generator binary entry |
127-
| `./kysely` | `getSchemas`, `columnType`, `generated`, types |
128-
| `./error` | `NotFoundError`, `QueryError`, `DatabaseError` |
129-
| `./runtime` | All runtime utilities |
124+
| Entry | Contents |
125+
| ------------- | ------------------------------------------------------------------------------------- |
126+
| `.` | `Selectable`, `Insertable`, `Updateable`, helpers |
127+
| `./generator` | Prisma generator binary entry |
128+
| `./kysely` | `columnType`, `generated`, `JsonValue`, `Selectable`/`Insertable`/`Updateable`, types |
129+
| `./error` | `NotFoundError`, `QueryError`, `DatabaseError` |
130+
| `./runtime` | All runtime utilities |
130131

131132
## Testing patterns
132133

@@ -150,6 +151,8 @@ can `Effect.catchTag` cleanly.
150151
- Generator must be rebuilt before `prisma generate` picks up changes
151152
- Test fixtures: `src/__tests__/fixtures/test.prisma`
152153
- Generated headers include timestamp + edit warning
153-
- Direct exports only — never reintroduce underscore prefixes or wrapper functions in generated code (`getSchemas` remains in runtime API, not in output)
154+
- Direct exports only — generated code exports schemas directly (`export const User = Schema.Struct(...)`); never reintroduce underscore prefixes or wrapper functions in the output
154155
- Run `bun run test:emit` after touching any generator emit string — it generates against the fixture and type-checks the emitted code against the installed Effect (unit tests only string-match, they don't compile output)
155-
- `effect` targets **4.x (beta)**, peer dep `^4.0.0-beta`, dev pin `4.0.0-beta.68`. `src/kysely/helpers.ts` uses the public `Schema.Struct.fields` API (not `effect/SchemaAST` internals — those were reworked in v4). Key v4 names: `Schema.Codec` (was `Schema.Schema`), `Schema.Top` (was `Schema.Schema.All`), `Schema.revealCodec` (was `asSchema`), `.annotate()` (was `.annotations()`), `Schema.Date` (was `DateFromSelf`), `Schema.Union([...])` (was variadic), `Schema.encodeKeys` (was `propertySignature(...).pipe(fromKey(...))`).
156+
- `effect` targets **4.x (beta)**, peer dep `^4.0.0-beta`, dev pin `4.0.0-beta.70`. `src/kysely/helpers.ts` uses the public `Schema.Struct.fields` API (not `effect/SchemaAST` internals — those were reworked in v4). Key v4 names: `Schema.Codec` (was `Schema.Schema`), `Schema.Top` (was `Schema.Schema.All`), `Schema.revealCodec` (was `asSchema`), `.annotate()` (was `.annotations()`), `Schema.Date` (was `DateFromSelf`), `Schema.Union([...])` (was variadic), `Schema.encodeKeys` (was `propertySignature(...).pipe(fromKey(...))`).
157+
- `@customType(...)` strings are emitted verbatim and must be valid Effect 4 syntax. `detectLegacyEffectV3Syntax()` in `src/utils/annotations.ts` warns (never rewrites) on known v3 patterns at generate time. v3 filters → `.check(Schema.is*)`; variadic `Union`/`Tuple`/multi-`Literal` → array form.
158+
- Consumers must pin the exact `6.0.0-next.x` version — a `"*"` range resolves to the stable `5.x` line (npm/pnpm exclude prereleases from ranges), which silently pulls the v3 types.

README.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,18 @@ bun add prisma-effect-kysely@next effect@beta
2222

2323
> **Effect 4 support is a pre-release.** It requires `effect@^4.0.0-beta` and is
2424
> published under the `next` dist-tag, not `latest`. It is **tested against
25-
> `effect@4.0.0-beta.68`**; later betas may introduce breaking Schema changes. A
25+
> `effect@4.0.0-beta.70`**; later betas may introduce breaking Schema changes. A
2626
> generator-output compile check (`bun run test:emit`) guards the emitted code
2727
> against the installed Effect, but pin `effect` if you need stability during the
2828
> beta. The `next` line will be promoted to `latest` when Effect 4 goes stable.
2929
>
30+
> **Pin the exact version — do not use `"*"` or `"latest"`.** A `"prisma-effect-kysely": "*"`
31+
> (or any range) dependency resolves to the **stable** `5.x` line, NOT the
32+
> pre-release, because npm/pnpm semver excludes prereleases from ranges. In a
33+
> workspace, depend on `"6.0.0-next.x"` exactly (or add a `pnpm.overrides` /
34+
> `resolutions` entry). Symptom of getting this wrong: the v3 `5.x` types resolve
35+
> and consumers see cascading `Schema.Top` / `unknown` type errors.
36+
>
3037
> Effect 4 and Effect 3 are not interchangeable: the generated output uses
3138
> Effect-4-only Schema APIs (`Schema.Date`, `Schema.String.check(Schema.isUUID())`,
3239
> `Schema.encodeKeys`, `Schema.Enum`, …). Stay on the `latest` line if you are on
@@ -135,6 +142,22 @@ model User {
135142

136143
Supported on all Prisma scalar types.
137144

145+
`@customType(...)` expressions are emitted **verbatim** — they must be valid
146+
Effect 4 syntax. The generator does not rewrite them, but it **warns** at
147+
`prisma generate` time when it detects Effect 3 syntax, pointing at the v4 form.
148+
Effect 4 moved all filters under `.check(Schema.is*)` and made the variadic
149+
combinators take an array:
150+
151+
| Effect 3 (`@customType`) | Effect 4 |
152+
| ------------------------------------- | ---------------------------------------------------------------------- |
153+
| `Schema.Number.pipe(Schema.int())` | `Schema.Number.pipe(Schema.check(Schema.isInt()))` |
154+
| `Schema.positive()` | `Schema.check(Schema.isGreaterThan(0))` |
155+
| `Schema.between(1, 5)` | `Schema.check(Schema.isBetween({ minimum: 1, maximum: 5 }))` |
156+
| `Schema.minLength(3)` | `Schema.check(Schema.isMinLength(3))` |
157+
| `Schema.Union(A, B)` | `Schema.Union([A, B])` |
158+
| `Schema.Literal('a', 'b')` | `Schema.Literals(['a', 'b'])` (single `Schema.Literal('x')` unchanged) |
159+
| `Schema.UUID` / `Schema.DateFromSelf` | `Schema.String.check(Schema.isUUID())` / `Schema.Date` |
160+
138161
## Implicit M2M Join Tables
139162

140163
Prisma columns `A`/`B` map to semantic snake_case fields via `Schema.encodeKeys`:
@@ -148,13 +171,13 @@ export const ProductToProductTag = Schema.Struct({
148171

149172
## Package Exports
150173

151-
| Entry | Contents |
152-
| -------------------------------- | --------------------------------------------------- |
153-
| `prisma-effect-kysely` | Type utilities + runtime helpers (default import) |
154-
| `prisma-effect-kysely/generator` | Prisma generator binary entry |
155-
| `prisma-effect-kysely/kysely` | `getSchemas`, `columnType`, `generated`, type utils |
156-
| `prisma-effect-kysely/error` | `NotFoundError`, `QueryError`, `DatabaseError` |
157-
| `prisma-effect-kysely/runtime` | All runtime utilities |
174+
| Entry | Contents |
175+
| -------------------------------- | -------------------------------------------------- |
176+
| `prisma-effect-kysely` | Type utilities + runtime helpers (default import) |
177+
| `prisma-effect-kysely/generator` | Prisma generator binary entry |
178+
| `prisma-effect-kysely/kysely` | `columnType`, `generated`, `JsonValue`, type utils |
179+
| `prisma-effect-kysely/error` | `NotFoundError`, `QueryError`, `DatabaseError` |
180+
| `prisma-effect-kysely/runtime` | All runtime utilities |
158181

159182
## Development
160183

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"@typescript-eslint/parser": "^8.59.1",
111111
"@vitest/coverage-v8": "^4.1.5",
112112
"@vitest/eslint-plugin": "^1.6.16",
113-
"effect": "4.0.0-beta.68",
113+
"effect": "4.0.0-beta.70",
114114
"eslint": "^10.0.0",
115115
"eslint-import-resolver-typescript": "^4.4.4",
116116
"eslint-plugin-import": "^2.32.0",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { detectLegacyEffectV3Syntax } from '../utils/annotations';
3+
4+
describe('detectLegacyEffectV3Syntax', () => {
5+
describe('flags Effect 3 filter syntax', () => {
6+
it('Schema.int()', () => {
7+
const hints = detectLegacyEffectV3Syntax('Schema.Number.pipe(Schema.int())');
8+
expect(hints).toHaveLength(1);
9+
expect(hints[0]).toContain('Schema.check(Schema.isInt())');
10+
});
11+
12+
it('Schema.between() — and notes the v4 object-arg shape', () => {
13+
const hints = detectLegacyEffectV3Syntax('Schema.Number.pipe(Schema.between(1, 5))');
14+
expect(hints[0]).toContain('isBetween({ minimum, maximum })');
15+
});
16+
17+
it('Schema.positive() maps to isGreaterThan(0)', () => {
18+
const hints = detectLegacyEffectV3Syntax('Schema.Number.pipe(Schema.positive())');
19+
expect(hints[0]).toContain('Schema.check(Schema.isGreaterThan(0))');
20+
});
21+
22+
it('the real creativetoolkits case: int + between together → two hints', () => {
23+
const hints = detectLegacyEffectV3Syntax(
24+
'Schema.Number.pipe(Schema.int(), Schema.between(1, 5))'
25+
);
26+
expect(hints).toHaveLength(2);
27+
});
28+
29+
it('greaterThan is matched without also matching greaterThanOrEqualTo', () => {
30+
const gt = detectLegacyEffectV3Syntax('Schema.Number.pipe(Schema.greaterThan(0))');
31+
expect(gt).toHaveLength(1);
32+
expect(gt[0]).toContain('isGreaterThan(n)');
33+
const gte = detectLegacyEffectV3Syntax('Schema.Number.pipe(Schema.greaterThanOrEqualTo(0))');
34+
expect(gte).toHaveLength(1);
35+
expect(gte[0]).toContain('isGreaterThanOrEqualTo(n)');
36+
});
37+
});
38+
39+
describe('flags variadic combinators (v4 takes an array)', () => {
40+
it('Schema.Union(a, b) — the real licensing.prisma case', () => {
41+
const hints = detectLegacyEffectV3Syntax('Schema.Union(SellerId, UserId)');
42+
expect(hints).toHaveLength(1);
43+
expect(hints[0]).toContain('Schema.Union([a, b, …])');
44+
});
45+
46+
it('Schema.Tuple(a, b)', () => {
47+
const hints = detectLegacyEffectV3Syntax('Schema.Tuple(Schema.String, Schema.Number)');
48+
expect(hints[0]).toContain('Schema.Tuple([a, b, …])');
49+
});
50+
51+
it('multi-arg Schema.Literal → Literals', () => {
52+
const hints = detectLegacyEffectV3Syntax("Schema.Literal('a', 'b')");
53+
expect(hints[0]).toContain('Schema.Literals([a, b, …])');
54+
});
55+
});
56+
57+
describe('flags removed/renamed schemas', () => {
58+
it('Schema.DateFromSelf', () => {
59+
expect(detectLegacyEffectV3Syntax('Schema.DateFromSelf')[0]).toContain('Schema.Date');
60+
});
61+
it('Schema.UUID', () => {
62+
expect(detectLegacyEffectV3Syntax('Schema.UUID')[0]).toContain(
63+
'Schema.String.check(Schema.isUUID())'
64+
);
65+
});
66+
it('Schema.optionalWith', () => {
67+
expect(
68+
detectLegacyEffectV3Syntax('Schema.optionalWith(Schema.String, { exact: true })')[0]
69+
).toContain('Schema.optionalKey');
70+
});
71+
});
72+
73+
describe('does NOT flag valid v4 syntax (no false positives)', () => {
74+
it.each([
75+
'Schema.String',
76+
'Schema.Number.check(Schema.isInt())',
77+
'Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 5 }))',
78+
'Schema.String.check(Schema.isUUID()).pipe(Schema.brand("UserId"))',
79+
'Schema.Union([SellerId, UserId])',
80+
'Schema.Literals(["a", "b"])',
81+
"Schema.Literal('single')", // single literal is unchanged in v4
82+
'Schema.Array(Schema.Number).check(Schema.isLengthBetween(3, 3))',
83+
'Schema.Date',
84+
])('clean: %s', (expr) => {
85+
expect(detectLegacyEffectV3Syntax(expr)).toEqual([]);
86+
});
87+
});
88+
});

src/generator/orchestrator.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { GeneratorOptions } from '@prisma/generator-helper';
22
import { EffectGenerator } from '../effect/generator.js';
33
import { KyselyGenerator } from '../kysely/generator.js';
44
import { PrismaGenerator } from '../prisma/generator.js';
5+
import { detectLegacyEffectV3Syntax, extractEffectTypeOverride } from '../utils/annotations.js';
56
import { FileManager } from '../utils/file-manager.js';
67
import {
78
type GeneratorConfig,
@@ -47,6 +48,7 @@ export class GeneratorOrchestrator {
4748
*/
4849
async generate(options: GeneratorOptions) {
4950
this.logStart(options);
51+
this.warnLegacyAnnotations(options);
5052

5153
// Check if multi-domain mode is enabled
5254
if (isMultiDomainEnabled(this.config)) {
@@ -244,6 +246,32 @@ export class GeneratorOrchestrator {
244246
}
245247
}
246248

249+
/**
250+
* Scan every field's `@customType(...)` annotation for Effect 3 syntax and warn
251+
* (to stderr, which Prisma surfaces to the user). `@customType` strings are
252+
* emitted verbatim, so a v3 expression silently breaks once compiled against
253+
* Effect 4. We never rewrite — only point at the v4 replacement.
254+
*/
255+
private warnLegacyAnnotations(options: GeneratorOptions) {
256+
const warnings: string[] = [];
257+
for (const model of options.dmmf.datamodel.models) {
258+
for (const field of model.fields) {
259+
const override = extractEffectTypeOverride(field);
260+
if (!override) continue;
261+
for (const hint of detectLegacyEffectV3Syntax(override)) {
262+
warnings.push(` ${model.name}.${field.name}: ${hint}`);
263+
}
264+
}
265+
}
266+
267+
if (warnings.length > 0) {
268+
console.warn(
269+
`[prisma-effect-kysely] @customType annotations use Effect 3 syntax that does not ` +
270+
`compile against Effect 4. Update them in your Prisma schema:\n${warnings.join('\n')}`
271+
);
272+
}
273+
}
274+
247275
/**
248276
* Log generation completion
249277
*/

0 commit comments

Comments
 (0)