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
42 changes: 42 additions & 0 deletions .changeset/entity-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@btravstack/entity": minor
---

**Breaking.** The package now exports one name to write against: `Entity`.

`computed`, `InvalidEntity` and every public type move onto it. A bare
`computed` was too generic to take from a consumer's import scope — it collides
outright with Vue, MobX, Angular signals and Solid — and the same reasoning
already put `union` under `Entity`. Applying it consistently collapses the
surface to a single import.

| before | after |
| -------------------------------------- | ---------------------- |
| `computed` | `Entity.computed` |
| `InvalidEntity` | `Entity.InvalidEntity` |
| `Input` `Output` `CreateInput` `Patch` | `Entity.Input` … |
| `ComputedField` | `Entity.ComputedField` |
| `EntityUnion` | `Entity.Union` |

```diff
-import { Entity, computed } from "@btravstack/entity";
-import type { Output } from "@btravstack/entity";
+import { Entity } from "@btravstack/entity";

class Person extends Entity("Person")(
{ first: First, last: Last },
- { computed: { fullName: computed(FullName, (d) => …) } },
+ { computed: { fullName: Entity.computed(FullName, (d) => …) } },
) {}

-type Row = Output<typeof Person>;
+type Row = Entity.Output<typeof Person>;
```

No deprecated top-level aliases are kept.

`BaseInstance`, `ConstructionKey` and `Sealed` stay top-level exports as well as
namespace members, and are the one exception. A downstream library compiling
with `declaration: true` emits the underlying type name rather than the
namespace path that aliases it, so hiding them behind `Entity` fails that build
with `TS4020`. They were never part of the API you write against.
26 changes: 21 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ Eight source modules under `packages/entity/src`, split by what they own:
and `_tag` non-enumerably, which is why `_tag` never reaches `toJSON()`,
`JSON.stringify`, or spread. `toJSON()` is the **only** public projection —
it, `equals` and `update` all route through a module-private `project`, so
there is no second public spelling of the same data.
there is no second public spelling of the same data. It also carries the
whole public surface: `Entity.computed` / `Entity.union` /
`Entity.InvalidEntity` as expando properties, and every public type in a
merged `declare namespace Entity`. Namespace members alias imported types
through `*Src` names deliberately — see the comment there before renaming
one.
- **`freeze.ts`** — `deepFreeze`, the runtime half of immutability. Freezes
and recurses into arrays and plain objects, freezes `Date` as a leaf, and
deliberately leaves `Map`/`Set`/class instances alone. The constructor
Expand All @@ -57,14 +62,16 @@ Eight source modules under `packages/entity/src`, split by what they own:
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.
fields, and `shape()`, which builds the validated field map. Both are
internal; neither is exported from `index.ts`.
- **`issues.ts`** — `keysOf` and `renderIssue`. Standard Schema permits a path
segment to be a bare `PropertyKey` or a `{ key }` wrapper; zod emits the
bare form, and `keysOf` normalises it wherever a path meets an API wanting
plain keys.
- **`computed.ts`** / **`errors.ts`** — the `computed(schema, from)` helper and
the `InvalidEntity` tagged error. Computed fields are re-derived on every
construction path, so they cannot drift from their sources.
- **`computed.ts`** / **`errors.ts`** — the `computed(schema, from)` helper
(public as `Entity.computed`) and the `InvalidEntity` tagged error. Computed
fields are re-derived on every construction path, so they cannot drift from
their sources.

