From 48e394e0916c14528ba24f2c8c331d9dd20b16e9 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Tue, 6 Oct 2026 10:16:52 +0200 Subject: [PATCH 1/2] docs: show where uniqueness-style rules belong Add a how-to separating a state invariant, a command precondition and a database constraint, with a runnable check-then-create example in billing-persistence. Two concurrent creates both pass the preflight lookup; the store's write-time unique check rejects one, and that violation is qualified into a typed SlugTaken while an unavailable store stays a defect. Closes #39 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/.vitepress/config.ts | 1 + docs/examples/billing-persistence.md | 11 + docs/how-to/enforce-uniqueness.md | 209 ++++++++++++++++++ examples/billing-persistence/README.md | 19 ++ .../src/uniqueness.spec.ts | 88 ++++++++ .../billing-persistence/src/uniqueness.ts | 118 ++++++++++ 6 files changed, 446 insertions(+) create mode 100644 docs/how-to/enforce-uniqueness.md create mode 100644 examples/billing-persistence/src/uniqueness.spec.ts create mode 100644 examples/billing-persistence/src/uniqueness.ts 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..3ce6946 --- /dev/null +++ b/docs/how-to/enforce-uniqueness.md @@ -0,0 +1,209 @@ +--- +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 { fromPromise, fromSafePromise, P, TaggedError } 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. 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 rejects the insert the way a driver would. + +```ts +insert(organization: Organization): Promise { + if (this.#bySlug.has(organization.slug)) { + return Promise.reject(new UniqueViolation("organization_slug_key")); + } + this.#bySlug.set(organization.slug, organization.toJSON()); + return Promise.resolve(); +} +``` + +In production a real database unique index replaces that method, and nothing +above the store 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: 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) => + fromSafePromise(store.slugIsTaken(organization.slug)) // 2. preflight + .ensure( + (taken) => !taken, + () => new SlugTaken({ slug: organization.slug }), + ) + .map(() => organization), + ) + .flatMap((organization) => + fromPromise(store.insert(organization), (cause, defect) => + cause instanceof UniqueViolation && + cause.constraint === "organization_slug_key" // 3. the authority + ? new SlugTaken({ slug: organization.slug }) + : defect(cause), + ).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. + +`fromSafePromise` says every rejection from the lookup is a defect: a store +that cannot answer the question is not a conflict. + +## Qualify the violation, and only that + +The insert's `qualify` callback is the triage. It turns exactly one failure +into `SlugTaken`: a unique violation **on this index**. Postgres reports +SQLSTATE `23505` with the constraint's name; match on the name too, or a +violation of some other unique index on the same table becomes a misleading +"slug taken". + +Every other rejection goes to `defect`. 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 `qualify` 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..7c83b7f --- /dev/null +++ b/examples/billing-persistence/src/uniqueness.spec.ts @@ -0,0 +1,88 @@ +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 { P, type AsyncResult } from "unthrown"; +import { expect, test } 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: async (slug) => { + const taken = await store.slugIsTaken(slug); + answers.push(taken); + if (answers.length === 2) resolve(); + return taken; + }, + insert: async (organization) => { + await bothLooked; + return 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: OrganizationStore = { + slugIsTaken: () => Promise.reject(new Error("must not be called")), + insert: () => Promise.reject(new Error("must not be called")), + }; + + expect(await status(registerOrganization(untouched)(acme("A".repeat(81))))).toBe(422); +}); + +test("an unavailable store is a defect, not a conflict", async () => { + const down: OrganizationStore = { + slugIsTaken: () => Promise.resolve(false), + insert: () => 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..8b8c6e0 --- /dev/null +++ b/examples/billing-persistence/src/uniqueness.ts @@ -0,0 +1,118 @@ +/** + * 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 constraint's violation is a *known* failure, so it is qualified at the + * boundary into the same `SlugTaken` the precondition returns. Every other + * rejection from the store is infrastructure failing and 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 { fromPromise, fromSafePromise, 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"; + +/** + * What a driver rejects with when a write hits a unique index — Postgres + * reports SQLSTATE `23505` plus the constraint's name. Matching on the name, + * not just the code, keeps a violation of some *other* index out of + * `SlugTaken`. + */ +export class UniqueViolation extends Error { + readonly constraint: string; + + constructor(constraint: string) { + super(`duplicate key value violates unique constraint "${constraint}"`); + this.constraint = constraint; + } +} + +/** The port. Both methods are plain promises, the way a driver hands them over. */ +export type OrganizationStore = { + slugIsTaken(slug: SlugValue): Promise; + /** Rejects with `UniqueViolation` when the slug is already stored. */ + insert(organization: Organization): Promise; +}; + +/** + * 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): Promise { + return Promise.resolve(this.#bySlug.has(slug)); + } + + insert(organization: Organization): Promise { + if (this.#bySlug.has(organization.slug)) { + return Promise.reject(new UniqueViolation(SLUG_UNIQUE)); + } + this.#bySlug.set(organization.slug, organization.toJSON()); + return Promise.resolve(); + } +} + +/** + * 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 is 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) => + fromSafePromise(store.slugIsTaken(organization.slug)) + .ensure( + (taken) => !taken, + () => new SlugTaken({ slug: organization.slug }), + ) + .map(() => organization), + ) + .flatMap((organization) => + fromPromise(store.insert(organization), (cause, defect) => + cause instanceof UniqueViolation && cause.constraint === SLUG_UNIQUE + ? new SlugTaken({ slug: organization.slug }) + : defect(cause), + ).map(() => organization), + ); From 7eacea57cf79ca14f4ed71855866a0ff54de2624 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Tue, 6 Oct 2026 10:33:46 +0200 Subject: [PATCH 2/2] docs(uniqueness): give the store a Result-returning port The store handed raw promises to the use case, which pulled the violation back out with `instanceof`. The port now returns AsyncResult: an adapter triages the driver rejection once, at its own `fromPromise` boundary, into a `UniqueViolation` TaggedError or a defect, and the use case maps the violation with `mapErrCases`. Also type `SlugTaken`'s payload as `Organization["slug"]` in the how-to, where `Slug` named a value in type position. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/how-to/enforce-uniqueness.md | 97 ++++++++++++++----- .../src/uniqueness.spec.ts | 32 +++--- .../billing-persistence/src/uniqueness.ts | 66 +++++++------ 3 files changed, 120 insertions(+), 75 deletions(-) diff --git a/docs/how-to/enforce-uniqueness.md b/docs/how-to/enforce-uniqueness.md index 3ce6946..04d71b7 100644 --- a/docs/how-to/enforce-uniqueness.md +++ b/docs/how-to/enforce-uniqueness.md @@ -14,7 +14,14 @@ cannot express it, and you need to know where it goes instead. > > ```ts > import { Entity } from "@btravstack/entity"; -> import { fromPromise, fromSafePromise, P, TaggedError } from "unthrown"; +> import { +> ErrAsync, +> OkAsync, +> P, +> TaggedError, +> fromPromise, +> type AsyncResult, +> } from "unthrown"; > ``` > > The runnable version is @@ -81,22 +88,51 @@ 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. 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 rejects the insert the way a driver would. +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: Organization): Promise { +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 Promise.reject(new UniqueViolation("organization_slug_key")); + return ErrAsync(new UniqueViolation({ constraint: "organization_slug_key" })); } this.#bySlug.set(organization.slug, organization.toJSON()); - return Promise.resolve(); + return OkAsync(); } ``` -In production a real database unique index replaces that method, and nothing -above the store changes. +Swap in the real adapter and nothing above the port changes. ## Model the conflict as its own error @@ -104,7 +140,9 @@ 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: Slug }> { +export class SlugTaken extends TaggedError("SlugTaken")<{ + slug: Organization["slug"]; +}> { override message = `slug "${this.slug}" is already taken`; } ``` @@ -125,7 +163,8 @@ export const registerOrganization = createOrganization(input) // 1. state invariants, synchronous .toAsync() .flatMap((organization) => - fromSafePromise(store.slugIsTaken(organization.slug)) // 2. preflight + store + .slugIsTaken(organization.slug) // 2. preflight .ensure( (taken) => !taken, () => new SlugTaken({ slug: organization.slug }), @@ -133,12 +172,16 @@ export const registerOrganization = .map(() => organization), ) .flatMap((organization) => - fromPromise(store.insert(organization), (cause, defect) => - cause instanceof UniqueViolation && - cause.constraint === "organization_slug_key" // 3. the authority - ? new SlugTaken({ slug: organization.slug }) - : defect(cause), - ).map(() => 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), ); ``` @@ -149,18 +192,20 @@ 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. -`fromSafePromise` says every rejection from the lookup is a defect: a store -that cannot answer the question is not a conflict. +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 -The insert's `qualify` callback is the triage. It turns exactly one failure -into `SlugTaken`: a unique violation **on this index**. Postgres reports -SQLSTATE `23505` with the constraint's name; match on the name too, or a -violation of some other unique index on the same table becomes a misleading -"slug taken". +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 rejection goes to `defect`. A refused connection, a timeout, or a +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 @@ -175,7 +220,7 @@ Two requests for the same slug, arriving together: 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 `qualify` maps that violation to `SlugTaken`. +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 diff --git a/examples/billing-persistence/src/uniqueness.spec.ts b/examples/billing-persistence/src/uniqueness.spec.ts index 7c83b7f..e6d9ea7 100644 --- a/examples/billing-persistence/src/uniqueness.spec.ts +++ b/examples/billing-persistence/src/uniqueness.spec.ts @@ -1,8 +1,8 @@ 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 { P, type AsyncResult } from "unthrown"; -import { expect, test } from "vitest"; +import { fromSafePromise, OkAsync, P, type AsyncResult } from "unthrown"; +import { expect, test, vi } from "vitest"; import { InMemoryOrganizationStore, @@ -32,16 +32,12 @@ const racing = (store: OrganizationStore) => { const bothLooked = new Promise((r) => (resolve = r)); const raced: OrganizationStore = { - slugIsTaken: async (slug) => { - const taken = await store.slugIsTaken(slug); - answers.push(taken); - if (answers.length === 2) resolve(); - return taken; - }, - insert: async (organization) => { - await bothLooked; - return store.insert(organization); - }, + 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 }; }; @@ -70,18 +66,18 @@ test("two concurrent creates both pass the preflight; the unique constraint reje }); test("an invariant failure never reaches the store", async () => { - const untouched: OrganizationStore = { - slugIsTaken: () => Promise.reject(new Error("must not be called")), - insert: () => Promise.reject(new Error("must not be called")), - }; + 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: () => Promise.resolve(false), - insert: () => Promise.reject(new Error("connection refused")), + 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 index 8b8c6e0..9f0b8de 100644 --- a/examples/billing-persistence/src/uniqueness.ts +++ b/examples/billing-persistence/src/uniqueness.ts @@ -17,15 +17,16 @@ * three that holds under concurrency, because the store checks and writes in * one atomic step. * - * The constraint's violation is a *known* failure, so it is qualified at the - * boundary into the same `SlugTaken` the precondition returns. Every other - * rejection from the store is infrastructure failing and stays a Defect. + * 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 { fromPromise, fromSafePromise, TaggedError, type AsyncResult } from "unthrown"; +import { ErrAsync, OkAsync, P, TaggedError, type AsyncResult } from "unthrown"; type SlugValue = Organization["slug"]; @@ -42,25 +43,22 @@ export class SlugTaken extends TaggedError("SlugTaken")<{ slug: SlugValue }> { export const SLUG_UNIQUE = "organization_slug_key"; /** - * What a driver rejects with when a write hits a unique index — Postgres - * reports SQLSTATE `23505` plus the constraint's name. Matching on the name, - * not just the code, keeps a violation of some *other* index out of - * `SlugTaken`. + * 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 Error { - readonly constraint: string; - - constructor(constraint: string) { - super(`duplicate key value violates unique constraint "${constraint}"`); - this.constraint = constraint; - } +export class UniqueViolation extends TaggedError("UniqueViolation")<{ constraint: string }> { + override message = `duplicate key value violates unique constraint "${this.constraint}"`; } -/** The port. Both methods are plain promises, the way a driver hands them over. */ +/** + * 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): Promise; - /** Rejects with `UniqueViolation` when the slug is already stored. */ - insert(organization: Organization): Promise; + slugIsTaken(slug: SlugValue): AsyncResult; + insert(organization: Organization): AsyncResult; }; /** @@ -72,16 +70,16 @@ export type OrganizationStore = { export class InMemoryOrganizationStore implements OrganizationStore { readonly #bySlug = new Map(); - slugIsTaken(slug: SlugValue): Promise { - return Promise.resolve(this.#bySlug.has(slug)); + slugIsTaken(slug: SlugValue): AsyncResult { + return OkAsync(this.#bySlug.has(slug)); } - insert(organization: Organization): Promise { + insert(organization: Organization): AsyncResult { if (this.#bySlug.has(organization.slug)) { - return Promise.reject(new UniqueViolation(SLUG_UNIQUE)); + return ErrAsync(new UniqueViolation({ constraint: SLUG_UNIQUE })); } this.#bySlug.set(organization.slug, organization.toJSON()); - return Promise.resolve(); + return OkAsync(); } } @@ -90,7 +88,7 @@ export class InMemoryOrganizationStore implements OrganizationStore { * * 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 is a Defect. + * 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. */ @@ -102,7 +100,8 @@ export const registerOrganization = createOrganization(input) .toAsync() .flatMap((organization) => - fromSafePromise(store.slugIsTaken(organization.slug)) + store + .slugIsTaken(organization.slug) .ensure( (taken) => !taken, () => new SlugTaken({ slug: organization.slug }), @@ -110,9 +109,14 @@ export const registerOrganization = .map(() => organization), ) .flatMap((organization) => - fromPromise(store.insert(organization), (cause, defect) => - cause instanceof UniqueViolation && cause.constraint === SLUG_UNIQUE - ? new SlugTaken({ slug: organization.slug }) - : defect(cause), - ).map(() => 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), );