You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
README: document the real Uniqueness API — the old section was actively misleading
The section was titled 'EXPERIMENTAL Uniqueness support' and said 'Let's see the API
proposal', documenting [Unique<FooCategory>] as though it worked. It never did:
nothing reads that attribute and no source generator wires it. A reader would have
decorated an event and got SILENT ZERO uniqueness — the exact failure this library
exists to prevent.
Now documents what ships: the reservation pattern, the 3 provider packages, the
Reserve -> SaveNew -> Confirm sequence, the R9 compensation obligation (the lease buys
liveness, not safety), provider choice incl. SQLite's same-host shared-volume boundary
and SQL Server's case-insensitive default collation, binary-exact comparison, why this
is NOT for document numbering, and an explicit statement that [Unique<T>] and
IUniqueFrom are inert.
Adds NuGet badges for the 3 new provider packages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012eBz8Xt5TA1LbF94tdbMZV
@@ -480,49 +483,118 @@ public class OrderProcessManager(IPlumberd plumberd)
480
483
481
484
```
482
485
483
-
### EXPERIMENTAL Uniqueness support
486
+
### Uniqueness (set-wide constraints)
484
487
485
-
Uniqueness support in EventSourcing is not out-of-the-box, especially in regards to EventStoreDB. You can use some "hacks" but at the end of the day, you want uniqueness to be enforced by some kind of database. EventStoreDB is not designed for that purpose.
488
+
An event store enforces invariants **within one stream**. Uniqueness — "no two companies share a tax id" —
489
+
spans streams, and there is no stream whose optimistic-concurrency check can express it. KurrentDB is not
490
+
designed for it, and the usual workaround (one stream per value, append-at-NoStream) does not survive
491
+
contact with reality: a *released* value's stream still exists, so it can never be re-reserved, and you get
492
+
one stream per possible value forever.
486
493
487
-
However, you can leverage typical reservation patterns. At the moment the library supports only the first option:
494
+
So put the constraint where constraints belong: a **relational unique index**, as a sidecar to the
495
+
aggregate write. `MicroPlumberd.Services.Uniqueness` implements the **reservation pattern**
496
+
(try / confirm / cancel).
488
497
489
-
- At domain-layer, a domain-service usually would enforce uniqueness. This commonly requires a round-trip to a database. So just before actual event(s) are saved in a stream, a check against uniqueness constraints should be evaluated - thus reservation is made. When the event is appended to the stream, a confirmation is done automatically (on db).
490
-
491
-
- At a app-layer, command-handler would typically reserve a name. And when aggregate, which is being executed by the handler, saves its events successfully, then the reservation is confirmed. If the handler fails, then the reservation is deleted. Seems simple? Under the hood, it is not that simple, because what if the process is terminated while the command-handler is executing? We need to make sure, that we can recover successfully from this situation.
**The lease buys liveness, not safety.** If a caller stalls past its lease, another source may take the
559
+
name — and the stalled caller's aggregate write can still land. Its `Confirm` then fails, but the aggregate
560
+
is *already persisted*. That failure is your signal to compensate (reject / soft-delete). It cannot be
561
+
fixed by reordering: `Reserve → Confirm → SaveNew` is strictly worse, because a crash between Confirm and
562
+
SaveNew burns the name **permanently** (confirmed rows never expire).
563
+
564
+
Size the lease to swamp your worst-case persist time. The default is 10 minutes against a sub-second
565
+
append: a stall that long means a dead process.
566
+
567
+
#### Choosing a provider
568
+
569
+
| Provider | When |
570
+
|---|---|
571
+
|**PostgreSQL**| Default for a real server. |
572
+
|**SQLite**| Light and serverless. **Requires a shared volume, and all instances MUST be on the SAME HOST** (docker named volume / bind mount — one kernel). Verified: 16 separate processes racing one file yield exactly one winner. **Never put the file on NFS/SMB or any cross-host share** — SQLite's locking is unreliable there and the failure mode is *database corruption*, which is worse than the bug this library prevents. WAL is enabled, which also makes that boundary structural: WAL needs shared memory, so it cannot work cross-host. |
573
+
|**SQL Server**| Supported. The dialect pins `COLLATE Latin1_General_100_BIN2` — SQL Server's *default* collation is case-INsensitive, which would otherwise make `"abc"` and `"ABC"` collide there but not on the other providers. |
574
+
575
+
#### Names are compared exactly
576
+
577
+
Comparison is **ordinal/binary on every provider**. There is no per-category case sensitivity: that would
578
+
be per-provider collation configuration, and normalisation is domain knowledge the library must not guess
579
+
(`ToLowerInvariant` and culture-aware casing disagree on the Turkish dotless i; Unicode folding has choices
580
+
only you can make). **Normalise before calling** if you want case-insensitive uniqueness.
581
+
582
+
#### This is NOT for document numbering
583
+
584
+
Numbering (`FV-12222/07/2024`) is a different problem: you **mint** the value rather than validating
585
+
someone else's, there is one counter per series rather than an unbounded candidate set, and the series key
586
+
is a real domain entity. Model it as an **aggregate** — the id is the template *without* the counter, the
587
+
sequence is state, and the versioned append gives you atomicity and gaplessness for free. No index, no
588
+
lease, no second store.
589
+
590
+
> Rule of thumb: if you would be creating **one stream per possible value**, you are not modelling an
591
+
> entity — you are building a lock, so use the index. A bounded domain entity is an aggregate.
592
+
593
+
#### `[Unique<T>]` / `IUniqueFrom<,>` — declarative only, NOT implemented
594
+
595
+
These types exist and are **inert**: nothing reads them. There is no source-generated wiring; reservation
596
+
is performed by calling `IUniqueNameReservation<T>` explicitly, as above. Do not decorate an event with
0 commit comments