Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/drop-class-standard-schema.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,15 @@ Eight source modules under `packages/entity/src`, split by what they own:
`Sealed<D>`, 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
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Organization, Issues>` 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
```

Expand Down
23 changes: 11 additions & 12 deletions packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions packages/entity/src/instance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 7 additions & 17 deletions packages/entity/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ function instanceSchema<T>(
}

/**
* 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
Expand All @@ -52,12 +52,11 @@ function instanceSchema<T>(
* 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
Expand All @@ -80,13 +79,4 @@ export function attachInstance<T>(Base: object, input: z.ZodType): void {
return built;
},
});
Object.defineProperty(Base, "~standard", {
configurable: true,
enumerable: false,
get(this: { instance: z.ZodType<T> }) {
const standard = (this.instance as unknown as { "~standard": unknown })["~standard"];
Object.defineProperty(this, "~standard", { value: standard, enumerable: false });
return standard;
},
});
}
3 changes: 0 additions & 3 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,6 @@ export type EntityStatic<
readonly instance: z.ZodType<
BaseInstance<S, A, I> & DeepReadonly<OutputOf<S, A>> & { readonly _tag: Tag }
>;
readonly "~standard": z.ZodType<
BaseInstance<S, A, I> & DeepReadonly<OutputOf<S, A>> & { readonly _tag: Tag }
>["~standard"];
/** phantom carriers, so consumers can recover the shapes for annotations */
readonly __input: InputOf<S>;
readonly __output: OutputOf<S, A>;
Expand Down
Loading