The design rule the whole package turns on: **contracts compose the four plain
`ZodObject`s; domain code composes the class itself.** The class carries a
Expand All @@ -89,6 +96,15 @@ design — `contract.spec.ts` pins that both ways.
updating the matching `@ts-expect-error` assertion.
- **One concept, one name.** The surface is meant to stay small enough that the
library can be "done". Resist convenience aliases.
- **`index.ts` exports `Entity`, and nothing else you write against.** A bare
`computed` or `union` is too generic to take from a consumer's import scope,
so everything hangs off the builder. The sole exception is `BaseInstance` /
`ConstructionKey` / `Sealed`, exported at the top level as well: a downstream
library compiling with `declaration: true` emits the _underlying_ name, not
the namespace path aliasing it, so hiding them fails the consumer pass with
`TS4020`. That is measured, not assumed — `consumer/index.ts` names every
namespace member for exactly this reason, and an **unused**
`@ts-expect-error` there is a failure signal, not noise.
- **Entities are not subclassable.** One `extends` is the declaration form;
`construct` defects on anything deeper. Behaviour goes in the entity's own
class body. This is runtime-only — TypeScript has no `final`, and
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ operation returns an [`unthrown`](https://github.com/btravstack/unthrown)

```ts
import { z } from "zod";
import { Entity, computed } from "@btravstack/entity";
import { Entity } from "@btravstack/entity";

const OrgId = z.uuid().brand("OrgId");
const Slug = z.string().min(1).brand("Slug");
Expand All @@ -27,7 +27,7 @@ class Organization extends Entity("Organization")(
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
computed: {
shout: computed(
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ change can re-check rather than re-litigate.
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { Entity, computed } from "@btravstack/entity";
> import { Entity } from "@btravstack/entity";
> ```

## What an entity is, and why this exists
Expand Down
2 changes: 1 addition & 1 deletion docs/how-to/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ from the model.
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { Entity, computed } from "@btravstack/entity";
> import { Entity } from "@btravstack/entity";
> ```

## Use the four `ZodObject` members directly
Expand Down
4 changes: 2 additions & 2 deletions docs/how-to/model-an-aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ entity rather than a bare schema.
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { Entity, computed } from "@btravstack/entity";
> import { Entity } from "@btravstack/entity";
> ```

## Use the class as a field
Expand All @@ -21,7 +21,7 @@ class Customer extends Entity("Customer")(
{ id: CustomerId, name: Name },
{
computed: {
shout: computed(
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
Expand Down
6 changes: 2 additions & 4 deletions docs/how-to/persist-and-rehydrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ without the storage layer knowing about entity internals.
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { Entity, computed } from "@btravstack/entity";
> import { Entity } from "@btravstack/entity";
> ```

## Write with `toJSON()`
Expand Down Expand Up @@ -72,9 +72,7 @@ The type helpers name each shape, so a repository signature never restates the
model:

```ts
import type { Output } from "@btravstack/entity";

type OrganizationRow = Output<typeof Organization>;
type OrganizationRow = Entity.Output<typeof Organization>;

interface OrganizationRepository {
save(org: Organization): Promise<void>;
Expand Down
2 changes: 1 addition & 1 deletion docs/how-to/test-domain-logic.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ tests without stubbing `Date.now` or `crypto.randomUUID`.
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { Entity, computed } from "@btravstack/entity";
> import { Entity } from "@btravstack/entity";
> ```

## Bind fixed generators instead of stubbing globals
Expand Down
40 changes: 27 additions & 13 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Every member of the public surface. For _why_ it is shaped this way, see
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { Entity, computed } from "@btravstack/entity";
> import { Entity } from "@btravstack/entity";
> ```

## `Entity(tag)(fields, options?)`
Expand Down Expand Up @@ -124,14 +124,14 @@ Compares the serialised form, so entities holding equal arrays compare equal.
Two separate `Entity(...)` calls never compare equal, even with identical
fields.

## `computed(schema, from)`
## `Entity.computed(schema, from)`

One derived field: its schema, and the function producing it.

```ts
computed: {
fullName: computed(FullName, (d) => `${d.first} ${d.last}` as z.infer<typeof FullName>),
initials: computed(Initials, (d) => `${d.first[0]}${d.last[0]}` as z.infer<typeof Initials>),
fullName: Entity.computed(FullName, (d) => `${d.first} ${d.last}` as z.infer<typeof FullName>),
initials: Entity.computed(Initials, (d) => `${d.first[0]}${d.last[0]}` as z.infer<typeof Initials>),
}
```

Expand Down Expand Up @@ -176,7 +176,7 @@ Member.discriminant; // "kind"
on it rather than trying each branch, so a failing member reports its own
issues. The union is a schema too, so it nests as a field.

## `InvalidEntity`
## `Entity.InvalidEntity`

```ts
class InvalidEntity extends TaggedError("InvalidEntity")<{
Expand All @@ -185,6 +185,11 @@ class InvalidEntity extends TaggedError("InvalidEntity")<{
}> {}
```

Reachable as both a value and a type — `e instanceof Entity.InvalidEntity` and
`const e: Entity.InvalidEntity`. The signatures above write it unqualified, the
way `SomeEntity` is also a stand-in; `Entity.InvalidEntity` is how you spell it.
Matching by tag needs no import at all: `P.tag("InvalidEntity")`.

Schema failures carry the failing field's `path`; an `invariants` violation has
none — that absence distinguishes a whole-entity rule from a field complaint.

Expand All @@ -200,14 +205,23 @@ none — that absence distinguishes a whole-entity rule from a field complaint.
## Helper types

```ts
import type { CreateInput, Input, Output, Patch } from "@btravstack/entity";
import { Entity } from "@btravstack/entity";

type OrgWire = Input<typeof Organization>; // what make() accepts
type OrgState = Output<typeof Organization>; // what toJSON() returns
type OrgCreate = CreateInput<typeof Organization>; // what a factory accepts
type OrgPatch = Patch<typeof Organization>; // what update() accepts
type OrgWire = Entity.Input<typeof Organization>; // what make() accepts
type OrgState = Entity.Output<typeof Organization>; // what toJSON() returns
type OrgCreate = Entity.CreateInput<typeof Organization>; // what a factory accepts
type OrgPatch = Entity.Patch<typeof Organization>; // what update() accepts
```

`BaseInstance`, `ConstructionKey` and `Sealed` are also exported, but only so a
consumer's emitted declarations can name them. They are not part of the API you
write against.
Also `Entity.ComputedField` and `Entity.Union`, the shapes `Entity.computed` and
`Entity.union` return.

`BaseInstance`, `ConstructionKey` and `Sealed` are the one exception to the
single-import rule: they are exported at the top level **as well as** under
`Entity`, because a downstream library compiling with `declaration: true` emits
the underlying name rather than the namespace path that aliases it, and would
otherwise fail with `TS4020`. They are not part of the API you write against.

```ts
import type { BaseInstance, ConstructionKey, Sealed } from "@btravstack/entity";
```
Loading
Loading