diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d415b42..3b9b1a7 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -23,6 +23,7 @@ const GUIDE_SIDEBAR = [ { text: "Evolve an entity", link: "/how-to/evolve-an-entity" }, { text: "Model an aggregate", link: "/how-to/model-an-aggregate" }, { text: "Number without gaps", link: "/how-to/number-without-gaps" }, + { text: "Enforce a uniqueness rule", link: "/how-to/enforce-uniqueness" }, { text: "Test domain logic", link: "/how-to/test-domain-logic" }, ], }, diff --git a/docs/examples/billing-persistence.md b/docs/examples/billing-persistence.md index 782acbe..926762c 100644 --- a/docs/examples/billing-persistence.md +++ b/docs/examples/billing-persistence.md @@ -75,3 +75,14 @@ changes shape — which is the point of the entity knowing nothing about persistence in the first place. Related how-to: [Persist and rehydrate](/how-to/persist-and-rehydrate). + +## Uniqueness + +`uniqueness.ts` registers an organization under a slug no other organization +holds. The preflight lookup gives early feedback; the store's write-time check +stands in for a database unique index and is the only part that holds under +concurrency. The spec races two creates past the lookup and asserts that +exactly one write lands, with the loser receiving `SlugTaken` rather than a +defect. + +Related how-to: [Enforce a uniqueness rule](/how-to/enforce-uniqueness). diff --git a/docs/how-to/enforce-uniqueness.md b/docs/how-to/enforce-uniqueness.md new file mode 100644 index 0000000..04d71b7 --- /dev/null +++ b/docs/how-to/enforce-uniqueness.md @@ -0,0 +1,254 @@ +--- +title: Enforce a uniqueness rule +description: Place a rule like "this slug is not taken" where it can actually hold, with a preflight lookup for feedback, a unique constraint for the guarantee, and a typed conflict error between them. +--- + +# Enforce a uniqueness rule + +**Problem:** a rule needs to look at data the entity does not hold. "No other +organization has this slug" is the usual one; "the customer referenced here +exists" and "this account is under its quota" are the same shape. Invariants +cannot express it, and you need to know where it goes instead. + +> Snippets below assume these imports: +> +> ```ts +> import { Entity } from "@btravstack/entity"; +> import { +> ErrAsync, +> OkAsync, +> P, +> TaggedError, +> fromPromise, +> type AsyncResult, +> } from "unthrown"; +> ``` +> +> The runnable version is +> [`examples/billing-persistence/src/uniqueness.ts`](https://github.com/btravstack/entity/blob/main/examples/billing-persistence/src/uniqueness.ts), +> with its spec beside it. + +## Sort the rule into the right layer + +Three kinds of rule are easy to blur together. Each lives in a different place, +and only one of them survives two requests arriving at once. + +| Rule | Example | Lives in | Holds under concurrency | +| ------------------------ | ------------------------------- | -------------------------------------------- | ----------------------------- | +| **State invariant** | a name is at most 80 characters | the entity's `invariants` | yes, it reads only the entity | +| **Command precondition** | no organization has this slug | the use case, before it writes | no | +| **Database constraint** | `unique (slug)` | the store, enforced atomically at write time | yes | + +The test is whether the entity's own fields are enough to decide the rule. If +they are, it is a state invariant: synchronous, local, and checked on every +construction path. If the answer depends on what else is stored, it is +open-world, and it belongs to the use case and the store. + +Ecto draws the same line between a changeset's +[validations and constraints](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints): +validations run before the database is touched, constraints are reported by the +database and mapped back after the write fails. + +## Keep the invariant closed-world + +Leave the entity's declaration exactly as it would be without the uniqueness +rule: + +```ts +export class Organization extends Entity("Organization")( + { + id: Entity.field(OrganizationId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { + invariants: [ + Entity.invariant( + (d) => d.name.length <= 80, + "name must be at most 80 characters", + ), + ], + }, +) {} +``` + +There is no async predicate to reach for, and no repository to pass in. That is +deliberate: `make` would stop being a pure function of its input, and a row +read back from storage would re-run a lookup that has nothing to do with +whether the row is well-formed. See [No I/O, by design](/explanation/no-io). + +## Add the unique constraint first + +The constraint is the rule. Everything else on this page is about reporting it +well: + +```sql +alter table organization add constraint organization_slug_key unique (slug); +``` + +A unique index checks and writes in one atomic step, so no second writer can +slip in between. + +## Give the store a Result-returning port + +A driver reports a violation by rejecting. Triage that rejection once, at the +adapter's boundary, so the use case never sees a raw promise. The violation is +a modeled error carrying the constraint's name; everything else is a defect: + +```ts +export class UniqueViolation extends TaggedError("UniqueViolation")<{ + constraint: string; +}> { + override message = `duplicate key value violates unique constraint "${this.constraint}"`; +} + +export type OrganizationStore = { + slugIsTaken(slug: Organization["slug"]): AsyncResult; + insert(organization: Organization): AsyncResult; +}; +``` + +A Postgres adapter's `insert` qualifies SQLSTATE `23505` and nothing else: + +```ts +insert: (organization) => + fromPromise(db.insert(organizations).values(toRow(organization)), (cause, defect) => + isUniqueViolation(cause) ? new UniqueViolation({ constraint: cause.constraint }) : defect(cause), + ).map(() => undefined), +``` + +The example has no database, so its in-memory store plays the index's part the +same way: the check and the write share one synchronous block, and a duplicate +returns the error a real adapter would have qualified. + +```ts +insert(organization: Organization): AsyncResult { + if (this.#bySlug.has(organization.slug)) { + return ErrAsync(new UniqueViolation({ constraint: "organization_slug_key" })); + } + this.#bySlug.set(organization.slug, organization.toJSON()); + return OkAsync(); +} +``` + +Swap in the real adapter and nothing above the port changes. + +## Model the conflict as its own error + +A taken slug is an expected outcome with a stable meaning, so give it a stable +type: + +```ts +export class SlugTaken extends TaggedError("SlugTaken")<{ + slug: Organization["slug"]; +}> { + override message = `slug "${this.slug}" is already taken`; +} +``` + +It is not an `InvalidEntity`. The organization is valid; it lost a race for a +name. Folding the two together would hand the caller one error for "your input +is malformed" and "your input is fine but someone got there first", which are a +422 and a 409, and are fixed in different ways. + +## Check, then create + +The use case runs the three layers in order: + +```ts +export const registerOrganization = + (store: OrganizationStore) => + (input: Entity.CreateInput) => + createOrganization(input) // 1. state invariants, synchronous + .toAsync() + .flatMap((organization) => + store + .slugIsTaken(organization.slug) // 2. preflight + .ensure( + (taken) => !taken, + () => new SlugTaken({ slug: organization.slug }), + ) + .map(() => organization), + ) + .flatMap((organization) => + store + .insert(organization) // 3. the authority + .mapErrCases((m, defect) => + m.with(P.tag("UniqueViolation"), (violation) => + violation.constraint === "organization_slug_key" + ? new SlugTaken({ slug: organization.slug }) + : defect(violation), + ), + ) + .map(() => organization), + ); +``` + +The result is `AsyncResult`. + +The preflight lookup is there for feedback. It catches the common case before +any write, and it is the place to put a friendlier message or a suggested +alternative. It cannot establish uniqueness, so never let it be the only check. +Dropping it is a valid choice; dropping the constraint is not. + +The lookup's error channel is `never`: its adapter already turned a store that +cannot answer into a defect, because that is not a conflict. + +## Qualify the violation, and only that + +Triage happens twice, each time at the layer that knows enough. The adapter's +`qualify` knows the driver: it turns SQLSTATE `23505` into `UniqueViolation` +and every other rejection into a defect. The use case's `mapErrCases` knows the +domain: it turns exactly one violation into `SlugTaken`, the one **on this +index**. Match on the constraint's name, or a violation of some other unique +index on the same table becomes a misleading "slug taken"; that one goes to +`defect`. + +Every other failure is a defect too. A refused connection, a timeout, or a +violation you did not anticipate is infrastructure failing, and it should page +someone rather than render as a 409. If you use Prisma, +[`@unthrown/prisma`](https://btravstack.github.io/unthrown/) already separates +`UniqueConstraintViolation` from `DriverError`; the same rule applies to which +one you map. + +## Watch the race lose + +Two requests for the same slug, arriving together: + +1. Both build a valid `Organization`. +2. Both look up the slug, and both see it free. +3. Both insert. The store accepts the first and rejects the second with a + unique violation. +4. The second request's `mapErrCases` maps that violation to `SlugTaken`. + +The loser gets the same error the preflight would have given it, so the caller +never needs to know which layer caught the conflict. The example's spec +reproduces this deterministically by holding both inserts until both lookups +have answered, then asserting that both lookups saw a free slug and exactly one +write succeeded. + +## Answer each channel at the edge + +The three outcomes stay distinguishable all the way out: + +```ts +registerOrganization(store)(input).match({ + ok: () => 201, + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => 422) + .with(P.tag("SlugTaken"), () => 409), + defect: () => 503, +}); +``` + +If your API renders every failure in one response shape, do that here, at the +edge, by mapping `SlugTaken` into it. Converting it into an `InvalidEntity` +first to borrow its `issues` would erase the distinction the matcher depends on. +Stable machine-readable codes for invariant failures are tracked in +[issue #70](https://github.com/btravstack/entity/issues/70). + +Related: [Persist and rehydrate](/how-to/persist-and-rehydrate) for the +repository this store sits beside, and +[Expose an HTTP contract](/how-to/http-contract) for the edge. diff --git a/examples/billing-persistence/README.md b/examples/billing-persistence/README.md index 2a0a2bc..6e78fff 100644 --- a/examples/billing-persistence/README.md +++ b/examples/billing-persistence/README.md @@ -69,3 +69,22 @@ number was allocated, and the next invoice still taking that number. See also the how-to: [Number without gaps](https://btravstack.github.io/entity/how-to/number-without-gaps). + +## Uniqueness + +`uniqueness.ts` is a rule no invariant can express: "this slug is not taken" is +a fact about every _other_ organization. The use case checks it with a +preflight lookup for feedback, and the store enforces it at write time, the way +a database unique index does. + +```ts +await registerOrganization(store)(input); // AsyncResult +``` + +The spec races two creates for the same slug: both pass the lookup, the store +rejects one insert, and that violation is mapped to the same `SlugTaken` the +lookup returns. A store that is down stays a Defect, so a conflict (409) never +looks like an outage (503). + +See also the how-to: [Enforce a uniqueness +rule](https://btravstack.github.io/entity/how-to/enforce-uniqueness). diff --git a/examples/billing-persistence/src/uniqueness.spec.ts b/examples/billing-persistence/src/uniqueness.spec.ts new file mode 100644 index 0000000..e6d9ea7 --- /dev/null +++ b/examples/billing-persistence/src/uniqueness.spec.ts @@ -0,0 +1,84 @@ +import type { Entity } from "@btravstack/entity"; +import { DisplayName, Slug } from "@btravstack/entity-example-billing-domain"; +import type { Organization } from "@btravstack/entity-example-billing-domain"; +import { fromSafePromise, OkAsync, P, type AsyncResult } from "unthrown"; +import { expect, test, vi } from "vitest"; + +import { + InMemoryOrganizationStore, + registerOrganization, + type OrganizationStore, + type SlugTaken, +} from "./uniqueness.js"; + +const acme = (name = "Acme SA") => ({ slug: Slug.parse("acme"), name: DisplayName.parse(name) }); + +/** The HTTP edge: three channels, three different answers. */ +const status = (result: AsyncResult) => + result.match({ + ok: () => 201, + errCases: (m) => m.with(P.tag("InvalidEntity"), () => 422).with(P.tag("SlugTaken"), () => 409), + defect: () => 503, + }); + +/** + * Holds every insert until both requests have done their lookup — the + * interleaving a loaded server produces by accident, produced on purpose so + * the test does not depend on scheduling luck. + */ +const racing = (store: OrganizationStore) => { + const answers: boolean[] = []; + let resolve = () => {}; + const bothLooked = new Promise((r) => (resolve = r)); + + const raced: OrganizationStore = { + slugIsTaken: (slug) => + store.slugIsTaken(slug).tap((taken) => { + answers.push(taken); + if (answers.length === 2) resolve(); + }), + insert: (organization) => fromSafePromise(bothLooked).flatMap(() => store.insert(organization)), + }; + return { raced, answers }; +}; + +test("a second registration of the same slug fails the preflight", async () => { + const register = registerOrganization(new InMemoryOrganizationStore()); + + expect(await status(register(acme()))).toBe(201); + expect(await status(register(acme("Acme SAS")))).toBe(409); +}); + +test("two concurrent creates both pass the preflight; the unique constraint rejects one", async () => { + const { raced, answers } = racing(new InMemoryOrganizationStore()); + const register = registerOrganization(raced); + + const outcomes = await Promise.all([ + status(register(acme())), + status(register(acme("Acme SAS"))), + ]); + + // Both lookups saw a free slug: the preflight cannot establish uniqueness. + expect(answers).toEqual([false, false]); + // The store can, because it checks and writes in one step. The loser gets + // the same 409 the preflight would have given it, never a 503. + expect(outcomes.sort()).toEqual([201, 409]); +}); + +test("an invariant failure never reaches the store", async () => { + const untouched = { slugIsTaken: vi.fn(), insert: vi.fn() }; + + expect(await status(registerOrganization(untouched)(acme("A".repeat(81))))).toBe(422); + expect(untouched.slugIsTaken).not.toHaveBeenCalled(); + expect(untouched.insert).not.toHaveBeenCalled(); +}); + +test("an unavailable store is a defect, not a conflict", async () => { + // the adapter's boundary qualified "connection refused" as a Defect + const down: OrganizationStore = { + slugIsTaken: () => OkAsync(false), + insert: () => fromSafePromise(Promise.reject(new Error("connection refused"))), + }; + + expect(await status(registerOrganization(down)(acme()))).toBe(503); +}); diff --git a/examples/billing-persistence/src/uniqueness.ts b/examples/billing-persistence/src/uniqueness.ts new file mode 100644 index 0000000..9f0b8de --- /dev/null +++ b/examples/billing-persistence/src/uniqueness.ts @@ -0,0 +1,122 @@ +/** + * Uniqueness, which no invariant can express. + * + * "This slug is not taken" is a fact about every *other* organization, so it + * cannot live in `invariants`: those are synchronous predicates over the + * entity's own fields, and that is what keeps construction free of I/O. Three + * different rules are in play when an organization is registered, and each + * belongs to a different layer: + * + * - **State invariant** — a closed-world fact about the entity's own data (a + * name at most 80 characters long). Declared on `Organization`, checked on + * every construction path, never needs a store. + * - **Command precondition** — an open-world check the use case runs before it + * writes (no organization has this slug yet). Good for feedback, worthless as + * a guarantee: two requests can both pass it. + * - **Database constraint** — the unique index on `slug`. The only one of the + * three that holds under concurrency, because the store checks and writes in + * one atomic step. + * + * The store's port speaks unthrown: a driver rejection is triaged once, at the + * adapter's `fromPromise` boundary, into a modeled `UniqueViolation` or a + * Defect. The use case then maps a violation of *this* index into the same + * `SlugTaken` the precondition returns; anything else stays a Defect. + * + * See also the how-to: . + */ +import type { Entity } from "@btravstack/entity"; +import { createOrganization, type Organization } from "@btravstack/entity-example-billing-domain"; +import { ErrAsync, OkAsync, P, TaggedError, type AsyncResult } from "unthrown"; + +type SlugValue = Organization["slug"]; + +/** + * The stable application error. It is not an `InvalidEntity`: the organization + * is perfectly valid, it just lost a race for a name. Whichever layer caught + * the conflict, the caller sees this one error. + */ +export class SlugTaken extends TaggedError("SlugTaken")<{ slug: SlugValue }> { + override message = `slug "${this.slug}" is already taken`; +} + +/** The name of the unique index, as a driver reports it on a violation. */ +export const SLUG_UNIQUE = "organization_slug_key"; + +/** + * A write hit a unique index — what a Postgres adapter qualifies SQLSTATE + * `23505` into, carrying the constraint's name. Keeping the name lets the use + * case keep a violation of some *other* index out of `SlugTaken`. + */ +export class UniqueViolation extends TaggedError("UniqueViolation")<{ constraint: string }> { + override message = `duplicate key value violates unique constraint "${this.constraint}"`; +} + +/** + * The port. A real adapter wraps its driver calls in `fromPromise`, qualifying + * a unique violation into `UniqueViolation` and everything else into a Defect, + * so no rejection crosses into the use case. + */ +export type OrganizationStore = { + slugIsTaken(slug: SlugValue): AsyncResult; + insert(organization: Organization): AsyncResult; +}; + +/** + * Stands in for a table with `unique (slug)`. The check and the write sit in + * one synchronous block, so nothing can interleave between them — the same + * atomicity a database unique index provides, and the reason the constraint + * holds where the preflight lookup does not. + */ +export class InMemoryOrganizationStore implements OrganizationStore { + readonly #bySlug = new Map(); + + slugIsTaken(slug: SlugValue): AsyncResult { + return OkAsync(this.#bySlug.has(slug)); + } + + insert(organization: Organization): AsyncResult { + if (this.#bySlug.has(organization.slug)) { + return ErrAsync(new UniqueViolation({ constraint: SLUG_UNIQUE })); + } + this.#bySlug.set(organization.slug, organization.toJSON()); + return OkAsync(); + } +} + +/** + * Check, then create — with the check demoted to what it actually is. + * + * 1. Construction runs the state invariants. Synchronous, no store involved. + * 2. The preflight lookup turns the common case into a clear error before any + * write. A store that cannot answer has already become a Defect. + * 3. The insert is the authority. A violation of *this* index is `SlugTaken`; + * anything else the store rejects with is a Defect. + */ +export const registerOrganization = + (store: OrganizationStore) => + ( + input: Entity.CreateInput, + ): AsyncResult => + createOrganization(input) + .toAsync() + .flatMap((organization) => + store + .slugIsTaken(organization.slug) + .ensure( + (taken) => !taken, + () => new SlugTaken({ slug: organization.slug }), + ) + .map(() => organization), + ) + .flatMap((organization) => + store + .insert(organization) + .mapErrCases((m, defect) => + m.with(P.tag("UniqueViolation"), (violation) => + violation.constraint === SLUG_UNIQUE + ? new SlugTaken({ slug: organization.slug }) + : defect(violation), + ), + ) + .map(() => organization), + );