diff --git a/.changeset/drop-class-standard-schema.md b/.changeset/drop-class-standard-schema.md new file mode 100644 index 0000000..79e695c --- /dev/null +++ b/.changeset/drop-class-standard-schema.md @@ -0,0 +1,17 @@ +--- +"@btravstack/entity": minor +--- + +**BREAKING**: the entity class no longer carries `~standard`. + +It bought one thing — `fromSchema(Organization)` in place of +`fromSchema(Organization.instance)` — while making the class a validator in +some contexts and not others: `z.object({ owner: Organization })` never +worked, because zod needs a real `ZodType`. Two spellings of one concept that +were not interchangeable. + +`instance` is a zod schema and zod implements Standard Schema, so it already +carries `~standard` and is accepted by anything that takes one. + +Migration: `fromSchema(Organization)` → `fromSchema(Organization.instance)`, +which is the spelling that works everywhere rather than most places. diff --git a/CLAUDE.md b/CLAUDE.md index 9ca6771..3657ad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,10 +66,15 @@ Eight source modules under `packages/entity/src`, split by what they own: `Sealed`, the module-private `unique symbol` that makes `new X(...)` a compile error. Written independently of the builder's body-local values so `EntityStatic` can serve as the builder's explicit return annotation. -- **`instance.ts`** — `attachInstance` installs `instance` and `~standard` as - lazy, self-overwriting accessor getters. The getter (not a plain value) is - what makes `X.instance.parse(...)` build an `X` rather than the base class, - since the subclass does not exist when the builder runs. +- **`instance.ts`** — `attachInstance` installs `instance` as a lazy, + self-overwriting accessor getter. The getter (not a plain value) is what + makes `X.instance.parse(...)` build an `X` rather than the base class, since + the subclass does not exist when the builder runs. `instance` is a zod + schema, so it is already a Standard Schema; the class carries no `~standard` + of its own. +- **`union.ts`** — `Entity.union(discriminant, members)`. Dispatches on the + declared discriminant rather than trying each branch, so a failing member + reports its own issues. - **`shape.ts`** — `OnlyNominal`, the type-level check rejecting unbranded fields, and `shape()`, the only sanctioned way to build a domain object. - **`issues.ts`** — `keysOf` and `renderIssue`. Standard Schema permits a path diff --git a/README.md b/README.md index cc3e9a2..eaf4e15 100644 --- a/README.md +++ b/README.md @@ -186,15 +186,14 @@ const Aggregate = z.object({ organization: Organization.instance }); Aggregate.parse(raw).organization instanceof Organization; // true ``` -`instance` is also a [Standard Schema](https://standardschema.dev) — the -class itself carries a non-enumerable `~standard` property delegating to -`instance`, so `@unthrown/standard-schema`'s `fromSchema(Organization)` -returns a `(raw) => Result` validator directly: +`instance` is also a [Standard Schema](https://standardschema.dev), being a +zod schema, so it hands straight to anything that accepts one — a router, a +form library, `@unthrown/standard-schema`'s `fromSchema`: ```ts import { fromSchema } from "@unthrown/standard-schema"; -const parseOrg = fromSchema(Organization); +const parseOrg = fromSchema(Organization.instance); parseOrg(raw).getOrThrow(); // Organization ``` diff --git a/packages/entity/README.md b/packages/entity/README.md index ad79e36..265f651 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -62,18 +62,17 @@ class name it labels, ahead of the field map. ## Statics -| Static | Kind | Purpose | -| -------------------- | --------------- | --------------------------------------------------------------------------------- | -| `entityName` | `string` | the tag passed to `Entity(tag)` | -| `input` | `ZodObject` | the full wire object | -| `output` | `ZodObject` | stored state and response body | -| `createInput` | `ZodObject` | create request — `input` minus `generated` | -| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | -| `instance` | `ZodType` | parses straight to a class instance; valid as a field, so aggregates are entities | -| `~standard` | Standard Schema | `instance`'s Standard Schema entry point | -| `make(state)` | method | already-stored state → entity, for row mappers and event folds | -| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | -| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | +| Static | Kind | Purpose | +| -------------------- | ----------- | --------------------------------------------------------------------------------- | +| `entityName` | `string` | the tag passed to `Entity(tag)` | +| `input` | `ZodObject` | the full wire object | +| `output` | `ZodObject` | stored state and response body | +| `createInput` | `ZodObject` | create request — `input` minus `generated` | +| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | +| `instance` | `ZodType` | parses straight to a class instance; valid as a field, so aggregates are entities | +| `make(state)` | method | already-stored state → entity, for row mappers and event folds | +| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | +| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | **Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`); domain code composes `instance`.** All four `ZodObject`s diff --git a/packages/entity/src/instance.spec.ts b/packages/entity/src/instance.spec.ts index a6270c0..a2a9dea 100644 --- a/packages/entity/src/instance.spec.ts +++ b/packages/entity/src/instance.spec.ts @@ -37,19 +37,22 @@ test("a nested invariant failure names the failing member in the issue path", () } }); -test("the class itself is a Standard Schema", () => { - const parse = fromSchema(Organization); +test("instance is the Standard Schema entry point", () => { + const parse = fromSchema(Organization.instance); expect(parse(raw).getOrThrow()).toBeInstanceOf(Organization); expect(parse({ id: "nope", slug: "" }).isErr()).toBe(true); }); -test("the standard-schema property is not enumerable", () => { - expect(Object.keys(Organization)).not.toContain("~standard"); +test("instance is not enumerable on the class", () => { + expect(Object.keys(Organization)).not.toContain("instance"); }); -test("instance and ~standard are built once and reused", () => { +test("instance is built once and reused", () => { expect(Organization.instance).toBe(Organization.instance); - expect(Organization["~standard"]).toBe(Organization["~standard"]); +}); + +test("instance is a Standard Schema, so it is what a framework receives", () => { + expect("~standard" in (Organization.instance as object)).toBe(true); }); test("a defect during make propagates instead of becoming a validation issue", () => { diff --git a/packages/entity/src/instance.ts b/packages/entity/src/instance.ts index f8fb3c7..3bf3af6 100644 --- a/packages/entity/src/instance.ts +++ b/packages/entity/src/instance.ts @@ -42,8 +42,8 @@ function instanceSchema( } /** - * Attaches `instance` and `~standard` to an entity class as lazily - * computed, self-overwriting accessor properties. + * Attaches `instance` to an entity class as a lazily computed, + * self-overwriting accessor property. * * The class is only ever consumed through a subclass (`class X extends * Entity(tag)(fields) {}`), which does not exist yet when the entity builder @@ -52,12 +52,11 @@ function instanceSchema( * failing `instanceof X`. A getter instead reads `this` from the access * site (`X.instance`), which JS's prototype-based static inheritance sets * to the actual receiver, so it binds to whichever subclass it was read - * from — but a bare getter would rebuild the schema (and its `~standard`) - * on every access, so `X.instance !== X.instance` and every `validate()` - * call would reconstruct the whole transform chain. Each getter therefore - * overwrites itself with a plain, non-enumerable data property on the same - * receiver the first time it runs, so later reads are free and identity is - * stable. + * from — but a bare getter would rebuild the schema on every access, so + * `X.instance !== X.instance` and every parse would reconstruct the whole + * transform chain. The getter therefore overwrites itself with a plain, + * non-enumerable data property on the same receiver the first time it runs, + * so later reads are free and identity is stable. * * Caveat: the self-overwrite is first-read-wins *per receiver*, not per * class. `attachInstance` runs once per `Entity(...)` call, so every entity @@ -80,13 +79,4 @@ export function attachInstance(Base: object, input: z.ZodType): void { return built; }, }); - Object.defineProperty(Base, "~standard", { - configurable: true, - enumerable: false, - get(this: { instance: z.ZodType }) { - const standard = (this.instance as unknown as { "~standard": unknown })["~standard"]; - Object.defineProperty(this, "~standard", { value: standard, enumerable: false }); - return standard; - }, - }); } diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index c43b888..add3cb3 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -276,9 +276,6 @@ export type EntityStatic< readonly instance: z.ZodType< BaseInstance & DeepReadonly> & { readonly _tag: Tag } >; - readonly "~standard": z.ZodType< - BaseInstance & DeepReadonly> & { readonly _tag: Tag } - >["~standard"]; /** phantom carriers, so consumers can recover the shapes for annotations */ readonly __input: InputOf; readonly __output: OutputOf;