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

**BREAKING**: a factory is a function, not an object with `.create`.

```ts
// before
const orgs = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});
orgs.create({ slug, name });

// after
const createOrg = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});
createOrg({ slug, name });
```

Nothing but `create` ever consumed the generators, so the object around it was
ceremony. `factoryAsync` changes the same way.

Migration: drop `.create`.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ design — `contract.spec.ts` pins that both ways.
`private`/`protected` constructors were measured to break the declaration
form (TS2675) and the statics (TS2684) respectively.
- **No I/O.** The package reads no clock and generates no id. `create` lives on
a factory (`Entity.factory(generators)` / `factoryAsync`), bound at the
a factory (`Entity.factory(generators)` / `factoryAsync`) — a function you
call with the caller's fields — bound at the
composition root; generators are functions, called once per `create`. A
rejecting async generator is a Defect, not an `InvalidEntity`.
- `zod`, `unthrown` and `@unthrown/standard-schema` are peer dependencies to
Expand Down
32 changes: 15 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,16 @@ class Organization extends Entity("Organization")(

// Bind the effect sources once, at the composition root. The entity itself
// never reads a clock or generates an id — see "No I/O" below.
const orgs = Organization.factory({
const createOrg = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});

// A create use case: the caller supplies only request fields.
const org = orgs
.create({
slug: "acme" as z.infer<typeof Slug>,
name: "Acme" as z.infer<typeof DisplayName>,
})
.getOrThrow();
const org = createOrg({
slug: "acme" as z.infer<typeof Slug>,
name: "Acme" as z.infer<typeof DisplayName>,
}).getOrThrow();

org.slug; // "acme" — typed, read-only
org.update({ name: "Acme Inc" as z.infer<typeof DisplayName> }); // a NEW entity; Result<Organization, InvalidEntity>
Expand Down Expand Up @@ -215,11 +213,11 @@ Organization.optional(); // ✗ does not exist

## The three entry points

| Entry point | Input | Use |
| ------------------------------------ | ------------------------------- | ------------------------------------------------------------------ |
| `Entity.factory(gens).create(input)` | caller fields only | a create use case |
| `entity.update(patch)` | a partial of the mutable fields | an update use case |
| `Entity.make(data)` | everything `input` describes | a row, a folded event stream, an untrusted import, a nested entity |
| Entry point | Input | Use |
| ----------------------------- | ------------------------------- | ------------------------------------------------------------------ |
| `Entity.factory(gens)(input)` | caller fields only | a create use case |
| `entity.update(patch)` | a partial of the mutable fields | an update use case |
| `Entity.make(data)` | everything `input` describes | a row, a folded event stream, an untrusted import, a nested entity |

`create` is the one reached through a factory, because it is the only one that
needs values the domain generates rather than receives.
Expand All @@ -228,13 +226,13 @@ The types and the schemas are derived from the same declarations, so the
rules are compile-time facts:

```ts
const orgs = Organization.factory({
const createOrg = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});

orgs.create({ slug, name }); // ✓
orgs.create({ slug, name, id }); // ✗ id is generated
createOrg({ slug, name }); // ✓
createOrg({ slug, name, id }); // ✗ id is generated

org.update({ name }); // ✓ Result<Organization, InvalidEntity>
org.update({ id }); // ✗ id is immutable
Expand Down Expand Up @@ -723,11 +721,11 @@ generator that _rejects_ surfaces as a `Defect`: infrastructure failing is not
the same as bad domain input.

```ts
const orgs = Organization.factoryAsync({
const createOrgAsync = Organization.factoryAsync({
id: () => ids.nextFromSequence(),
createdAt: () => clock.now(),
});
(await orgs.create({ slug, name })).getOrThrow();
(await createOrgAsync({ slug, name })).getOrThrow();
```

## Peer dependencies
Expand Down
8 changes: 4 additions & 4 deletions packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ class Organization extends Entity("Organization")(
) {}

// effect sources bound once, at the composition root
const orgs = Organization.factory({
const createOrg = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});

const org = orgs.create({ slug, name }).getOrThrow();
const org = createOrg({ slug, name }).getOrThrow();

org.update({ name: newName }); // a NEW entity; immutable fields rejected at compile time
Organization.make(row); // row mappers and event folds
Expand Down Expand Up @@ -71,8 +71,8 @@ class name it labels, ahead of the field map.
| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial |
| _the class itself_ | zod schema | parses to a class instance; valid as a field and anywhere zod takes a schema |
| `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 }` |
| `factory(gens)` | method | binds the generated fields' sources → `(input) => Result<Entity>` |
| `factoryAsync(gens)` | method | same, for promise-returning generators → `(input) => AsyncResult<Entity>` |

**Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`,
`updateInput`); domain code composes the class itself.** All four `ZodObject`s
Expand Down
37 changes: 19 additions & 18 deletions packages/entity/src/crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,28 @@ const input = {
name: "Acme",
trialEndsAt: "2026-09-01T09:00:00Z",
} as never;
const orgs = Organization.factory({
const createOrg = Organization.factory({
id: () => "0199b1f4-1b1e-7000-8000-000000000000" as never,
createdAt: () => "2026-08-06T09:00:00Z" as never,
});

test("create applies the generated values", () => {
const org = orgs.create(input).getOrThrow();
const org = createOrg(input).getOrThrow();
expect(org.id).toBe("0199b1f4-1b1e-7000-8000-000000000000");
expect(org.createdAt).toBe("2026-08-06T09:00:00Z");
expect(org.slug).toBe("acme");
});

test("create ignores a generated field smuggled in by a caller", () => {
const org = orgs
.create({ ...(input as object), id: "0199b1f4-1b1e-7000-8000-999999999999" } as never)
.getOrThrow();
const org = createOrg({
...(input as object),
id: "0199b1f4-1b1e-7000-8000-999999999999",
} as never).getOrThrow();
expect(org.id).toBe("0199b1f4-1b1e-7000-8000-000000000000");
});

test("create enforces invariants", () => {
const bad = orgs.create({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never);
const bad = createOrg({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never);
expect(bad.isErr()).toBe(true);
});

Expand All @@ -61,7 +62,7 @@ test("updateInput is partial and omits the immutable fields", () => {
});

test("update returns a new instance and leaves the original untouched", () => {
const org = orgs.create(input).getOrThrow();
const org = createOrg(input).getOrThrow();
const renamed = org.update({ name: "Renamed" as never }).getOrThrow();
expect(renamed).not.toBe(org);
expect(renamed.name).toBe("Renamed");
Expand All @@ -70,13 +71,13 @@ test("update returns a new instance and leaves the original untouched", () => {
});

test("update ignores an immutable field smuggled in at runtime", () => {
const org = orgs.create(input).getOrThrow();
const org = createOrg(input).getOrThrow();
const updated = org.update({ slug: "other" } as never).getOrThrow();
expect(updated.slug).toBe("acme");
});

test("update re-runs invariants", () => {
const org = orgs.create(input).getOrThrow();
const org = createOrg(input).getOrThrow();
const issues = org.update({ trialEndsAt: "2026-01-01T09:00:00Z" as never }).match({
ok: () => [] as readonly string[],
errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => i.message)),
Expand All @@ -93,32 +94,32 @@ test("an entity with no generated or immutable options still exposes both schema

test("a generator runs once per create, not once per factory", () => {
let n = 0;
const counted = Organization.factory({
const countedCreate = Organization.factory({
id: () => `0199b1f4-1b1e-7000-8000-00000000000${(n += 1)}` as never,
createdAt: () => "2026-08-06T09:00:00Z" as never,
});
const a = counted.create(input).getOrThrow();
const b = counted.create(input).getOrThrow();
const a = countedCreate(input).getOrThrow();
const b = countedCreate(input).getOrThrow();
expect(a.id).not.toBe(b.id);
expect(n).toBe(2);
});

test("an async factory awaits its generators", async () => {
const asyncOrgs = Organization.factoryAsync({
const createOrgAsync = Organization.factoryAsync({
id: () => Promise.resolve("0199b1f4-1b1e-7000-8000-000000000000" as never),
createdAt: () => Promise.resolve("2026-08-06T09:00:00Z" as never),
});
const org = (await asyncOrgs.create(input)).getOrThrow();
const org = (await createOrgAsync(input)).getOrThrow();
expect(org.id).toBe("0199b1f4-1b1e-7000-8000-000000000000");
});

test("an async factory still reports invariant failures as InvalidEntity", async () => {
const asyncOrgs = Organization.factoryAsync({
const createOrgAsync = Organization.factoryAsync({
id: () => Promise.resolve("0199b1f4-1b1e-7000-8000-000000000000" as never),
createdAt: () => Promise.resolve("2026-08-06T09:00:00Z" as never),
});
const outcome = (
await asyncOrgs.create({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never)
await createOrgAsync({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never)
).match({
ok: () => "WRONGLY ACCEPTED",
errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"),
Expand All @@ -128,11 +129,11 @@ test("an async factory still reports invariant failures as InvalidEntity", async
});

test("a rejecting generator is a defect, not an InvalidEntity", async () => {
const broken = Organization.factoryAsync({
const createBroken = Organization.factoryAsync({
id: () => Promise.reject(new Error("id source unreachable")),
createdAt: () => Promise.resolve("2026-08-06T09:00:00Z" as never),
});
const outcome = (await broken.create(input)).match({
const outcome = (await createBroken(input)).match({
ok: () => "WRONGLY ACCEPTED",
errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"),
defect: () => "defect",
Expand Down
6 changes: 3 additions & 3 deletions packages/entity/src/entity.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ test("create rejects a generated field and update rejects an immutable one", ()
{ generated: ["id", "createdAt"], immutable: ["id", "createdAt"] },
) {}

const orgs = Org.factory({ id: () => "x" as never, createdAt: () => "t" as never });
const createOrg = Org.factory({ id: () => "x" as never, createdAt: () => "t" as never });
// @ts-expect-error `id` is generated by the domain, not supplied by the caller
orgs.create({ slug: "s" as never, id: "x" as never });
orgs.create({ slug: "s" as never });
createOrg({ slug: "s" as never, id: "x" as never });
createOrg({ slug: "s" as never });

const org = Org.make({}).getOrThrow();
// @ts-expect-error `id` is immutable
Expand Down
21 changes: 8 additions & 13 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,26 +321,21 @@ export function Entity<Tag extends string>(tag: Tag) {
generators: Generators<S, G>,
): EntityFactory<T, S, G> {
const Ctor = this as unknown as { make: (state: unknown) => Result<T, InvalidEntity> };
return {
create: (input) =>
// generated spreads last, so a caller cannot override a domain-owned field
Ctor.make({ ...(input as object), ...callAll(generators) }),
};
// generated spreads last, so a caller cannot override a domain-owned field
return (input) => Ctor.make({ ...(input as object), ...callAll(generators) });
}

static factoryAsync<T>(
this: new (d: Sealed<OutputShape>) => T,
generators: AsyncGenerators<S, G>,
): AsyncEntityFactory<T, S, G> {
const Ctor = this as unknown as { make: (state: unknown) => Result<T, InvalidEntity> };
return {
create: (input) =>
// a generator that rejects is infrastructure failing, not bad domain
// input, so it stays a Defect rather than becoming an InvalidEntity
fromPromise(resolveAll(generators), (cause, defect) => defect(cause)).flatMap(
(generated) => Ctor.make({ ...(input as object), ...generated }),
),
};
// a generator that rejects is infrastructure failing, not bad domain
// input, so it stays a Defect rather than becoming an InvalidEntity
return (input) =>
fromPromise(resolveAll(generators), (cause, defect) => defect(cause)).flatMap(
(generated) => Ctor.make({ ...(input as object), ...generated }),
);
}

/** a partial of the mutable fields → a NEW entity */
Expand Down
21 changes: 13 additions & 8 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,17 @@ export type AsyncGenerators<S extends Fields, G extends readonly (keyof S)[]> =
[K in keyof GeneratedOf<S, G>]: () => PromiseLike<GeneratedOf<S, G>[K]>;
};

/** An entity bound to its effect sources. `create` is the only member: nothing
* else consumes generators, and `make`/`decode` stay on the class. */
export type EntityFactory<T, S extends Fields, G extends readonly (keyof S)[]> = {
create(input: CreateInputOf<S, G>): Result<T, InvalidEntity>;
};
/**
* An entity bound to its effect sources: call it with the caller's fields.
*
* A plain function rather than an object with one method — nothing else
* consumes generators, so `.create` was ceremony around the only thing a
* factory does. `make` stays on the class.
*/
export type EntityFactory<T, S extends Fields, G extends readonly (keyof S)[]> = (
input: CreateInputOf<S, G>,
) => Result<T, InvalidEntity>;

export type AsyncEntityFactory<T, S extends Fields, G extends readonly (keyof S)[]> = {
create(input: CreateInputOf<S, G>): AsyncResult<T, InvalidEntity>;
};
export type AsyncEntityFactory<T, S extends Fields, G extends readonly (keyof S)[]> = (
input: CreateInputOf<S, G>,
) => AsyncResult<T, InvalidEntity>;
Loading