diff --git a/AGENTS.md b/AGENTS.md index c1c66f6..a21360d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ implementation whose only job is to prove the conventions compile, run, and fail | `src/tests/SharedTestingInfra` | `TestBuilder`; referenced automatically by every test project | | `skills/` | Authored agent skills, installable with `npx skills add purview-dev/engineering-playbook` | | `templates/` | Ready-to-copy starter files for a repository adopting the playbook | +| `samples/Acme.Persistence` | Standalone, runnable EF Core sample for the identifier conventions; run with `just samples` | | `src/Directory.Build.props` | `NamespacePrefix`, the SDK import, and `NamespaceRemoveSuffix` | | `.config/purview-build.json` | Pipeline configuration: solution, test discovery, filter | | `.config/architecture-policy.json` | The dependency and naming policy the architecture tests read | @@ -82,11 +83,11 @@ build-time `.agents/` mirror outright. The skills CLI clones the git repository, | Skill | Answers | | -- | -- | -| `skills/purview-engineering-principles/` | the ten repository conventions, what each prevents, and whether a build or a reviewer enforces it | +| `skills/purview-engineering-principles/` | the ten universal conventions, the conditional persistence rules, and whether a build or a reviewer enforces each | | `skills/purview-architecture/` | how many projects, which layers, where validation lives | | `skills/purview-architecture-testing/` | turning a boundary into a failing build | | `skills/purview-testing-conventions/` | TUnit, AAA, `TestBuilder`, substitutes, tiers | -| `skills/purview-persistence/` | storage approach, EF Core abstraction, GUID key strategy | +| `skills/purview-persistence/` | storage approach, EF Core integration, well-known identifiers, GUID key strategy | | `skills/purview-telemetry/` | logging, tracing, metrics, and when a project is "too small" for them | | `skills/purview-org-harvest/` | which repository owns which convention, and how it is read at `main` before anything is adopted | @@ -123,6 +124,23 @@ folder is installed with it. Name the other skill in prose instead. no family, like `Bogus`, keeps its literal version inline. - **A clean build has no warnings.** +## Persistence conventions + +These apply only when the repository persists data; a stateless CLI or a pure library takes none of them. + +- **A value known before storage is derived, not generated.** Use UUIDv5 over an installation-scoped + namespace and key it `ValueGeneratedNever()`, so a sequential convention never overwrites it. See + [Well-Known Identifiers](docs/wiki/Well-Known-Identifiers.md). +- **Every other GUID key uses one strategy per store.** SQL Server and PostgreSQL order GUIDs + differently, so the strategy is carried as context-options data and applied by a model-finalising + convention. See [GUID Key Strategy](docs/wiki/Guid-Key-Strategy.md). +- **EF Core is the boundary.** Use `DbContext` directly rather than a repository added for testability; + map value objects once; keep design-time and runtime options in step; apply migrations deliberately. + See [EF Core Integration](docs/wiki/EF-Core-Integration.md). +- **The runnable evidence is the sample.** `samples/Acme.Persistence` is outside `src/` and outside the + canonical solution; `just samples` builds, runs and self-checks it. Run it when you change the sample + or an identifier convention. + ## Local commands ```text @@ -132,6 +150,7 @@ just lint-check # CSharpier check just lint-fix # CSharpier format just org-inventory # drift between the organisation and .config/org-inventory.json just org-inventory-write # regenerate the snapshot; commit the diff with the harvest +just samples # build and run the standalone persistence sample just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool just scrub # reset bin/obj, forced restore, build-server shutdown ``` diff --git a/Justfile b/Justfile index 60b9353..f75eb00 100644 --- a/Justfile +++ b/Justfile @@ -54,6 +54,18 @@ build *args: echo "Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} +# Builds the standalone persistence sample +[group('Samples')] +samples-build *args: + echo "Building {{ BLUE }}samples/Acme.Persistence{{ NORMAL }}..." + dotnet build samples/Acme.Persistence/Acme.Persistence.csproj {{ args }} + +# Runs the standalone persistence sample (builds it first) +[group('Samples')] +samples *args: + echo "Running {{ BLUE }}samples/Acme.Persistence{{ NORMAL }}..." + dotnet run --project samples/Acme.Persistence/Acme.Persistence.csproj -- {{ args }} + # Restore NuGet packages for the solution [group('Build and Test')] restore *args: diff --git a/README.md b/README.md index d996a42..01ae9e1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ coding agents are enabled to work in a codebase without hand-holding. | [Architecture as tests](docs/wiki/Architecture-As-Tests.md) | How does the build refuse a dependency that points the wrong way? | | [Verification model](docs/wiki/Verification-Model.md) | Which checks run, in which tier, for which change? | | [Contracts and data exchange](docs/wiki/Contracts-And-Data-Exchange.md) | How do value objects, schemas and results cross a boundary? | +| [Well-known identifiers](docs/wiki/Well-Known-Identifiers.md) | How is an identity derived so every process and restart agrees? | +| [GUID key strategy](docs/wiki/Guid-Key-Strategy.md) | How is a sequential GUID key chosen and applied per store? | +| [EF Core integration](docs/wiki/EF-Core-Integration.md) | How do the context, providers, value objects, models and migrations fit together? | | [Testing and test data](docs/wiki/Testing-And-Test-Data.md) | How are tests written, and where does their data come from? | | [Agent enablement](docs/wiki/Agent-Enablement.md) | How is a repository made legible to a coding agent? | | [Agent skills distribution](docs/wiki/Agent-Skills-Distribution.md) | How is a skill published once and reach an agent? | @@ -45,6 +48,7 @@ coding agents are enabled to work in a codebase without hand-holding. | `docs/wiki` | The playbook. Aggregated by the purview-dev website. | | `templates/` | Ready-to-copy starter files for a repository adopting the playbook. | | `src/` | The reference implementation: a layered repository whose architecture rules are tests. | +| `samples/` | Runnable samples for conventions that `src/` cannot host; run with `just samples`. | | `mkdocs.yml` | Wiki site configuration (`docs_dir: docs/wiki`). | | `.config/purview-build.json` | Pipeline configuration for the shared Purview build workflow. | | `Justfile` | Supported local workflow commands. | @@ -56,6 +60,7 @@ coding agents are enabled to work in a codebase without hand-holding. just build # dotnet build (Debug) just test # run the unit tests just lint-check # CSharpier check +just samples # build and run the standalone persistence sample just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool just scrub # reset bin/obj, forced restore, build-server shutdown ``` diff --git a/docs/wiki/Adopting-This-At-Your-Scale.md b/docs/wiki/Adopting-This-At-Your-Scale.md index 26bca92..86df6c2 100644 --- a/docs/wiki/Adopting-This-At-Your-Scale.md +++ b/docs/wiki/Adopting-This-At-Your-Scale.md @@ -32,6 +32,7 @@ adopt, and that decision is a review obligation. | **Repository** | `src/src/`, one test project | `src/src/*` + `src/tests/*` + a shared testing project | As medium, plus `Sdk/` assets per package and `samples/` | | **SDK** | Yes | Yes | Yes | | **Test data builder** | Yes, from the first test | Yes | Yes | +| **Persistence identifiers** | `Guid.NewGuid()` or a store default; UUIDv5 only if a value must be known before storage | One provider's strategy, plus derived ids where seeding needs them | Two-provider strategy, derived catalogue, design-time parity | | **Architecture tests** | No | Yes | Yes, and promoted into the build | | **CI tiers** | One | Two (`Unit`, plus one slow tier) | Several, with a release gate | | **Pack validation** | n/a | Only if it packs | Yes, exhaustively | @@ -73,6 +74,9 @@ Everything from Small, plus: Keep the fast tier always on; make the slow tier explicit. - **A wiki page per public concern**, plus a getting-started page that is the shortest path to a working example. +- **The EF Core integration rules as soon as persistence exists.** A `Persistence` project, value objects + mapped once, design-time and runtime options in step, and a real-store integration suite. See + [EF Core Integration](EF-Core-Integration.md). This is the profile the reference implementation in this repository is built to. It is the smallest shape where every mechanism in the playbook is visible. @@ -88,6 +92,9 @@ Everything from Medium, plus: catches the drift between a package's contract page and the package. - **A release gate.** The slow tier runs before a release, not only on a pull request. - **Release channels** rather than a second repository for prereleases. +- **The derived-identifier catalogue and the two-provider GUID strategy**, once a system tenant or a + second store appears. See [Well-Known Identifiers](Well-Known-Identifiers.md) and [GUID Key + Strategy](Guid-Key-Strategy.md). - **Your own agent skills**, packaged so the guidance travels with the code. - **Architecture rules promoted into the build.** At this size a rule that can be skipped is a rule that will be, so move it from a test into an analyzer or an MSBuild check that runs before compile. @@ -100,6 +107,11 @@ Four things that are more expensive than they look when taken out of order. the web layer" is meaningless in a repository with one project, and it teaches the team that architecture tests are ceremony. +**A GUID strategy before the second provider.** With one store and moderate write volume, a store default +or `Guid.NewGuid()` is enough. The strategy witness, options extension and model-finalising convention +earn their place when a second provider exists or the identifier must be known before `SaveChanges` — +not in anticipation. See [GUID Key Strategy](Guid-Key-Strategy.md). + **A test data builder after the tests.** Retrofitting `TestBuilder` means editing every test file at once, which means a large diff nobody reviews carefully. Adopt it with the first test, when the diff is one file. @@ -136,5 +148,8 @@ where it belongs. A migration that leaves the conventions undocumented is a migr - [Getting Started](Getting-Started.md) — the same steps, in order, with the files inline - [Templates](https://github.com/purview-dev/engineering-playbook/tree/main/templates) — the copy-paste versions - [Engineering Principles](Engineering-Principles.md) +- [EF Core Integration](EF-Core-Integration.md) +- [Well-Known Identifiers](Well-Known-Identifiers.md) +- [GUID Key Strategy](Guid-Key-Strategy.md) - [Worked Examples](Worked-Examples.md) - [Contributing](Contributing.md) diff --git a/docs/wiki/Agent-Enablement.md b/docs/wiki/Agent-Enablement.md index 1cda014..1ddeaaf 100644 --- a/docs/wiki/Agent-Enablement.md +++ b/docs/wiki/Agent-Enablement.md @@ -87,11 +87,15 @@ whose `description` is the trigger — it is what tells an agent that this skill --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. --- ``` +The persistence skill owns the whole persistence decision, not only the key: EF Core integration, the +derived-identifier gate and the provider-specific GUID strategy are one activation, because an agent that +is already answering "how do we store this" is the agent that must not answer only half of it. + Writing a good trigger is most of writing a good skill. "Use when X, and you need Y" is far more useful than a topic label, because the agent is matching a situation, not searching an index. diff --git a/docs/wiki/Agent-Guidance-Skills.md b/docs/wiki/Agent-Guidance-Skills.md index 6d47ced..d8023a7 100644 --- a/docs/wiki/Agent-Guidance-Skills.md +++ b/docs/wiki/Agent-Guidance-Skills.md @@ -7,12 +7,12 @@ library, and they are the only place that guidance is written: | Skill | Answers | | -- | -- | -| `purview-engineering-principles` | What are the ten repository conventions, what does each prevent, and is it enforced by a build or by a reviewer? | +| `purview-engineering-principles` | What are the ten universal conventions and the conditional persistence rules, what does each prevent, and is it enforced by a build or by a reviewer? | | `purview-architecture` | How much structure does this project actually need, and what do I need to know before saying? | | `purview-architecture-testing` | How does a boundary become a failing build rather than a review comment? | | `purview-testing-conventions` | How are tests written and fed — framework, structure, naming, and `TestBuilder`? | | `purview-telemetry` | What instrumentation does a project of this size get? (Answer: always some.) | -| `purview-persistence` | How is data stored, and — only when it applies — how are GUID keys generated? | +| `purview-persistence` | How is data stored with EF Core, how are identifiers derived when they are known in advance, and — only when it applies — how are GUID keys generated? | | `purview-org-harvest` | Which repository owns which convention, and how is it read at `main` before anything is adopted? | Two obligations fall out of that, and both are enforced only by review: @@ -104,10 +104,11 @@ The library branch is the one most often got wrong: a library that calls `AddOpe every consumer that already configured a provider, and wins or loses silently depending on initialisation order. -## Persistence and GUID keys +## Persistence, EF Core and GUID keys -This guidance activates only when persistence is real and GUIDs are keys. For a stateless CLI or a -pure library, the correct output is silence. +This guidance activates only when persistence is real. For a stateless CLI or a pure library, the +correct output is silence. The EF Core integration rules are in [EF Core +Integration](EF-Core-Integration.md); the decision diagram below is the identifier-specific branch. **Diagram N — persistence-specific GUID strategy selection.** @@ -115,8 +116,11 @@ pure library, the correct output is silence. flowchart TD A["Persistence confirmed?"] -- no --> Z["Out of scope - ask nothing"] A -- yes --> B{"GUIDs used as
primary keys?"} - B -- no --> Z - B -- yes --> C{"Identifier needed
before SaveChanges?"} + B -- no --> Z2["No identifier decision
EF Core rules may still apply"] + B -- yes --> Q{"Identifier value
predetermined before storage?"} + + Q -- yes --> R["Derive it - UUIDv5 over an
installation-scoped namespace
ValueGeneratedNever"] + Q -- no --> C{"Identifier needed
before SaveChanges?"} C -- yes --> D["Application must generate it"] C -- no --> E["Database may generate it"] @@ -124,7 +128,7 @@ flowchart TD D --> F{"Provider?"} E --> G{"Provider?"} - F -- "SQL Server" --> H["EF Core SequentialGuidValueGenerator"] + F -- "SQL Server" --> H["EF Core SequentialGuidValueGenerator
or a store-ordered FastGuid strategy"] F -- "PostgreSQL" --> I["Guid.CreateVersion7 on .NET 9+
ValueGeneratedNever"] G -- "SQL Server" --> J["NEWSEQUENTIALID()"] @@ -137,6 +141,7 @@ flowchart TD J --> N L --> N M --> N + R --> S["A derived identity is not a secret
and is never regenerated"] N -- yes --> O["Random v4 - ordered keys leak creation time"] N -- no --> P["Keep the ordered choice"] ``` @@ -144,11 +149,38 @@ flowchart TD **The two providers do not share an answer.** SQL Server compares `uniqueidentifier` with the last six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy carried -across without re-deriving it is the failure this diagram exists to prevent. - -There is no Purview GUID generation package. Prefer a framework or database capability; a custom -generator is a correctness risk — endianness, clock regression, counter rollover and thread safety -are all live hazards, and the failure shows up as duplicate keys in production. +across without re-deriving it is the failure this diagram exists to prevent. When one context type +serves both providers, the strategy is carried as data on the context options, not chosen from +`Database.ProviderName` at model-build time. + +**A derived identity is the third case, and it must be excluded from the sequential convention.** When +the value is known before the row exists — a system tenant, a seeded reference row, an installation's +service principal — derive it with UUIDv5 in an installation-scoped namespace and configure +`ValueGeneratedNever()`. The full rule, catalogue and tests are on [Well-Known +Identifiers](Well-Known-Identifiers.md); the convention that must skip it is on [GUID Key +Strategy](Guid-Key-Strategy.md). + +No **NuGet** package provides the *strategy/convention pattern*. A reviewed generator such as +`FastGuid` supplies a store-ordered implementation, but the witness, options extension, value generator +and model-finalising convention are repository-local: copy them, do not depend on a package that claims +to own the wiring. A hand-written generator is a correctness risk — endianness, clock regression, counter +rollover and thread safety are all live hazards, and the failure shows up as duplicate keys in production. + +### EF Core integration + +The persistence skill also covers the non-identifier half of the same decision, because an agent that is +already choosing a store is the agent that will otherwise invent a repository over `DbContext`. The full +rule is on [EF Core Integration](EF-Core-Integration.md); the four points worth keeping in mind are: + +- **`DbContext` is the boundary.** No `IRepository` or unit-of-work wrapper added for testability; + `Purview.Containers` gives a real database in tests. +- **The provider is chosen where the context is registered.** The choice is carried as context-options + data, never read from `Database.ProviderName` while the model is being built. +- **Value objects are mapped once.** A converter per property is the version that gets forgotten, and a + value-object key needs a converter-aware generator. +- **Design time must match runtime.** The design-time factory and the application registration make the + same provider, strategy and `ReplaceService` choices, or the next migration describes a model nobody + runs. ## How it is enforced @@ -158,7 +190,7 @@ are all live hazards, and the failure shows up as duplicate keys in production. | A skill is reachable without loading every skill | The `description` frontmatter is the activation trigger | | Context is established before a recommendation | **Review** | | Telemetry is recommended at every project size | **Review** | -| GUID guidance stays silent without persistence | **Review** | +| Persistence, EF Core and GUID guidance stay silent without persistence | **Review** | The last three are review obligations and this page does not pretend otherwise. A build can check that a skill is well-formed; it cannot check that an agent followed prose. See @@ -172,11 +204,16 @@ thing when read. | Scenario | Expected | | -- | -- | -| "Add a command to this CLI" (no persistence) | No new projects, no repository, no DI indirection. Telemetry recommended. **No GUID question asked.** | +| "Add a command to this CLI" (no persistence) | No new projects, no repository, no DI indirection. Telemetry recommended. **No EF Core or GUID question asked.** | | "Build a small HTTP API" | Single project. Structured logging and an `ActivitySource` from the first commit. Console or one OTLP endpoint — no collector, no dashboards. | | "Add telemetry to this reusable library" | `ILogger` / `ActivitySource` / `Meter`, names published for the host. **No `AddOpenTelemetry`, no exporter, no backend requirement.** | -| "Use GUID keys on SQL Server, high write volume" | `NEWSEQUENTIALID()`, or `SequentialGuidValueGenerator` if the value is needed before insert. **Not** UUIDv7. | -| "Use GUID keys on PostgreSQL, high write volume" | `Guid.CreateVersion7()` with `ValueGeneratedNever()`. `uuidv7()` only if the server is 18+. | +| "Use GUID keys on SQL Server, high write volume" | `NEWSEQUENTIALID()` as a store default, EF Core's client-side `SequentialGuidValueGenerator` (the provider's default) when the value is needed before insert, or a store-ordered `FastGuid` strategy when both providers must share one model. **Not** UUIDv7. | +| "Use GUID keys on PostgreSQL, high write volume" | `Guid.CreateVersion7()` with `ValueGeneratedNever()`, or `FastGuid.NewPostgreSqlGuid()` in the shared strategy. `uuidv7()` only if the server is 18+. | +| "Seed a system tenant every replica must agree on" | UUIDv5 over the installation namespace with `ValueGeneratedNever()`, a read-or-insert bootstrap, and a test that the migration literal matches the derivation. | +| "Random GUID keys, no seeding" | No UUIDv5 and no strategy abstraction. `Guid.NewGuid()` is the answer. | +| "SQL Server and PostgreSQL behind one context type" | One strategy carried as context-options data, chosen per provider. Different service providers, different models. Never one byte-ordered generator for both. | +| "Add EF Core to an existing layered repository" | A `Persistence` project holding `DbContext`, mappings and migration definitions; the provider chosen in `Composition` or the host; value objects converted once; no repository wrapper; a real-container integration suite. | +| "The migration is empty but the runtime model changed" | Design-time options do not match runtime: compare both models, and call the same options extension and `ReplaceService` from both paths. | | Repository already has `AddOpenTelemetry` | Existing setup extended, not replaced. No duplicate registration. | | "Should I add a repository over EF Core?" | Only with a named reason other than testability — `Purview.Containers` gives a real database in tests. | | Project file already states the type and persistence | No questions asked at all. | diff --git a/docs/wiki/Contracts-And-Data-Exchange.md b/docs/wiki/Contracts-And-Data-Exchange.md index 3a23339..ba5f183 100644 --- a/docs/wiki/Contracts-And-Data-Exchange.md +++ b/docs/wiki/Contracts-And-Data-Exchange.md @@ -91,7 +91,9 @@ so a value object is never a black box that has to be unwrapped through a helper The same generator covers the storage boundary: reference `Microsoft.EntityFrameworkCore` and it emits the value converters, comparers and complex-type mapping, plus a `ConfigureValueObjects` extension — so queries -use the value object type directly instead of `.Value`. +use the value object type directly instead of `.Value`. That is the mapping EF Core should configure once, +and it is why a value-object key also needs a converter-aware value generator when the application owns the +value. See [EF Core Integration](EF-Core-Integration.md). ## Rule 4 in practice @@ -185,6 +187,8 @@ a hope. ## See also - [Worked Examples](Worked-Examples.md) +- [EF Core Integration](EF-Core-Integration.md) — value converters and value-object keys in the model +- [Well-Known Identifiers](Well-Known-Identifiers.md) — value objects that carry a derived identity - [Testing and Test Data](Testing-And-Test-Data.md) - [value-objects](https://github.com/purview-dev/value-objects) - [results](https://github.com/purview-dev/results) diff --git a/docs/wiki/Documentation-Convention.md b/docs/wiki/Documentation-Convention.md index 039578e..97f0145 100644 --- a/docs/wiki/Documentation-Convention.md +++ b/docs/wiki/Documentation-Convention.md @@ -89,8 +89,8 @@ Every content page uses the same four sections, in this order: Three pages are deliberately not content pages. `Home.md` is the hub and `index.md` is the landing page, so both are navigation rather than rules. `Engineering-Principles.md` is an index: it states each -of the ten rules in one line and links to the page that states it in full, so a rule is never written -twice. +of the ten universal rules in one line, plus the conditional persistence rules, and links to the page +that states it in full, so a rule is never written twice. The shape is not decoration. It forces three questions that a page written as prose usually skips: *is this actually a rule, or an opinion?*; *what breaks if we drop it?*; and *is it enforced, or are we asking a diff --git a/docs/wiki/EF-Core-Integration.md b/docs/wiki/EF-Core-Integration.md new file mode 100644 index 0000000..108026a --- /dev/null +++ b/docs/wiki/EF-Core-Integration.md @@ -0,0 +1,134 @@ +# EF Core Integration + +## The rule + +When EF Core is the store, it is the boundary. These rules are conditional on a repository that actually +persists data; a stateless CLI or a pure library takes none of them. + +| Rule | Statement | +| -- | -- | +| **1** | Use `DbContext` directly. Do not add `IRepository` or a unit-of-work wrapper for testability. | +| **2** | Choose the provider, and the identifier strategy, where the context is registered. Carry the choice as context-options data; never branch on `Database.ProviderName` while the model is being built. | +| **3** | Map a value object once, in the model. Do not repeat a converter or comparer per property. | +| **4** | A generated value has exactly one owner: the application, the store, or a derivation. | +| **5** | Design-time options and runtime options make the same choices, including the provider, the strategy and every `ReplaceService` call. | +| **6** | The model cache key includes everything that varies the model: provider, strategy, tenant and design time. | +| **7** | Migrations are code, reviewed and applied by a deliberate step. The API does not migrate at startup in production. | +| **8** | Integration tests run against a real store. The in-memory provider is not a database. | +| **9** | A connection string is configuration, not source, and never telemetry. | + +Rules 2 and 4 are where most of the identifier-specific detail lives. [Well-Known +Identifiers](Well-Known-Identifiers.md) owns the derived case, and [GUID Key +Strategy](Guid-Key-Strategy.md) owns the generated case and the full convention implementation. + +## Why + +| Rule | Failure it prevents | +| -- | -- | +| 1 | A generic repository hides `Include`, tracking, `SaveChanges` semantics and the query shape, then needs its own tests to prove it forwards correctly. It is not more testable than a real database in a container. | +| 2 | The model is built once and cached. A `Database.ProviderName` branch at model-build time picks one provider's model and serves it to the other. | +| 3 | A converter configured per property is forgotten on the next property, and queries start failing on the one type that was missed. | +| 4 | Two owners overwrite each other: a value generator runs over a derived key, or EF fetches a store value that the application already supplied. | +| 5 | The migration snapshot describes a different model from the one the application runs, so the next migration contains a change nobody intended. | +| 6 | EF serves the first tenant's or strategy's model to the next one. The service-provider cache and the model cache are separate, and both need a key that matches the decision. | +| 7 | An API that migrates at startup mutates a production schema on a deploy, without review, and cannot be rolled back cleanly. | +| 8 | The in-memory provider passes a test that a real store rejects: foreign keys, unique indexes, computed columns, concurrency tokens and SQL semantics are all missing. | +| 9 | A connection string in `appsettings.json` or a log line is a credential disclosure, and rotating it does not remove the copy from the history. | + +The design-time failure deserves its own sentence because it is invisible in a green build. A migration is +generated from the design-time model, which is built by `IDesignTimeDbContextFactory` or by the host's +registration. If that path omits a `ReplaceService`, an options extension or the provider, `dotnet ef +migrations add` compares the runtime model with a different model and writes a migration that claims a +schema change that is not real. + +## The sample + +`samples/Acme.Persistence` is the runnable reference for the whole integration: one `DbContext` per provider +strategy, a model-finalising convention, a value generator that honours value converters, an options +extension, a model cache key, a customizer and a composition method. `just samples` builds and runs it, +including the identifier checks, the foreign-key copy and the explicit `ValueGeneratedNever()` key. It +sits outside `src/` because the reference implementation there is in-memory by design. + +The wiring is short enough to read as a rule: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +Four details in that snippet are deliberate: + +- **The provider is chosen once.** `ConfigureStore` is the only place that names `UseSqlServer` or + `UseNpgsql`, and it runs where the context is registered. +- **The strategy is data on the options.** `UseSequentialGuids` adds an extension; the model and the value + generator read it. No model code asks which provider is in use. +- **The model cache key is replaced.** The service-provider hash separates strategies, but the model cache + is keyed separately and would otherwise return the first model it built. +- **The application-facing factory is registered separately.** EF's internal registration is not reachable + from an outbox writer or a correlation-id generator, and a second implementation would be a second + ordering. + +A single-provider application can make the type parameter concrete and skip the extension, but it still +needs the design-time parity and the cache key if the model varies at design time. + +## How it is enforced + +| Rule | Enforced by | +| -- | -- | +| EF Core stays out of the domain, application, contracts and provider layers | The architecture policy's `forbiddenPackagePrefixes`, checked by `PurviewArchitectureTests` | +| The provider is chosen once, at composition | Review, plus a two-provider test that asserts two models | +| A value object is mapped once | The `Purview.ValueObjects` source generator when EF Core is referenced; otherwise review | +| One owner per generated value | Review, plus the tests below in the adopting repository | +| Design-time and runtime options agree | An integration test that builds the model both ways and compares the snapshot | +| The model cache key covers the model's inputs | A test with two strategies and one context type asserts two models | +| Migrations are applied deliberately | Review, and the `Migrator` host in [Project Responsibilities](Project-Responsibilities.md) | +| Integration tests use a real store | The `*.IntegrationTests` project name and the `Purview.Containers` rule in [Testing and Test Data](Testing-And-Test-Data.md) | +| The connection string stays out of source and telemetry | Review; in an adopting repository, a secret scanner and a redaction test turn it into a build failure | + +The checks worth writing in an adopting repository are small and specific: + +| Check | Shape | +| -- | -- | +| Model covers every key | For each entity type and each GUID key that is not a foreign key, the property is generated on add and has the expected factory. | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal; it never receives a generated value. | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges`. | +| Two strategies, one context type | Different strategies produce different service providers and different models. | +| Design time matches runtime | The design-time model and the runtime model produce the same migration snapshot. | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container. | + +`src/` does not demonstrate any of this. The reference implementation is deliberately in-memory, and the page +says so rather than claiming a check it does not have. + +## At your scale + +- **One project, low volume.** Use `DbContext` directly, with integer keys or `Guid.NewGuid()`. Create the + schema with one migration or `EnsureCreated` for a local tool, and do not add a repository, an options + extension or a strategy. The integration rules that still apply are 1, 4 and 9. +- **One project, a real database, a few queries.** Add value-object converters and a real-provider + integration test. Keep the provider choice in composition, and keep migrations in the repository even if + a developer applies them locally. +- **Several layers.** Put the `DbContext`, mappings and migration definitions in `Persistence`; keep the + provider choice in `Composition` or the host. Add the architecture policy so EF Core cannot leak upward. +- **Two providers or application-generated keys.** Add the options extension, the value generator and the + model cache key from [GUID Key Strategy](Guid-Key-Strategy.md). That is the point at which the extra + wiring is earned. +- **Several stores or tenants.** Add a design-time factory per store, a model cache key that includes the + tenant, and a separate `Migrator` host. Keep the API out of the migration path. + +## See also + +- [Well-Known Identifiers](Well-Known-Identifiers.md) — the derived owner of a key +- [GUID Key Strategy](Guid-Key-Strategy.md) — the generated owner, and the full convention +- [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) — value objects and their converters +- [Testing and Test Data](Testing-And-Test-Data.md) — real stores and container-backed suites +- [Project Responsibilities](Project-Responsibilities.md) — Persistence owns definitions, Migrator executes them +- [Working with EF Core](https://learn.microsoft.com/en-us/ef/core/) — the framework's own documentation diff --git a/docs/wiki/Engineering-Principles.md b/docs/wiki/Engineering-Principles.md index 0ca0f46..8678224 100644 --- a/docs/wiki/Engineering-Principles.md +++ b/docs/wiki/Engineering-Principles.md @@ -1,13 +1,14 @@ # Engineering Principles -These are the ten rules the Purview repositories are held to. This page is an index: each rule is -stated once, in full, on the page named beside it. That page carries the failure the rule prevents, the -mechanism that makes it true, and what a smaller repository does instead — so a rule is never written -twice and the two copies cannot disagree. Where a rule has no dedicated page, the line here is its full -statement. - -The distinction that runs through all ten: **a convention a build can check is checked by the build; a -convention only a reviewer can check is labelled a review obligation and never described as enforced.** +These are the ten universal rules the Purview repositories are held to, plus the conditional persistence +rules below them. This page is an index: each rule is stated once, in full, on the page named beside it. +That page carries the failure the rule prevents, the mechanism that makes it true, and what a smaller +repository does instead — so a rule is never written twice and the two copies cannot disagree. Where a +rule has no dedicated page, the line here is its full statement. + +The distinction that runs through every rule here: **a convention a build can check is checked by the +build; a convention only a reviewer can check is labelled a review obligation and never described as +enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. | # | Principle | In one line | Detail | @@ -23,10 +24,23 @@ A rule that claims enforcement it does not have is worse than no rule, because p | 9 | An agent is a reader with no memory | A repository states its purpose, layout and conventions in `AGENTS.md`; nothing important is discoverable only from the code. | [Agent Enablement](Agent-Enablement.md) | | 10 | A change is verified in proportion to its risk | Checks are tiered; a pull request runs the fast tier, and the slow tier is run deliberately. | [Verification Model](Verification-Model.md) | -Enforcement is stated per rule on the linked page. Three are enforced by the build in a way worth -knowing at a glance: identity (rule 2) by `PurviewProjectFileNameMismatch` and the SDK's derivation, -architecture (rule 3) by the architecture tests, and the verification tiers (rule 10) by the pipeline's -`TestFilter`. The rest are enforced partly by the build and partly by review, and each page says which. +### Conditional persistence rules + +Three further rules apply only when a repository actually stores data. They are not part of the ten +because a stateless CLI or a pure library must not inherit them, and the persistence guidance should stay +silent there. Each is stated in full on its own page. + +| Rule | In one line | Detail | Page | +| -- | -- | -- | -- | +| P1 | A value known before storage is derived, not generated | UUIDv5 over an installation-scoped namespace, keyed `ValueGeneratedNever()` so no generator overwrites it. | [Well-Known Identifiers](Well-Known-Identifiers.md) | +| P2 | A GUID key strategy is chosen once per store | One provider-specific strategy, carried as context-options data and applied to every key by convention. | [GUID Key Strategy](Guid-Key-Strategy.md) | +| P3 | EF Core is the persistence boundary | `DbContext` used directly, value objects mapped once, design-time and runtime options in step, migrations applied deliberately. | [EF Core Integration](EF-Core-Integration.md) | + +Enforcement is stated per rule on the linked page. Three of the ten are enforced by the build in a way +worth knowing at a glance: identity (rule 2) by `PurviewProjectFileNameMismatch` and the SDK's +derivation, architecture (rule 3) by the architecture tests, and the verification tiers (rule 10) by the +pipeline's `TestFilter`. The rest are enforced partly by the build and partly by review, and each page +says which. ## At your scale @@ -34,10 +48,17 @@ Every rule has a smaller version, and taking one before the problem it prevents [Adopting This at Your Scale](Adopting-This-At-Your-Scale.md) decides which rules apply to a small, medium or large repository, and in what order to adopt them. +The conditional persistence rules are the clearest example: a repository that stores nothing takes none +of them, and a repository with low write volume takes only P3. The smaller version of P1 and P2 is +`Guid.NewGuid()`, not an abstraction. + ## See also - [Repository Shape](Repository-Shape.md) - [Architecture as Tests](Architecture-As-Tests.md) - [Verification Model](Verification-Model.md) - [Testing and Test Data](Testing-And-Test-Data.md) +- [Well-Known Identifiers](Well-Known-Identifiers.md) +- [GUID Key Strategy](Guid-Key-Strategy.md) +- [EF Core Integration](EF-Core-Integration.md) - [Adopting This at Your Scale](Adopting-This-At-Your-Scale.md) diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 312c6c3..22e2fb7 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -469,6 +469,20 @@ Four things in the steps above are deliberately **not** templated, because a cop on arrival: the solution file (its paths depend on your project list), the two `.csproj` files (they are empty by design), and the `Justfile` (see step 12). `templates/README.md` says which, and why. +## When the repository persists data + +The steps above build a repository with no store, which is the floor. When persistence arrives, add the +three conditional rules on their own pages rather than inventing a shape: + +| Concern | Rule | Page | +| -- | -- | -- | +| The context and the model | `DbContext` is the boundary; map value objects once; design time matches runtime; migrate deliberately | [EF Core Integration](EF-Core-Integration.md) | +| A value known before storage | Derive it with UUIDv5 over an installation-scoped namespace and key it `ValueGeneratedNever()` | [Well-Known Identifiers](Well-Known-Identifiers.md) | +| Every other GUID key | One strategy per store, applied by a model-finalising convention | [GUID Key Strategy](Guid-Key-Strategy.md) | + +The working example is `samples/Acme.Persistence`, run with `just samples`. It sits outside `src/` so the +reference implementation in this repository stays in-memory and the canonical solution keeps its shape. + ## How it is enforced Step 13 proved it by breaking a rule on purpose. The table below is the same statement for the whole @@ -482,6 +496,7 @@ repository: which concern lives where, and what refuses a change that breaks it. | Code style | Bootstrapped `.editorconfig` | The build, with the style policy validated | | Test wiring | The `.csproj` filename | The build | | Test data | `TestBuilder` in `SharedTestingInfra` | Review | +| Persistence identifiers and mapping | `Persistence` project, a model convention, `ValueGeneratedNever()` on derived keys | The architecture policy for direction; the sample and its checks for the conventions | | Architecture | Dependency-direction tests + `.config/architecture-policy.json` | The build | | Verification tiers | `TestFilter` in `purview-build.json` | The pipeline | | Documentation | `docs/wiki` + `_Sidebar.md` | The build (`DocumentationTests`) for navigation and fences; review for the rest | diff --git a/docs/wiki/Guid-Key-Strategy.md b/docs/wiki/Guid-Key-Strategy.md new file mode 100644 index 0000000..8d5d228 --- /dev/null +++ b/docs/wiki/Guid-Key-Strategy.md @@ -0,0 +1,584 @@ +# GUID Key Strategy + +## The rule + +A GUID key needs one strategy per store, chosen once, and applied everywhere without being repeated on +each property. + +1. **Pick one strategy per store, and carry it as data.** The provider is known where the context is + registered, so that is where the choice belongs. Do not branch on `Database.ProviderName` at model + build time; the model is built once, and the branch is a runtime lookup pretending to be + configuration. +2. **When the identifier must exist before `SaveChanges`, the application generates it.** The store is + not the generator for outbox rows, correlation values, event envelopes or an object graph built + before the commit. +3. **Apply the strategy by convention to every GUID key in the model.** "Every key" includes keys + declared by a framework base context such as ASP.NET Identity, and keys modelled as scalar value + objects. A property added next month must be covered without anybody remembering. +4. **The generator must ascend in that store's comparison order.** Byte order and SQL Server's + `uniqueidentifier` order are not the same order. A value that is sequential in bytes can be + effectively random to a SQL Server index. +5. **Explicit configuration wins.** `ValueGeneratedNever()`, a different generator, a store default and + a computed column all override the convention. The convention writes at the lowest precedence so + that stating an exception is enough. + +## Why + +| Failure | Cause | +| -- | -- | +| Page splits and fragmentation on a clustered SQL Server index | Random GUIDs insert into the middle of the index instead of its end. | +| A store-ordered value that still fragments SQL Server | UUIDv7 is ordered in bytes, but `uniqueidentifier` weights the trailing six bytes most significantly. | +| One "portable" generator that helps neither store | PostgreSQL compares `uuid` in byte order; SQL Server does not, so the two providers cannot share an implementation. | +| A new entity silently gets a random key | The strategy was configured per property, and the new property had no configuration class. | +| A value-object key is assigned a raw `Guid` | The generator returned a `Guid`, but EF passes the generator's value to the property as-is; the property type is `OrderId`. | +| The first provider to build the model wins for the process | The model is cached per context type, and an external internal service provider bypasses the strategy's cache key. | + +## The samples + +The complete implementation is `samples/Acme.Persistence` at the repository root; `just samples` builds +and runs it, and `--provider` selects SQL Server, PostgreSQL or the UUIDv7 fallback. The page shows the +parts that carry the decision, and the sample is the copyable whole. It lives outside `src/` because the +reference implementation there is in-memory by design, and adding EF Core, a persistence project and a +database would contradict [Worked Examples](Worked-Examples.md). [EF Core +Integration](EF-Core-Integration.md) states the model, composition and migration rules the sample also +has to satisfy. + +### 1. The strategy as a pure witness + +```csharp +namespace Acme.Persistence.ValueGeneration; + +/// A store-specific sequential GUID strategy. +public interface ISequentialGuidProvider +{ + static abstract string Name { get; } + static abstract Guid NewGuid(); + static abstract Guid NewGuid(DateTime timestampUtc); + static abstract DateTime GetTimestamp(Guid value); + static abstract Guid MinValueFor(DateTime timestampUtc); + static abstract Guid MaxValueFor(DateTime timestampUtc); +} +``` + +Static abstract members make each implementation a pure witness. It is never instantiated, so there is +no state, no lifetime and no dependency-injection registration to get wrong. It can be a type argument +to both EF's model pipeline and ordinary application code, and the compiler resolves every call to the +one implementation named in the type argument. An instance-based strategy would need a registration, +and a wrong registration would silently produce the wrong store's ordering. + +### 2. Three providers + +`FastGuid` below is the `FastGuid` NuGet package (`SecurityDriven.FastGuid`). Its values are ordered for +a specific store, not merely in byte order. + +```csharp +using SecurityDriven; + +namespace Acme.Persistence.ValueGeneration; + +/// Values ascend in SQL Server's uniqueidentifier comparison order. +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} + +/// Values ascend in PostgreSQL's big-endian uuid comparison order. +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} + +/// RFC 9562 UUIDv7. Genuinely sequential on PostgreSQL, not on SQL Server. +public sealed class UuidV7SequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "UuidV7"; + + public static Guid NewGuid() => Guid.CreateVersion7(); + + public static Guid NewGuid(DateTime timestampUtc) => + Guid.CreateVersion7(new DateTimeOffset(UtcTimestamp.EnsureUtc(timestampUtc))); + + public static DateTime GetTimestamp(Guid value) => FastGuid.UUID.ExtractDateTimeV7(value); + + public static Guid MinValueFor(DateTime timestampUtc) => ForTimestamp(timestampUtc, minimum: true); + + public static Guid MaxValueFor(DateTime timestampUtc) => ForTimestamp(timestampUtc, minimum: false); + + static Guid ForTimestamp(DateTime timestampUtc, bool minimum) + { + var milliseconds = new DateTimeOffset(UtcTimestamp.EnsureUtc(timestampUtc)) + .ToUnixTimeMilliseconds(); + Span bytes = stackalloc byte[16]; + bytes[0] = (byte)(milliseconds >> 40); + bytes[1] = (byte)(milliseconds >> 32); + bytes[2] = (byte)(milliseconds >> 24); + bytes[3] = (byte)(milliseconds >> 16); + bytes[4] = (byte)(milliseconds >> 8); + bytes[5] = (byte)milliseconds; + bytes[6] = 0x70; // version 7 + bytes[8] = 0x80; // RFC 9562 variant + + if (!minimum) + { + bytes[6] |= 0x0F; + bytes[7] = 0xFF; + bytes[8] |= 0x3F; + bytes[9..].Fill(0xFF); + } + + return new Guid(bytes, bigEndian: true); + } +} + +/// Rejects an ambiguous timestamp before it becomes a silent ordering bug. +static class UtcTimestamp +{ + public static DateTime EnsureUtc(DateTime timestampUtc) => + timestampUtc.Kind == DateTimeKind.Unspecified + ? throw new ArgumentException( + "A sequential GUID timestamp must be UTC; Unspecified cannot be ordered reliably.", + nameof(timestampUtc)) + : timestampUtc.ToUniversalTime(); +} +``` + +- **SQL Server weights the trailing bytes most.** It compares `uniqueidentifier` by its last six bytes, + then the preceding groups. `FastGuid.NewSqlServerGuid()` puts the increasing part where SQL Server + sorts it. `Guid.CreateVersion7()` does not, so it behaves like a random value on a clustered index. +- **PostgreSQL compares `uuid` in plain big-endian byte order.** `FastGuid.NewPostgreSqlGuid()` and + `Guid.CreateVersion7()` both work there. Npgsql converts the value to the big-endian wire form. +- **UUIDv7 is RFC 9562, and it is the fallback.** Use it when no provider strategy is registered, or on + PostgreSQL. It is genuinely sequential on PostgreSQL and not sequential on SQL Server. Choosing it + everywhere is the mistake this page exists to prevent. +- **`EnsureUtc` rejects `DateTimeKind.Unspecified`.** An unspecified timestamp is interpreted in the + machine's local zone, so the same code orders differently on two hosts. That is a silent ordering + bug, not a convenience. +- **`FastGuid` values are not RFC 9562 UUIDs.** They are ordinary GUIDs with a store-specific layout, + so PostgreSQL's `uuid_extract_timestamp()` cannot read them. `GetTimestamp` is the only supported way + to recover the timestamp. + +### 3. The options extension is the decision, made once + +```csharp +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Acme.Persistence.ValueGeneration; + +/// The strategy choice, carried as data on the context options. +public sealed class SequentialGuidOptionsExtension(string strategyName, Type valueGeneratorType, Type factoryType) + : IDbContextOptionsExtension +{ + public string StrategyName { get; } = strategyName; + public Type ValueGeneratorType { get; } = valueGeneratorType; + public Type FactoryType { get; } = factoryType; + + public DbContextOptionsExtensionInfo Info => field ??= new ExtensionInfo(this); + + // Registered separately from EF's internal generator so application code can mint the same + // values for outbox rows, event envelopes and correlation ids. + public void ApplyServices(IServiceCollection services) => + services.AddSingleton(typeof(ISequentialGuidFactory), FactoryType); + + public void Validate(IDbContextOptions options) + { + // The strategy participates in EF's service-provider cache key. An external internal + // provider would bypass that key, so the first provider to build the model would win. + if (options.FindExtension()?.InternalServiceProvider is not null) + { + throw new InvalidOperationException( + "Sequential GUID generation cannot be combined with UseInternalServiceProvider."); + } + } + + sealed class ExtensionInfo(SequentialGuidOptionsExtension extension) + : DbContextOptionsExtensionInfo(extension) + { + // DbContextOptionsExtensionInfo.Extension is typed as the interface, so this narrows it. + public new SequentialGuidOptionsExtension Extension => (SequentialGuidOptionsExtension)base.Extension; + + public override bool IsDatabaseProvider => false; + public override string LogFragment => $"SequentialGuids={Extension.StrategyName} "; + + public override int GetServiceProviderHashCode() => + HashCode.Combine(Extension.ValueGeneratorType, Extension.FactoryType); + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => + other is ExtensionInfo otherInfo + && otherInfo.Extension.ValueGeneratorType == Extension.ValueGeneratorType + && otherInfo.Extension.FactoryType == Extension.FactoryType; + + public override void PopulateDebugInfo(IDictionary debugInfo) + { + debugInfo["SequentialGuid:Strategy"] = Extension.StrategyName; + debugInfo["SequentialGuid:ValueGenerator"] = + Extension.ValueGeneratorType.AssemblyQualifiedName ?? string.Empty; + } + } +} +``` + +The two comments are the design. `ApplyServices` registers the application-facing factory, and the +`Validate` guard refuses a combination that would make the strategy's cache key irrelevant. The +`ExtensionInfo` hash and `ShouldUseSameServiceProvider` must agree with the fields: if either ignores +the value generator or the factory, two strategies collapse to one cached service provider. + +### 4. The generator, with value-object conversion + +The subtle part is the converter. EF passes the generator's return value to the property as-is, so a key +modelled as a `readonly record struct OrderId` needs the `Guid` converted. The generator declares +`GeneratesTemporaryValues => false`, because a temporary key would make EF rewrite the value after the +insert and break every reference built before the commit. + +```csharp +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +namespace Acme.Persistence.ValueGeneration; + +/// Creates the generator and hands it the property's value converter. +public sealed class SequentialGuidValueGeneratorFactory : ValueGeneratorFactory + where TProvider : ISequentialGuidProvider +{ + public override ValueGenerator Create(IProperty property, ITypeBase typeBase) + { + ValueGenerator generator = new SequentialGuidValueGenerator(); + var converter = property.GetValueConverter(); + return converter is null ? generator : generator.WithConverter(converter); + } +} + +/// Generates the strategy's values from the application, not from the store. +public sealed class SequentialGuidValueGenerator : ValueGenerator + where TProvider : ISequentialGuidProvider +{ + public override bool GeneratesTemporaryValues => false; + + public override Guid Next(EntityEntry entry) => TProvider.NewGuid(); +} +``` + +`ValueGenerator.WithConverter` wraps the generator so the value is converted to the model type before +EF assigns it. Without it, a `OrderId` key receives a boxed `Guid` and the insert fails or the value is +silently unusable. + +### 5. The convention, applied to every key + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +namespace Acme.Persistence.ValueGeneration; + +/// Applies the strategy to every GUID key at convention precedence. +public sealed class SequentialGuidConvention(Func? alsoGenerate = null) + : IModelFinalizingConvention + where TProvider : ISequentialGuidProvider +{ + static readonly Type FactoryType = typeof(SequentialGuidValueGeneratorFactory); + + public void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context) + { + GuardFactory(); + + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (!ShouldGenerate(property)) + continue; + + var builder = property.Builder + ?? throw new InvalidOperationException( + $"'{property.Name}' is read-only during model finalisation."); + builder.ValueGenerated(ValueGenerated.OnAdd, fromDataAnnotation: false); + builder.HasValueGeneratorFactory(FactoryType, fromDataAnnotation: false); + } + } + } + + bool ShouldGenerate(IReadOnlyProperty property) + { + if (!IsGuid(property.ClrType)) + return false; + + // A foreign key takes its value from the principal, and a default or computed column is + // owned by the store. Explicit configuration wins because this writes at convention + // precedence, the lowest. + if (property.IsForeignKey() + || property.GetDefaultValueSql() is not null + || property.GetComputedColumnSql() is not null) + { + return false; + } + + return property.IsKey() || (alsoGenerate?.Invoke(property) ?? false); + } + + static bool IsGuid(Type clrType) => + clrType == typeof(Guid) + || clrType == typeof(Guid?) + || typeof(IComparable).IsAssignableFrom(clrType); + + static void GuardFactory() + { + if (!typeof(ValueGeneratorFactory).IsAssignableFrom(FactoryType) + || FactoryType.GetConstructor(Type.EmptyTypes) is null) + { + throw new InvalidOperationException( + $"'{FactoryType}' must derive from ValueGeneratorFactory and have a parameterless constructor."); + } + } +} +``` + +Every branch exists for a reason: + +- **`IModelFinalizingConvention`, not a loop at the end of `OnModelCreating`.** The convention sees + entity types discovered late through navigations, complex types, and configuration classes applied + in an order the context does not control. +- **The convention configuration source is the lowest precedence.** Explicit configuration therefore + wins, and `property.Builder` is non-null for a property in a finalising model; the guard makes that + assumption explicit rather than dereferencing silently. +- **`Guid`, `Guid?`, and any `IComparable`.** The third form is how a scalar value-object + identifier models its key, and it is the case a `Guid`-only check misses. +- **Foreign keys are skipped.** A foreign key takes its value from the principal that owns it. +- **Default and computed columns are skipped.** The store owns those values. +- **Keys are always included.** `alsoGenerate` widens the set to indexed non-key GUID columns, which + pay the same fragmentation cost. In `ConfigureConventions`, where `configurationBuilder` is the + `ModelConfigurationBuilder`: + + ```csharp + configurationBuilder.Conventions.Add( + _ => new SequentialGuidConvention( + property => property.GetContainingIndexes().Any())); + ``` + +- **The factory is guarded.** `HasValueGeneratorFactory` takes a type, so a compile error cannot catch + a type that does not derive from `ValueGeneratorFactory` or lacks a parameterless constructor. The + guard turns that into a model-build failure with a name in it. + +### 6. Wiring: one call per provider + +The options builder extension carries the strategy's three facts — its name, its generator and its +application-facing factory — as one extension: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Acme.Persistence.ValueGeneration; + +/// Carries the provider's strategy on the context options, once. +public static class SequentialGuidDbContextOptionsBuilderExtensions +{ + public static DbContextOptionsBuilder UseSequentialGuids(this DbContextOptionsBuilder builder) + where TProvider : ISequentialGuidProvider + { + ArgumentNullException.ThrowIfNull(builder); + + var extension = new SequentialGuidOptionsExtension( + TProvider.Name, + typeof(SequentialGuidValueGenerator), + typeof(SequentialGuidFactory)); + + ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(extension); + + return builder; + } +} + +/// The application-facing view of the configured strategy. +public interface ISequentialGuidFactory +{ + Guid NewGuid(); + Guid NewGuid(DateTime timestampUtc); + DateTime GetTimestamp(Guid value); + Guid MinValueFor(DateTime timestampUtc); + Guid MaxValueFor(DateTime timestampUtc); +} + +/// Forwards to the strategy so application code never resolves the wrong instance. +public sealed class SequentialGuidFactory : ISequentialGuidFactory + where TProvider : ISequentialGuidProvider +{ + public Guid NewGuid() => TProvider.NewGuid(); + + public Guid NewGuid(DateTime timestampUtc) => TProvider.NewGuid(timestampUtc); + + public DateTime GetTimestamp(Guid value) => TProvider.GetTimestamp(value); + + public Guid MinValueFor(DateTime timestampUtc) => TProvider.MinValueFor(timestampUtc); + + public Guid MaxValueFor(DateTime timestampUtc) => TProvider.MaxValueFor(timestampUtc); +} +``` + +The host registers the context, and the application-facing factory, with the same provider: + +```csharp +builder.Services.AddDbContext((services, options) => +{ + options + .UseSqlServer(connectionString) + .UseSequentialGuids() + .ReplaceService() + .ReplaceService(); +}); + +builder.Services.AddSingleton>(); +``` + +The convention itself is registered where the context configures its conventions, so the same static is +reachable from a context that cannot derive from the application base context: + +```csharp +using Microsoft.EntityFrameworkCore; + +public sealed class AcmeDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + AcmeModelConventions.ApplySequentialGuids(configurationBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + // ... entity configuration ... + } +} + +public static class AcmeModelConventions +{ + public static void ApplySequentialGuids(ModelConfigurationBuilder configurationBuilder) + where TProvider : ISequentialGuidProvider + { + configurationBuilder.Conventions.Add( + _ => new SequentialGuidConvention( + property => property.GetContainingIndexes().Any())); + } +} +``` + +Four wiring obligations are easy to miss: + +- **The design-time options builder must do the same three things.** If it omits `UseSequentialGuids` + or either `ReplaceService`, the migration snapshot and the runtime model disagree, and the next + migration will contain a change nobody intended. +- **A context that cannot derive from the application base context calls the shared static.** ASP.NET + Identity's context is the usual case: it must call `AcmeModelConventions.ApplySequentialGuids` from + its own `ConfigureConventions`, and its own `base.OnModelCreating` first. Otherwise the Identity + user key is random while every other key is sequential. +- **Register the application-facing factory separately.** EF's internal registration is not reachable + from application code. Outbox rows, event envelopes and correlation values need the same values, and + a second implementation is a second ordering. +- **Replace `IModelCacheKeyFactory` when the model varies by design-time state or tenant.** The service + provider hash separates strategies, but tenant-specific model configuration is not in the options. + The cache key must include it, or the first tenant's model is served to the next. + +The sample at `samples/Acme.Persistence` closes `AcmeDbContext` over the strategy so one executable can +demonstrate all three providers. A single-provider application can make the type parameter concrete +instead, or hard-code the provider in `ConfigureConventions` as above; the convention and the wiring are +otherwise unchanged. + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +/// Separates the model cache by context type, design time and strategy. +public sealed class AcmeModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) => + ( + context.GetType(), + designTime, + context.GetService() + .FindExtension() + ?.StrategyName + ); +} +``` + +### 7. Where UUIDv5 and sequential keys meet + +- A UUIDv5 identifier is explicitly keyed with `ValueGeneratedNever()`, so this convention leaves it + alone. That is the documented exception, and a test must prove it: [Well-Known + Identifiers](Well-Known-Identifiers.md) publishes the check. +- Never apply the sequential convention to a table whose key is derived. The derived value is the + point; a "sequential" derived key is a contradiction. +- Conversely, do not use UUIDv5 to make ordinary rows deterministic. It is not sequential, and a + high-write table pays for that at the index. Derive only the known identities. + +## How it is enforced + +The wiki cannot enforce this. A suite attached to the adopting repository does, and these are the +checks to write. They are tests in that repository, not in this one. `samples/Acme.Persistence` runs the +executable subset — monotonicity, the UUIDv7 trap, timestamp round-trips and range bounds — through +`just samples`; it is a sample self-check, not a suite in the pipeline. + +| Check | Shape | +| -- | -- | +| Store-order monotonicity | 10,000 values; `SqlGuid.CompareTo` on SQL Server; big-endian `SequenceCompareTo` on PostgreSQL. | +| UUIDv7 is not SQL Server monotonic | Assert inversions exceed a threshold. The test documents the trap rather than pretending it is absent. | +| Timestamp round-trip | Extract within 1 ms of the requested UTC timestamp, for every strategy. | +| Range bounds | `MinValueFor(t) <= NewGuid(t) <= MaxValueFor(t)` under the store's comparison. | +| Every key covered | For each entity type and each GUID key that is not a foreign key: `ValueGenerated.OnAdd` and the provider's factory. | +| Foreign keys excluded | A foreign-key GUID is never `OnAdd` from this convention. | +| Explicit configuration wins | A `ValueGeneratedNever()` property stays `Never`. | +| Framework keys included | The Identity user's key uses the configured generator. | +| Two providers, one context type | Different strategies produce different service providers and different models. | +| Integration | Insert N rows; keys ascend in the store's order; a repeat run leaves no duplicates. | + +## At your scale + +- **One provider, low write volume.** `Guid.NewGuid()`. Do not build any of this. Fragmentation never + matters at tens of inserts a minute, and a random GUID is the simpler, more private choice. +- **One provider, high write volume, value needed after insert.** `NEWSEQUENTIALID()` on SQL Server, + `uuidv7()` on PostgreSQL 18 and later. Both are store capabilities and both are sufficient. +- **One provider, value needed before insert.** `Guid.CreateVersion7()` with `ValueGeneratedNever()` on + PostgreSQL; EF Core's `SequentialGuidValueGenerator` on SQL Server. Still no abstraction. +- **Two providers, or identifiers needed before insert on both.** The strategy and convention above. + The abstraction is earned by the second provider, not by anticipation. + +## See also + +- [Well-Known Identifiers](Well-Known-Identifiers.md) — the derived keys this convention must skip +- [EF Core Integration](EF-Core-Integration.md) — the context, model and migration rules around it +- [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) — value-object identifiers and their + converters +- [Worked Examples](Worked-Examples.md) — why `src/` has no persistence to compile these against diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 6c6438e..8742f17 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -39,6 +39,9 @@ rule in full. | [Architecture as Tests](Architecture-As-Tests.md) | How the build refuses a dependency that points the wrong way. | | [Verification Model](Verification-Model.md) | Which checks run, in which tier, for which change. | | [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) | How value objects, schemas and results cross a boundary. | +| [Well-Known Identifiers](Well-Known-Identifiers.md) | Identities whose value must be identical in every process and restart, derived with UUIDv5. | +| [GUID Key Strategy](Guid-Key-Strategy.md) | Choosing and applying a store-ordered GUID key for SQL Server or PostgreSQL. | +| [EF Core Integration](EF-Core-Integration.md) | How the context, providers, value objects, models and migrations fit together. | | [Testing and Test Data](Testing-And-Test-Data.md) | How tests are written, and where their data comes from. | | [Agent Enablement](Agent-Enablement.md) | How a repository is made legible to a coding agent. | | [Agent Skills Distribution](Agent-Skills-Distribution.md) | How a skill is published once and reaches both a package's consumers and an agent's own directories. | @@ -58,14 +61,20 @@ just prose: the dependency-direction rules fail the build when they are broken, conventions are shown in the form they are meant to be copied. [Worked Examples](Worked-Examples.md) walks it end to end. +The conventions that need EF Core live in `samples/Acme.Persistence`, a standalone runnable sample +started with `just samples`. It is outside `src/` so the reference implementation stays in-memory. +[EF Core Integration](EF-Core-Integration.md), [Well-Known Identifiers](Well-Known-Identifiers.md) and +[GUID Key Strategy](Guid-Key-Strategy.md) are the rules it proves. + ## Provenance -Every rule here is drawn from a public Purview repository — chiefly +Every rule here is drawn from Purview repositories and the public specifications and packages cited on +its page — chiefly [build-sdk](https://github.com/purview-dev/build-sdk) (the MSBuild SDK that supplies the defaults), [containers](https://github.com/purview-dev/containers) (a multi-package library with two backends), [value-objects](https://github.com/purview-dev/value-objects), [results](https://github.com/purview-dev/results) and [event-sourcing](https://github.com/purview-dev/event-sourcing) (data exchange at a boundary), and [purview-dev.github.io](https://github.com/purview-dev/purview-dev.github.io) (the public catalogue, -and documentation as a published surface). Each page names the repositories it was derived from, so a -claim can always be checked against running code; [Cross-Repository -Harvest](Cross-Repository-Harvest.md) is how that claim stays true. +and documentation as a published surface). Each page names the public repository, specification or +package a claim can be checked against, so a claim can always be checked against running code; +[Cross-Repository Harvest](Cross-Repository-Harvest.md) is how that claim stays true. diff --git a/docs/wiki/Project-Responsibilities.md b/docs/wiki/Project-Responsibilities.md index ea83dd4..db177f7 100644 --- a/docs/wiki/Project-Responsibilities.md +++ b/docs/wiki/Project-Responsibilities.md @@ -287,6 +287,11 @@ Application never references `Persistence.SqlServer`. It receives an implementat Persistence **owns** migration definitions. Migrator **executes** them. Splitting those two is what stops an API startup path from mutating a production schema. +Persistence also owns the `DbContext`, the mappings and the identifier strategy's model wiring; the +provider choice belongs where the context is registered, usually Composition or the host. Core and +Application never see EF Core. [EF Core Integration](EF-Core-Integration.md) states the rules that keep +that boundary honest, and [GUID Key Strategy](Guid-Key-Strategy.md) owns the identifier-specific part. + ### Diagram G — persistence and migration ```text @@ -459,6 +464,7 @@ flowchart TD ## See also - [Architecture as Tests](Architecture-As-Tests.md) — how a dependency rule becomes a failing build +- [EF Core Integration](EF-Core-Integration.md) — what the Persistence project owns - [Repository Shape](Repository-Shape.md) - [Engineering Principles](Engineering-Principles.md) - [Adopting This at Your Scale](Adopting-This-At-Your-Scale.md) diff --git a/docs/wiki/Testing-And-Test-Data.md b/docs/wiki/Testing-And-Test-Data.md index b891d43..7bd22b0 100644 --- a/docs/wiki/Testing-And-Test-Data.md +++ b/docs/wiki/Testing-And-Test-Data.md @@ -466,10 +466,13 @@ Three rules make such a suite safe to own: than a stability guarantee. The `Purview.Containers` packages are a family, so their version is a single property in `Directory.Packages.props` and a bump cannot be applied to half of them. -**`src/` does not demonstrate this either**, and for the stated reason: this repository ships no persistence -and its pipeline runs the `[Category=Unit]` tier only. -[containers](https://github.com/purview-dev/containers) is the repository to read instead — its -`integration-docker` job is the second-job pattern in use. +**`src/` does not demonstrate this either**, and for the stated reason: the reference implementation ships +no persistence and its pipeline runs the `[Category=Unit]` tier only. `samples/Acme.Persistence` shows the +EF Core side — a real provider when given a connection string, in-memory only for the runnable default — +but it is a console self-check rather than a test project, and `just samples` is its check. +[containers](https://github.com/purview-dev/containers) is the repository to read for the second-job +pattern in use, and [EF Core Integration](EF-Core-Integration.md) states the rules a container-backed suite +should prove. ## How it is enforced @@ -510,6 +513,7 @@ and its pipeline runs the `[Category=Unit]` tier only. - [Verification Model](Verification-Model.md) - [Architecture as Tests](Architecture-As-Tests.md) +- [EF Core Integration](EF-Core-Integration.md) - [Worked Examples](Worked-Examples.md) - [BDTest](https://bdtest.tomlonghurst.dev/) - [Purview.Containers](https://purview.dev/docs/containers/) diff --git a/docs/wiki/Verification-Model.md b/docs/wiki/Verification-Model.md index afc6ad9..2a46ac9 100644 --- a/docs/wiki/Verification-Model.md +++ b/docs/wiki/Verification-Model.md @@ -156,6 +156,13 @@ Three things are visible in that snippet, and all three are deliberate: the repo caller; the pack switches match `.config/purview-build.json`; and the filter is the same string as the one in the configuration, so the two cannot drift apart silently. +The repository also owns a runnable sample, `samples/Acme.Persistence`, that the pipeline does **not** +build. It sits outside `src/`, has no analyzers and is not in the canonical solution; `just samples` is +its verification, and it fails with a non-zero exit code when its identifier or model checks are untrue. +That is a deliberate fourth tier — a sample, not a test — and the page says so rather than counting it in +the fast job. [EF Core Integration](EF-Core-Integration.md) lists the checks the sample and an adopting +repository's integration suite should cover. + ## A gate is not bypassed A check that runs before a merge — a hook, a required status check, a lint step in the pipeline — is @@ -196,6 +203,7 @@ ten minutes. | The repository's configuration is valid | The pipeline, which validates the JSON against the schema | | A gate is never bypassed | **Review obligation** — and a required status check as the backstop, because a hook cannot enforce its own non-bypass | | The slow tier is run before a release | Review — and, where it matters, a release gate | +| The runnable sample still passes its own checks | **Review** — `just samples`, deliberately outside the pipeline | ## At your scale @@ -211,6 +219,8 @@ ten minutes. - **A hook that is too slow.** Fix the hook — narrow what it runs, or move the slow tier behind a required check — rather than passing `--no-verify`. The exemption you take once is the one the next person takes by default. +- **A convention whose evidence is a sample.** Keep the sample outside the solution and give it a single + command that fails loudly, as `just samples` does. Do not call it a test unless the pipeline runs it. - **A platform.** Make the gate a required status check as well as a hook, so the hook is a convenience and the check is the guarantee. Keep an audit trail of who merged with a check missing, because that is the only way a bypass is ever visible. diff --git a/docs/wiki/Well-Known-Identifiers.md b/docs/wiki/Well-Known-Identifiers.md new file mode 100644 index 0000000..be96179 --- /dev/null +++ b/docs/wiki/Well-Known-Identifiers.md @@ -0,0 +1,316 @@ +# Well-Known Identifiers + +## The rule + +An identifier whose value is known before storage is **derived**, not generated. The derivation is +UUIDv5 over a namespace and a name, and it carries five obligations: + +1. **Derive it.** `Uuid5.Create(namespace, name)` produces an RFC 4122 version-5 (SHA-1) UUID that is + byte-identical for the same namespace and name in every process, language and restart. +2. **Scope the namespace to the isolation boundary.** The namespace is one installation or one + deployment — never a global constant shared by every deployment. Two installations that share a + namespace share identities they must not share. +3. **Fix the name as a canonical constant of the identity kind.** It is not a display name, not user + data and not a secret. It identifies a role in the system, not a row's contents. +4. **Treat the persisted value as authoritative.** Once written, never recompute it in a later release + and update the row. If the derivation and the row disagree, the row is what every reference already + points at. +5. **Key it explicitly.** An entity whose key is derived configures `ValueGeneratedNever()`. It is + excluded from [GUID Key Strategy](Guid-Key-Strategy.md), whose convention must never generate over + a value the application already knows. + +### The catalogue + +The identities worth deriving are few and enumerable. Write them down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +The last row is conditional. Derive it only when the provider guarantees the external key is stable and +never recycled. If the provider can recycle a key, keep the local key sequential and store the external +key as a unique column instead; a recycled external key would otherwise inherit the identity, history +and references of the object that used it before. + +### Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never built from a display name, email, URL, title, or anything a user can edit. +- Never built from a secret and never used as a capability token. A name is guessable; the derived + identifier is guessable too. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration, not a rename. + +## Why + +Each obligation prevents a specific failure: + +| Obligation | Failure it prevents | +| -- | -- | +| Derive, do not generate | A second bootstrap run inserts a duplicate system tenant, or a second replica mints a different id for the same logical row. | +| Scope the namespace | Two installations collide, so one deployment's system tenant appears in another's data. | +| Canonical name | Correcting a display name changes the id, and every foreign key and audit row now points at a row that no longer exists under that id. | +| Persisted value wins | A later release recomputes an id and updates the row, breaking references and history that were built on the old value. | +| Explicit keying | The sequential-GUID convention overwrites a derived key, so "deterministic" quietly becomes random. | + +There is one more failure worth naming because it looks correct in review: a namespace generated per +process. `Guid.NewGuid()` at startup is random on every restart, so the "deterministic" identifier is +deterministic only until the process restarts. The namespace must be persisted configuration — the +installation id — not a runtime value. + +**The limit: UUIDv5 is not sequential.** Do not use it for a high-write table's primary key. It exists +for a small, known set of identities; every other key takes [GUID Key Strategy](Guid-Key-Strategy.md). + +## The samples + +The complete implementation is `samples/Acme.Persistence` at the repository root; `just samples` builds +and runs it. The page shows the parts that carry the decision, and the sample is the copyable whole. It +lives outside `src/` because the reference implementation there is in-memory by design, and adding EF +Core, a persistence project and a database would contradict [Worked Examples](Worked-Examples.md). + +### 1. The helper + +```csharp +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; + +namespace Acme; + +/// Deterministic RFC 4122 version-5 (SHA-1) UUID generation. +public static class Uuid5 +{ + public static readonly Guid NamespaceDns = new("6ba7b810-9dad-11d1-80b4-00c04fd430c8"); + public static readonly Guid NamespaceUrl = new("6ba7b811-9dad-11d1-80b4-00c04fd430c8"); + public static readonly Guid NamespaceOid = new("6ba7b812-9dad-11d1-80b4-00c04fd430c8"); + public static readonly Guid NamespaceX500 = new("6ba7b814-9dad-11d1-80b4-00c04fd430c8"); + + [SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", + Justification = "SHA-1 is mandated by RFC 4122 for UUIDv5.")] + public static Guid Create(Guid namespaceId, string name) + { + ArgumentNullException.ThrowIfNull(name); + + // The text form is big-endian. Guid.ToByteArray() and new Guid(byte[]) use a mixed-endian + // layout and produce the wrong hash for every namespace whose first three groups are not + // palindromic — which is most of them. + Span namespaceBytes = stackalloc byte[16]; + namespaceId.TryWriteBytes(namespaceBytes, bigEndian: true, out _); + + var nameBytes = Encoding.UTF8.GetBytes(name); + var hashInput = new byte[16 + nameBytes.Length]; + namespaceBytes.CopyTo(hashInput.AsSpan(0, 16)); + nameBytes.CopyTo(hashInput, 16); + + var hash = SHA1.HashData(hashInput); + + var result = hash.AsSpan(0, 16); + result[6] = (byte)((result[6] & 0x0F) | 0x50); // version 5 + result[8] = (byte)((result[8] & 0x3F) | 0x80); // RFC 4122 variant + + return new Guid(result, bigEndian: true); + } +} +``` + +The endianness comment is load-bearing. `Guid.ToByteArray()` writes the first three groups +little-endian, so the same namespace hashes differently through that path. The bug is invisible for +palindromic namespaces and wrong for almost every real installation id. + +### 2. The catalogue as code + +```csharp +namespace Acme.Installation; + +/// Well-known identities scoped to one installation. +public sealed record WellKnownIdentifiers(InstallationId Installation) +{ + public Guid SystemTenantId => Uuid5.Create(Installation.Value, "system-tenant"); + public Guid SystemPrincipalId => Uuid5.Create(Installation.Value, "system-service-principal"); + + public Guid ReferenceRow(string entity, string code) => + Uuid5.Create(Installation.Value, $"{entity}:{code}".ToLowerInvariant()); +} +``` + +`InstallationId` is the installation's strongly-typed identifier over a `Guid`; its `Value` is the +namespace. `ReferenceRow` lowercases the composed name so `Role` and `role` cannot produce two +identities for the same code. + +### 3. Idempotent bootstrap + +The sequence matters more than the surrounding seeder. Read the persisted identity; if it exists it +wins, and a configured value must match or the operation fails. If it does not exist, insert it. Derive +dependants from the persisted id, save once, and let a second run change nothing. + +```csharp +using Acme.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Acme.Installation; + +public sealed class InstallationBootstrap( + AcmeDbContext dbContext, + IOptions options) +{ + public async Task RunAsync(CancellationToken cancellationToken) + { + var configured = new InstallationId(options.Value.InstallationId); + var persisted = await dbContext + .Installations.OrderBy(installation => installation.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + // The persisted identity is authoritative; a configured one that disagrees is a + // deployment error, not a reason to rewrite the row. + if (persisted is not null && persisted.Id != configured) + { + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match " + + $"the persisted id '{persisted.Id.Value}'."); + } + + var installation = persisted ?? new Installation(configured, DateTime.UtcNow); + if (persisted is null) + dbContext.Installations.Add(installation); + + var wellKnown = new WellKnownIdentifiers(installation.Id); + if ( + !await dbContext + .Tenants.AnyAsync(tenant => tenant.Id == wellKnown.SystemTenantId, cancellationToken) + .ConfigureAwait(false) + ) + { + dbContext.Tenants.Add(new Tenant(wellKnown.SystemTenantId, "System")); + } + + if ( + !await dbContext + .ServicePrincipals.AnyAsync( + principal => principal.Id == wellKnown.SystemPrincipalId, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + dbContext.ServicePrincipals.Add( + new ServicePrincipal(wellKnown.SystemPrincipalId, "System") + ); + } + + // One commit: either the installation and its system identities exist, or none do. + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +`Installation`, `Tenant` and `ServicePrincipal` are ordinary EF entities. `Installation` holds the +installation id; the other two are keyed by the derived values and configure `ValueGeneratedNever()`. +The sample is an extract: the point is the read-or-insert sequence and the guard, not the shape of the +rows. + +### 4. Migrations cannot call the helper + +A migration and `HasData` are compiled and replayed, so they need a literal GUID. They cannot call +`Uuid5.Create`, because the namespace is deployment configuration and a migration must produce the same +SQL on every machine that applies it. + +The pattern is a small script that prints ready-to-paste constants from the same derivation, and a test +that asserts every printed constant equals `Uuid5.Create(...)`. The literal installation id below is +fixed so the constants are reproducible; a real repository substitutes the deployment's id. + +```csharp +// tools/DerivedIdentifiers/Program.cs +using Acme.Installation; + +var installation = new InstallationId(Guid.Parse(args[0])); +var wellKnown = new WellKnownIdentifiers(installation); + +Console.WriteLine($"tenant: {wellKnown.SystemTenantId:D}"); +Console.WriteLine($"principal: {wellKnown.SystemPrincipalId:D}"); +``` + +For installation `11111111-1111-4111-8111-111111111111` the script prints: + +```text +tenant: 598f4dd5-4881-5833-8615-2915ab6f7ac9 +principal: 62331408-8c66-5cd0-82e1-d8fc91642fb4 +``` + +Paste the tenant constant into the migration and the test: + +```csharp +migrationBuilder.InsertData( + table: "Tenants", + columns: ["Id", "Name"], + values: [Guid.Parse("598f4dd5-4881-5833-8615-2915ab6f7ac9"), "System"]); +``` + +```csharp +using Acme; + +[Test] +public async Task SeededTenant_GivenTheMigrationConstant_MatchesTheDerivation() +{ + var namespaceId = Guid.Parse("11111111-1111-4111-8111-111111111111"); + var seeded = Guid.Parse("598f4dd5-4881-5833-8615-2915ab6f7ac9"); + + await Assert.That(seeded).IsEqualTo(Uuid5.Create(namespaceId, "system-tenant")); +} +``` + +**Changing the namespace or a name after a migration has shipped is a data migration.** Every row +already written carries the old value, and every reference points at it. Plan the backfill; do not +change the constant and hope. + +## How it is enforced + +The wiki cannot enforce this. A suite attached to the adopting repository does, and these are the +checks to write. They are tests in that repository, not in this one. `samples/Acme.Persistence` runs the +RFC vectors and catalogue-uniqueness subset through `just samples`; the bootstrap and explicit-keying +checks are the adopting repository's. + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500, plus the empty name; assert exact GUIDs. | +| Determinism | The same pair twice is equal; different names differ. | +| Version and variant bits | Byte 6 high nibble is `0x5`; byte 8 high bits are `10`. | +| Cross-language equality | A second implementation (Python `uuid.uuid5` or Node `crypto`) reproduces the vectors. | +| Catalogue uniqueness | The catalogue's constants are distinct. | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws. | +| Explicit keying | The entity's key is `ValueGenerated.Never`, and the sequential convention leaves it alone. | + +The reference vectors below are the contract. They match RFC 4122 and Python's `uuid.uuid5`, and an +implementation that does not reproduce them is not UUIDv5 — most often because of the endianness trap +in the helper. + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +RFC 4122 is the specification for version 5; [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562) is the +current UUID specification and retains the version-5 algorithm. The .NET BCL has no UUIDv5 API, which +is why the helper above exists rather than a package reference. + +## At your scale + +- **Single deployment, no seed or ingest.** Do not add this. Use [GUID Key + Strategy](Guid-Key-Strategy.md) for every key; there is no identity whose value is known in advance. +- **One installation, a handful of system rows.** One namespace constant and two or three names. Skip + the catalogue abstraction until there is a second identity kind. +- **Several installations and provider ingestion.** The catalogue above, the derived-literal script, + and the uniqueness and idempotency tests. The namespace becomes configuration that is persisted and + checked at bootstrap, not a constant compiled into the application. + +## See also + +- [GUID Key Strategy](Guid-Key-Strategy.md) — the sequential keys every other table uses +- [EF Core Integration](EF-Core-Integration.md) — mapping the key and the bootstrap in the model +- [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) — value objects that carry an identifier +- [Worked Examples](Worked-Examples.md) — why `src/` has no persistence diff --git a/docs/wiki/Worked-Examples.md b/docs/wiki/Worked-Examples.md index ca37f46..c2eef44 100644 --- a/docs/wiki/Worked-Examples.md +++ b/docs/wiki/Worked-Examples.md @@ -294,9 +294,10 @@ Build succeeded. 0 Error(s) Test run summary: Passed! - total: 71 + total: 90 failed: 0 - succeeded: 71 + succeeded: 85 + skipped: 5 ``` The zero-warning result is worth noticing, because it took work: the telemetry source generator's public @@ -319,7 +320,13 @@ The fastest way to understand a rule is to watch it fail. Three edits, each of w - **No package.** It ships no NuGet package, so there is no pack metadata, no pack validation manifest and no consumer-verification script. Those mechanisms are documented, and [containers](https://github.com/purview-dev/containers) is the reference for them. -- **No persistence.** The adapter is in-memory. There is no migration, no schema, no connection string. +- **No persistence in `src/`.** The adapter is in-memory. There is no migration, no schema and no + connection string in the canonical solution. +- **No persistence-specific identifier code in `src/`.** [Well-Known Identifiers](Well-Known-Identifiers.md), + [GUID Key Strategy](Guid-Key-Strategy.md) and [EF Core Integration](EF-Core-Integration.md) are + implemented by the standalone sample at `samples/Acme.Persistence`. `just samples` builds and runs it; + it sits outside the canonical `src/` solution because the reference implementation here is in-memory by + design. - **No web layer.** The result-to-response mapping in [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) is described, not implemented, because [results](https://github.com/purview-dev/results) already implements it and a second implementation here @@ -339,3 +346,4 @@ Scale](Adopting-This-At-Your-Scale.md) is the page that decides. - [Architecture as Tests](Architecture-As-Tests.md) - [Testing and Test Data](Testing-And-Test-Data.md) - [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) +- [EF Core Integration](EF-Core-Integration.md) diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index e664445..bb0b0d8 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -6,6 +6,9 @@ - [Architecture as Tests](Architecture-As-Tests.md) - [Verification Model](Verification-Model.md) - [Contracts and Data Exchange](Contracts-And-Data-Exchange.md) +- [Well-Known Identifiers](Well-Known-Identifiers.md) +- [GUID Key Strategy](Guid-Key-Strategy.md) +- [EF Core Integration](EF-Core-Integration.md) - [Testing and Test Data](Testing-And-Test-Data.md) - [Agent Enablement](Agent-Enablement.md) - [Agent Skills Distribution](Agent-Skills-Distribution.md) diff --git a/samples/Acme.Persistence/Acme.Persistence.csproj b/samples/Acme.Persistence/Acme.Persistence.csproj new file mode 100644 index 0000000..4860c87 --- /dev/null +++ b/samples/Acme.Persistence/Acme.Persistence.csproj @@ -0,0 +1,33 @@ + + + Exe + net10.0 + enable + enable + Acme.Persistence + Acme.Persistence + + false + none + false + false + + + + + + + + + + + + diff --git a/samples/Acme.Persistence/Domain/Entities.cs b/samples/Acme.Persistence/Domain/Entities.cs new file mode 100644 index 0000000..3160703 --- /dev/null +++ b/samples/Acme.Persistence/Domain/Entities.cs @@ -0,0 +1,56 @@ +namespace Acme.Domain; + +/// The installation row. Its id is configuration, not a generated value. +public sealed record Installation(InstallationId Id, DateTime CreatedAtUtc); + +/// A system tenant, keyed by a UUIDv5 derived from the installation id. +public sealed class Tenant(Guid id, string name) +{ + public Guid Id { get; } = id; + + public string Name { get; } = name; +} + +/// A system principal, keyed by a UUIDv5 derived from the installation id. +public sealed class ServicePrincipal(Guid id, string name) +{ + public Guid Id { get; } = id; + + public string Name { get; } = name; +} + +/// A customer keyed by the sequential-GUID convention. +public sealed class Customer +{ + public Guid Id { get; init; } + + public string Name { get; init; } = string.Empty; +} + +/// A scalar value-object identifier, to prove the generator honours a converter. +public readonly record struct OrderId(Guid Value) : IComparable +{ + public int CompareTo(Guid other) => Value.CompareTo(other); + + public override string ToString() => Value.ToString("D"); +} + +/// An order whose key is a value object and whose foreign key must not be generated. +public sealed class Order +{ + public OrderId Id { get; init; } + + public Guid CustomerId { get; init; } + + public Guid? ExternalId { get; init; } + + public string Reference { get; init; } = string.Empty; +} + +/// A GUID key with explicit configuration, to prove the convention leaves it alone. +public sealed class Setting +{ + public Guid Id { get; init; } + + public string Name { get; init; } = string.Empty; +} diff --git a/samples/Acme.Persistence/Domain/InstallationId.cs b/samples/Acme.Persistence/Domain/InstallationId.cs new file mode 100644 index 0000000..e0e000f --- /dev/null +++ b/samples/Acme.Persistence/Domain/InstallationId.cs @@ -0,0 +1,11 @@ +namespace Acme.Domain; + +/// The installation's identifier, and the namespace for its well-known identities. +public readonly record struct InstallationId(Guid Value) : IComparable +{ + public int CompareTo(Guid other) => Value.CompareTo(other); + + public static InstallationId Parse(string value) => new(Guid.Parse(value)); + + public override string ToString() => Value.ToString("D"); +} diff --git a/samples/Acme.Persistence/Identifiers/Uuid5.cs b/samples/Acme.Persistence/Identifiers/Uuid5.cs new file mode 100644 index 0000000..479d509 --- /dev/null +++ b/samples/Acme.Persistence/Identifiers/Uuid5.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; + +namespace Acme; + +/// Deterministic RFC 4122 version-5 (SHA-1) UUID generation. +public static class Uuid5 +{ + public static readonly Guid NamespaceDns = new("6ba7b810-9dad-11d1-80b4-00c04fd430c8"); + public static readonly Guid NamespaceUrl = new("6ba7b811-9dad-11d1-80b4-00c04fd430c8"); + public static readonly Guid NamespaceOid = new("6ba7b812-9dad-11d1-80b4-00c04fd430c8"); + public static readonly Guid NamespaceX500 = new("6ba7b814-9dad-11d1-80b4-00c04fd430c8"); + + [SuppressMessage( + "Security", + "CA5350:Do Not Use Weak Cryptographic Algorithms", + Justification = "SHA-1 is mandated by RFC 4122 for UUIDv5." + )] + public static Guid Create(Guid namespaceId, string name) + { + ArgumentNullException.ThrowIfNull(name); + + // The text form is big-endian. Guid.ToByteArray() and new Guid(byte[]) use a mixed-endian + // layout and produce the wrong hash for every namespace whose first three groups are not + // palindromic — which is most of them. + Span namespaceBytes = stackalloc byte[16]; + namespaceId.TryWriteBytes(namespaceBytes, bigEndian: true, out _); + + var nameBytes = Encoding.UTF8.GetBytes(name); + var hashInput = new byte[16 + nameBytes.Length]; + namespaceBytes.CopyTo(hashInput.AsSpan(0, 16)); + nameBytes.CopyTo(hashInput, 16); + + var hash = SHA1.HashData(hashInput); + + var result = hash.AsSpan(0, 16); + result[6] = (byte)((result[6] & 0x0F) | 0x50); // version 5 + result[8] = (byte)((result[8] & 0x3F) | 0x80); // RFC 4122 variant + + return new Guid(result, bigEndian: true); + } +} diff --git a/samples/Acme.Persistence/Identifiers/WellKnownIdentifiers.cs b/samples/Acme.Persistence/Identifiers/WellKnownIdentifiers.cs new file mode 100644 index 0000000..a0c810f --- /dev/null +++ b/samples/Acme.Persistence/Identifiers/WellKnownIdentifiers.cs @@ -0,0 +1,14 @@ +using Acme.Domain; + +namespace Acme.Installation; + +/// Well-known identities scoped to one installation. +public sealed record WellKnownIdentifiers(InstallationId Installation) +{ + public Guid SystemTenantId => Uuid5.Create(Installation.Value, "system-tenant"); + + public Guid SystemPrincipalId => Uuid5.Create(Installation.Value, "system-service-principal"); + + public Guid ReferenceRow(string entity, string code) => + Uuid5.Create(Installation.Value, $"{entity}:{code}".ToLowerInvariant()); +} diff --git a/samples/Acme.Persistence/Persistence/AcmeDbContext.cs b/samples/Acme.Persistence/Persistence/AcmeDbContext.cs new file mode 100644 index 0000000..ab68ed8 --- /dev/null +++ b/samples/Acme.Persistence/Persistence/AcmeDbContext.cs @@ -0,0 +1,79 @@ +using Acme.Domain; +using Acme.Persistence.ValueGeneration; +using Microsoft.EntityFrameworkCore; +using DomainInstallation = Acme.Domain.Installation; + +namespace Acme.Persistence; + +/// The sample model. The provider is a type argument, so each store gets its own model. +public sealed class AcmeDbContext(DbContextOptions> options) : DbContext(options) + where TProvider : ISequentialGuidProvider +{ + public DbSet Installations => Set(); + + public DbSet Tenants => Set(); + + public DbSet ServicePrincipals => Set(); + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Settings => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + AcmeModelConventions.ApplySequentialGuids(configurationBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(installation => + { + installation.HasKey(i => i.Id); + installation + .Property(i => i.Id) + .HasConversion(id => id.Value, value => new InstallationId(value)) + .ValueGeneratedNever(); + }); + + modelBuilder.Entity(tenant => + { + tenant.HasKey(t => t.Id); + tenant.Property(t => t.Id).ValueGeneratedNever(); + tenant.Property(t => t.Name).HasMaxLength(128); + }); + + modelBuilder.Entity(principal => + { + principal.HasKey(p => p.Id); + principal.Property(p => p.Id).ValueGeneratedNever(); + principal.Property(p => p.Name).HasMaxLength(128); + }); + + modelBuilder.Entity(setting => + { + setting.HasKey(s => s.Id); + setting.Property(s => s.Id).ValueGeneratedNever(); + setting.Property(s => s.Name).HasMaxLength(128); + }); + + modelBuilder.Entity(customer => + { + customer.HasKey(c => c.Id); + customer.Property(c => c.Name).HasMaxLength(128); + }); + + modelBuilder.Entity(order => + { + order.HasKey(o => o.Id); + order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value)); + order.Property(o => o.Reference).HasMaxLength(64); + order.HasIndex(o => o.ExternalId); + order.HasOne().WithMany().HasForeignKey(o => o.CustomerId); + }); + } +} diff --git a/samples/Acme.Persistence/Persistence/AcmeModelCacheKeyFactory.cs b/samples/Acme.Persistence/Persistence/AcmeModelCacheKeyFactory.cs new file mode 100644 index 0000000..a8fc2e9 --- /dev/null +++ b/samples/Acme.Persistence/Persistence/AcmeModelCacheKeyFactory.cs @@ -0,0 +1,16 @@ +using Acme.Persistence.ValueGeneration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Acme.Persistence; + +/// Separates the model cache by context type, design time and strategy. +public sealed class AcmeModelCacheKeyFactory : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) => + ( + context.GetType(), + designTime, + context.GetService().FindExtension()?.StrategyName + ); +} diff --git a/samples/Acme.Persistence/Persistence/AcmeModelConventions.cs b/samples/Acme.Persistence/Persistence/AcmeModelConventions.cs new file mode 100644 index 0000000..518cf46 --- /dev/null +++ b/samples/Acme.Persistence/Persistence/AcmeModelConventions.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Acme.Persistence.ValueGeneration; + +/// The shared registration, callable from any context shape. +public static class AcmeModelConventions +{ + public static void ApplySequentialGuids(ModelConfigurationBuilder configurationBuilder) + where TProvider : ISequentialGuidProvider + { + configurationBuilder.Conventions.Add(_ => new SequentialGuidConvention(property => + property.GetContainingIndexes().Any() + )); + } +} diff --git a/samples/Acme.Persistence/Persistence/AcmeModelCustomizer.cs b/samples/Acme.Persistence/Persistence/AcmeModelCustomizer.cs new file mode 100644 index 0000000..012dd99 --- /dev/null +++ b/samples/Acme.Persistence/Persistence/AcmeModelCustomizer.cs @@ -0,0 +1,18 @@ +using Acme.Domain; +using Acme.Persistence.ValueGeneration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Acme.Persistence; + +/// Provider-specific model configuration that is not a convention. +public sealed class AcmeModelCustomizer(ModelCustomizerDependencies dependencies) + : ModelCustomizer(dependencies) + where TProvider : ISequentialGuidProvider +{ + public override void Customize(ModelBuilder modelBuilder, DbContext context) + { + base.Customize(modelBuilder, context); + modelBuilder.Entity().ToTable("Orders"); + } +} diff --git a/samples/Acme.Persistence/Persistence/AcmeServiceCollectionExtensions.cs b/samples/Acme.Persistence/Persistence/AcmeServiceCollectionExtensions.cs new file mode 100644 index 0000000..73b203a --- /dev/null +++ b/samples/Acme.Persistence/Persistence/AcmeServiceCollectionExtensions.cs @@ -0,0 +1,61 @@ +using Acme.Installation; +using Acme.Persistence.ValueGeneration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Acme.Persistence; + +/// Composition for the sample: one strategy, one context, one factory. +public static class AcmeServiceCollectionExtensions +{ + public static IServiceCollection AddAcmePersistence( + this IServiceCollection services, + string? connectionString = null + ) + where TProvider : ISequentialGuidProvider + { + services.AddDbContext>( + (serviceProvider, options) => + { + ConfigureStore(options, connectionString); + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); + } + ); + + // The application-facing factory is a host registration, separate from EF's internal one. + services.AddSingleton>(); + services.AddScoped>(); + + return services; + } + + static void ConfigureStore(DbContextOptionsBuilder options, string? connectionString) + where TProvider : ISequentialGuidProvider + { + if (connectionString is null) + { + options.UseInMemoryDatabase($"acme-{TProvider.Name}"); + return; + } + + if (typeof(TProvider) == typeof(SqlServerSequentialGuidProvider)) + { + options.UseSqlServer(connectionString); + return; + } + + if (typeof(TProvider) == typeof(PostgreSqlSequentialGuidProvider)) + { + options.UseNpgsql(connectionString); + return; + } + + throw new InvalidOperationException( + "UUIDv7 is the fallback strategy; run it against the in-memory store or choose PostgreSQL." + ); + } +} diff --git a/samples/Acme.Persistence/Persistence/InstallationBootstrap.cs b/samples/Acme.Persistence/Persistence/InstallationBootstrap.cs new file mode 100644 index 0000000..7b9113a --- /dev/null +++ b/samples/Acme.Persistence/Persistence/InstallationBootstrap.cs @@ -0,0 +1,68 @@ +using Acme.Domain; +using Acme.Persistence; +using Acme.Persistence.ValueGeneration; +using Microsoft.EntityFrameworkCore; +using DomainInstallation = Acme.Domain.Installation; + +namespace Acme.Installation; + +/// What a bootstrap run did, so the caller can prove the second run is a no-op. +public sealed record BootstrapResult( + InstallationId InstallationId, + Guid SystemTenantId, + Guid SystemPrincipalId, + bool Created +); + +/// Read-or-insert bootstrap for one installation and its well-known identities. +public sealed class InstallationBootstrap(AcmeDbContext dbContext) + where TProvider : ISequentialGuidProvider +{ + public async Task RunAsync(Guid installationId, CancellationToken cancellationToken) + { + var configured = new InstallationId(installationId); + var persisted = await dbContext + .Installations.OrderBy(installation => installation.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + // The persisted identity is authoritative; a configured one that disagrees is a + // deployment error, not a reason to rewrite the row. + if (persisted is not null && persisted.Id != configured) + { + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match " + + $"the persisted id '{persisted.Id.Value}'." + ); + } + + var created = persisted is null; + var installation = persisted ?? new DomainInstallation(configured, DateTime.UtcNow); + if (created) + dbContext.Installations.Add(installation); + + var wellKnown = new WellKnownIdentifiers(installation.Id); + if ( + !await dbContext + .Tenants.AnyAsync(tenant => tenant.Id == wellKnown.SystemTenantId, cancellationToken) + .ConfigureAwait(false) + ) + { + dbContext.Tenants.Add(new Tenant(wellKnown.SystemTenantId, "System")); + } + + if ( + !await dbContext + .ServicePrincipals.AnyAsync(principal => principal.Id == wellKnown.SystemPrincipalId, cancellationToken) + .ConfigureAwait(false) + ) + { + dbContext.ServicePrincipals.Add(new ServicePrincipal(wellKnown.SystemPrincipalId, "System")); + } + + // One commit: either the installation and its system identities exist, or none do. + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return new BootstrapResult(installation.Id, wellKnown.SystemTenantId, wellKnown.SystemPrincipalId, created); + } +} diff --git a/samples/Acme.Persistence/Program.cs b/samples/Acme.Persistence/Program.cs new file mode 100644 index 0000000..6d930de --- /dev/null +++ b/samples/Acme.Persistence/Program.cs @@ -0,0 +1,194 @@ +using Acme.Domain; +using Acme.Installation; +using Acme.Persistence; +using Acme.Persistence.ValueGeneration; +using Acme.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +var options = ParseArguments(args); + +if (options.ShowHelp) +{ + PrintHelp(); + return 0; +} + +if (options.PrintConstants) +{ + PrintConstants(options.InstallationId); + return 0; +} + +IdentifierVerification.Run(); +Console.WriteLine("identifier checks passed"); + +var services = new ServiceCollection(); +var exitCode = options.Provider switch +{ + "sqlserver" => await RunAsync(services, options), + "postgresql" => await RunAsync(services, options), + "uuidv7" => await RunAsync(services, options), + _ => throw new ArgumentException($"Unknown provider '{options.Provider}'."), +}; + +return exitCode; + +static async Task RunAsync(IServiceCollection services, SampleOptions options) + where TProvider : ISequentialGuidProvider +{ + services.AddAcmePersistence(options.ConnectionString); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService>(); + var bootstrap = scope.ServiceProvider.GetRequiredService>(); + + var first = await bootstrap.RunAsync(options.InstallationId, CancellationToken.None); + var firstSnapshot = await Snapshot(dbContext); + var second = await bootstrap.RunAsync(options.InstallationId, CancellationToken.None); + var secondSnapshot = await Snapshot(dbContext); + + Require(first.Created, "the first bootstrap should create the installation"); + Require(!second.Created, "the second bootstrap should be a no-op"); + Require(firstSnapshot == secondSnapshot, "the second bootstrap must not change any row"); + Require(first.SystemTenantId == second.SystemTenantId, "the tenant id must be stable"); + + // Save the principal first: the foreign key must copy the generated customer key, not a value + // that was empty when the order was constructed. + var customer = new Customer { Name = "Acme" }; + dbContext.Customers.Add(customer); + await dbContext.SaveChangesAsync(); + + var order = new Order { CustomerId = customer.Id, Reference = "A-1000" }; + dbContext.Orders.Add(order); + + var settingId = Guid.Parse("22222222-2222-4222-8222-222222222222"); + var setting = new Setting { Id = settingId, Name = "explicit" }; + dbContext.Settings.Add(setting); + await dbContext.SaveChangesAsync(); + + Require(order.Id.Value != Guid.Empty, "the order key was not generated"); + Require(order.CustomerId == customer.Id, "the order's foreign key was not copied from the generated customer key"); + Require(order.ExternalId is not null, "the nullable indexed GUID was not generated"); + Require(setting.Id == settingId, "the explicitly configured setting key was overwritten"); + + var factory = scope.ServiceProvider.GetRequiredService(); + var factoryValue = factory.NewGuid(); + var timestamp = factory.GetTimestamp(factoryValue); + Require(Math.Abs((timestamp - DateTime.UtcNow).TotalMinutes) < 5, "the timestamp is not recent"); + + Console.WriteLine($"provider {TProvider.Name}"); + Console.WriteLine($"installation {options.InstallationId:D}"); + Console.WriteLine($"system tenant {first.SystemTenantId:D}"); + Console.WriteLine($"system principal {first.SystemPrincipalId:D}"); + Console.WriteLine("bootstrap first run created; second run unchanged"); + Console.WriteLine($"customer key {customer.Id:D}"); + Console.WriteLine($"order key {order.Id.Value:D}"); + Console.WriteLine($"order customer key {order.CustomerId:D}"); + Console.WriteLine($"nullable indexed {order.ExternalId!.Value:D}"); + Console.WriteLine($"explicit setting {setting.Id:D}"); + Console.WriteLine($"factory value {factoryValue:D}"); + Console.WriteLine($"factory timestamp {timestamp:O}"); + Console.WriteLine("all checks passed"); + + return 0; +} + +static async Task Snapshot(AcmeDbContext dbContext) + where TProvider : ISequentialGuidProvider => + new( + await dbContext.Installations.CountAsync(), + await dbContext.Tenants.CountAsync(), + await dbContext.ServicePrincipals.CountAsync(), + await dbContext.Tenants.Select(tenant => tenant.Id).SingleAsync(), + await dbContext.ServicePrincipals.Select(principal => principal.Id).SingleAsync() + ); + +static void PrintConstants(Guid installationId) +{ + var wellKnown = new WellKnownIdentifiers(new InstallationId(installationId)); + Console.WriteLine($"installation {installationId:D}"); + Console.WriteLine($"system tenant {wellKnown.SystemTenantId:D}"); + Console.WriteLine($"system principal {wellKnown.SystemPrincipalId:D}"); + Console.WriteLine($"role:admin {wellKnown.ReferenceRow("role", "admin"):D}"); +} + +static SampleOptions ParseArguments(string[] args) +{ + var provider = "sqlserver"; + var installationId = Guid.Parse("11111111-1111-4111-8111-111111111111"); + string? connectionString = null; + var printConstants = false; + var showHelp = false; + + for (var index = 0; index < args.Length; index++) + { + switch (args[index]) + { + case "--provider": + provider = RequireValue(args, ref index, "--provider"); + break; + case "--installation": + installationId = Guid.Parse(RequireValue(args, ref index, "--installation")); + break; + case "--connection": + connectionString = RequireValue(args, ref index, "--connection"); + break; + case "--print-constants": + printConstants = true; + break; + case "--help" or "-h": + showHelp = true; + break; + default: + throw new ArgumentException($"Unknown argument '{args[index]}'. Use --help."); + } + } + + if (provider is not ("sqlserver" or "postgresql" or "uuidv7")) + { + throw new ArgumentException($"Unknown provider '{provider}'. Choose sqlserver, postgresql or uuidv7."); + } + + return new SampleOptions(provider, installationId, connectionString, printConstants, showHelp); +} + +static string RequireValue(string[] args, ref int index, string name) +{ + if (index + 1 >= args.Length) + throw new ArgumentException($"'{name}' needs a value."); + + index++; + return args[index]; +} + +static void Require(bool condition, string message) +{ + if (!condition) + throw new InvalidOperationException(message); +} + +static void PrintHelp() => + Console.WriteLine( + """ + Usage: dotnet run --project samples/Acme.Persistence -- [options] + + --provider Strategy to demonstrate (default: sqlserver) + --installation Namespace for well-known identities + --connection Use a real SQL Server or PostgreSQL store + --print-constants Print derived identifiers for a migration + --help Show this text + """ + ); + +sealed record SampleOptions( + string Provider, + Guid InstallationId, + string? ConnectionString, + bool PrintConstants, + bool ShowHelp +); + +sealed record Snapshot(int Installations, int Tenants, int Principals, Guid TenantId, Guid PrincipalId); diff --git a/samples/Acme.Persistence/ValueGeneration/ISequentialGuidProvider.cs b/samples/Acme.Persistence/ValueGeneration/ISequentialGuidProvider.cs new file mode 100644 index 0000000..c355026 --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/ISequentialGuidProvider.cs @@ -0,0 +1,17 @@ +namespace Acme.Persistence.ValueGeneration; + +/// A store-specific sequential GUID strategy. +public interface ISequentialGuidProvider +{ + static abstract string Name { get; } + + static abstract Guid NewGuid(); + + static abstract Guid NewGuid(DateTime timestampUtc); + + static abstract DateTime GetTimestamp(Guid value); + + static abstract Guid MinValueFor(DateTime timestampUtc); + + static abstract Guid MaxValueFor(DateTime timestampUtc); +} diff --git a/samples/Acme.Persistence/ValueGeneration/SequentialGuidConvention.cs b/samples/Acme.Persistence/ValueGeneration/SequentialGuidConvention.cs new file mode 100644 index 0000000..22220fc --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/SequentialGuidConvention.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +namespace Acme.Persistence.ValueGeneration; + +/// Applies the strategy to every GUID key at convention precedence. +public sealed class SequentialGuidConvention(Func? alsoGenerate = null) + : IModelFinalizingConvention + where TProvider : ISequentialGuidProvider +{ + static readonly Type FactoryType = typeof(SequentialGuidValueGeneratorFactory); + + public void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context + ) + { + GuardFactory(); + + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (!ShouldGenerate(property)) + continue; + + var builder = + property.Builder + ?? throw new InvalidOperationException( + $"'{property.Name}' is read-only during model finalisation." + ); + builder.ValueGenerated(ValueGenerated.OnAdd, fromDataAnnotation: false); + builder.HasValueGeneratorFactory(FactoryType, fromDataAnnotation: false); + } + } + } + + bool ShouldGenerate(IReadOnlyProperty property) + { + if (!IsGuid(property.ClrType)) + return false; + + // A foreign key takes its value from the principal, and a default or computed column is + // owned by the store. Explicit configuration wins because this writes at convention + // precedence, the lowest. + if ( + property.IsForeignKey() + || property.GetDefaultValueSql() is not null + || property.GetComputedColumnSql() is not null + ) + { + return false; + } + + return property.IsKey() || (alsoGenerate?.Invoke(property) ?? false); + } + + static bool IsGuid(Type clrType) => + clrType == typeof(Guid) || clrType == typeof(Guid?) || typeof(IComparable).IsAssignableFrom(clrType); + + static void GuardFactory() + { + if ( + !typeof(ValueGeneratorFactory).IsAssignableFrom(FactoryType) + || FactoryType.GetConstructor(Type.EmptyTypes) is null + ) + { + throw new InvalidOperationException( + $"'{FactoryType}' must derive from ValueGeneratorFactory and have a parameterless constructor." + ); + } + } +} diff --git a/samples/Acme.Persistence/ValueGeneration/SequentialGuidDbContextOptionsBuilderExtensions.cs b/samples/Acme.Persistence/ValueGeneration/SequentialGuidDbContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..7cca568 --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/SequentialGuidDbContextOptionsBuilderExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Acme.Persistence.ValueGeneration; + +/// Carries the provider's strategy on the context options, once. +public static class SequentialGuidDbContextOptionsBuilderExtensions +{ + public static DbContextOptionsBuilder UseSequentialGuids(this DbContextOptionsBuilder builder) + where TProvider : ISequentialGuidProvider + { + ArgumentNullException.ThrowIfNull(builder); + + var extension = new SequentialGuidOptionsExtension( + TProvider.Name, + typeof(SequentialGuidValueGenerator), + typeof(SequentialGuidFactory) + ); + + ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(extension); + + return builder; + } +} diff --git a/samples/Acme.Persistence/ValueGeneration/SequentialGuidFactory.cs b/samples/Acme.Persistence/ValueGeneration/SequentialGuidFactory.cs new file mode 100644 index 0000000..c7bd2a9 --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/SequentialGuidFactory.cs @@ -0,0 +1,30 @@ +namespace Acme.Persistence.ValueGeneration; + +/// The application-facing view of the configured strategy. +public interface ISequentialGuidFactory +{ + Guid NewGuid(); + + Guid NewGuid(DateTime timestampUtc); + + DateTime GetTimestamp(Guid value); + + Guid MinValueFor(DateTime timestampUtc); + + Guid MaxValueFor(DateTime timestampUtc); +} + +/// Forwards to the strategy so application code never resolves the wrong instance. +public sealed class SequentialGuidFactory : ISequentialGuidFactory + where TProvider : ISequentialGuidProvider +{ + public Guid NewGuid() => TProvider.NewGuid(); + + public Guid NewGuid(DateTime timestampUtc) => TProvider.NewGuid(timestampUtc); + + public DateTime GetTimestamp(Guid value) => TProvider.GetTimestamp(value); + + public Guid MinValueFor(DateTime timestampUtc) => TProvider.MinValueFor(timestampUtc); + + public Guid MaxValueFor(DateTime timestampUtc) => TProvider.MaxValueFor(timestampUtc); +} diff --git a/samples/Acme.Persistence/ValueGeneration/SequentialGuidOptionsExtension.cs b/samples/Acme.Persistence/ValueGeneration/SequentialGuidOptionsExtension.cs new file mode 100644 index 0000000..e91c68a --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/SequentialGuidOptionsExtension.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace Acme.Persistence.ValueGeneration; + +/// The strategy choice, carried as data on the context options. +public sealed class SequentialGuidOptionsExtension(string strategyName, Type valueGeneratorType, Type factoryType) + : IDbContextOptionsExtension +{ + public string StrategyName { get; } = strategyName; + + public Type ValueGeneratorType { get; } = valueGeneratorType; + + public Type FactoryType { get; } = factoryType; + + public DbContextOptionsExtensionInfo Info => field ??= new ExtensionInfo(this); + + // Registered separately from EF's internal generator so application code can mint the same + // values for outbox rows, event envelopes and correlation ids. + public void ApplyServices(IServiceCollection services) => + services.AddSingleton(typeof(ISequentialGuidFactory), FactoryType); + + public void Validate(IDbContextOptions options) + { + // The strategy participates in EF's service-provider cache key. An external internal + // provider would bypass that key, so the first provider to build the model would win. + if (options.FindExtension()?.InternalServiceProvider is not null) + { + throw new InvalidOperationException( + "Sequential GUID generation cannot be combined with UseInternalServiceProvider." + ); + } + } + + sealed class ExtensionInfo(SequentialGuidOptionsExtension extension) : DbContextOptionsExtensionInfo(extension) + { + // DbContextOptionsExtensionInfo.Extension is typed as the interface, so this narrows it. + public new SequentialGuidOptionsExtension Extension => (SequentialGuidOptionsExtension)base.Extension; + + public override bool IsDatabaseProvider => false; + + public override string LogFragment => $"SequentialGuids={Extension.StrategyName} "; + + public override int GetServiceProviderHashCode() => + HashCode.Combine(Extension.ValueGeneratorType, Extension.FactoryType); + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => + other is ExtensionInfo otherInfo + && otherInfo.Extension.ValueGeneratorType == Extension.ValueGeneratorType + && otherInfo.Extension.FactoryType == Extension.FactoryType; + + public override void PopulateDebugInfo(IDictionary debugInfo) + { + debugInfo["SequentialGuid:Strategy"] = Extension.StrategyName; + debugInfo["SequentialGuid:ValueGenerator"] = + Extension.ValueGeneratorType.AssemblyQualifiedName ?? string.Empty; + } + } +} diff --git a/samples/Acme.Persistence/ValueGeneration/SequentialGuidProviders.cs b/samples/Acme.Persistence/ValueGeneration/SequentialGuidProviders.cs new file mode 100644 index 0000000..e40f2ea --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/SequentialGuidProviders.cs @@ -0,0 +1,94 @@ +using SecurityDriven; + +namespace Acme.Persistence.ValueGeneration; + +/// Values ascend in SQL Server's uniqueidentifier comparison order. +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} + +/// Values ascend in PostgreSQL's big-endian uuid comparison order. +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} + +/// RFC 9562 UUIDv7. Genuinely sequential on PostgreSQL, not on SQL Server. +public sealed class UuidV7SequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "UuidV7"; + + public static Guid NewGuid() => Guid.CreateVersion7(); + + public static Guid NewGuid(DateTime timestampUtc) => + Guid.CreateVersion7(new DateTimeOffset(UtcTimestamp.EnsureUtc(timestampUtc))); + + public static DateTime GetTimestamp(Guid value) => FastGuid.UUID.ExtractDateTimeV7(value); + + public static Guid MinValueFor(DateTime timestampUtc) => ForTimestamp(timestampUtc, minimum: true); + + public static Guid MaxValueFor(DateTime timestampUtc) => ForTimestamp(timestampUtc, minimum: false); + + static Guid ForTimestamp(DateTime timestampUtc, bool minimum) + { + var milliseconds = new DateTimeOffset(UtcTimestamp.EnsureUtc(timestampUtc)).ToUnixTimeMilliseconds(); + Span bytes = stackalloc byte[16]; + bytes[0] = (byte)(milliseconds >> 40); + bytes[1] = (byte)(milliseconds >> 32); + bytes[2] = (byte)(milliseconds >> 24); + bytes[3] = (byte)(milliseconds >> 16); + bytes[4] = (byte)(milliseconds >> 8); + bytes[5] = (byte)milliseconds; + bytes[6] = 0x70; // version 7 + bytes[8] = 0x80; // RFC 9562 variant + + if (!minimum) + { + bytes[6] |= 0x0F; + bytes[7] = 0xFF; + bytes[8] |= 0x3F; + bytes[9..].Fill(0xFF); + } + + return new Guid(bytes, bigEndian: true); + } +} + +/// Rejects an ambiguous timestamp before it becomes a silent ordering bug. +static class UtcTimestamp +{ + public static DateTime EnsureUtc(DateTime timestampUtc) => + timestampUtc.Kind == DateTimeKind.Unspecified + ? throw new ArgumentException( + "A sequential GUID timestamp must be UTC; Unspecified cannot be ordered reliably.", + nameof(timestampUtc) + ) + : timestampUtc.ToUniversalTime(); +} diff --git a/samples/Acme.Persistence/ValueGeneration/SequentialGuidValueGenerator.cs b/samples/Acme.Persistence/ValueGeneration/SequentialGuidValueGenerator.cs new file mode 100644 index 0000000..470de22 --- /dev/null +++ b/samples/Acme.Persistence/ValueGeneration/SequentialGuidValueGenerator.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +namespace Acme.Persistence.ValueGeneration; + +/// Creates the generator and hands it the property's value converter. +public sealed class SequentialGuidValueGeneratorFactory : ValueGeneratorFactory + where TProvider : ISequentialGuidProvider +{ + public override ValueGenerator Create(IProperty property, ITypeBase typeBase) + { + ValueGenerator generator = new SequentialGuidValueGenerator(); + var converter = property.GetValueConverter(); + return converter is null ? generator : generator.WithConverter(converter); + } +} + +/// Generates the strategy's values from the application, not from the store. +public sealed class SequentialGuidValueGenerator : ValueGenerator + where TProvider : ISequentialGuidProvider +{ + // A temporary value would be written and then rewritten after the insert. + public override bool GeneratesTemporaryValues => false; + + public override Guid Next(EntityEntry entry) => TProvider.NewGuid(); +} diff --git a/samples/Acme.Persistence/Verification/IdentifierVerification.cs b/samples/Acme.Persistence/Verification/IdentifierVerification.cs new file mode 100644 index 0000000..3a33cde --- /dev/null +++ b/samples/Acme.Persistence/Verification/IdentifierVerification.cs @@ -0,0 +1,139 @@ +using System.Data.SqlTypes; +using Acme.Persistence.ValueGeneration; + +namespace Acme.Verification; + +/// +/// The checks the wiki publishes as the enforcement table: RFC vectors, store-order monotonicity, +/// the UUIDv7 trap, timestamp round-trips and range bounds. +/// +public static class IdentifierVerification +{ + public static void Run() + { + VerifyUuid5(); + VerifyMonotonic(CompareSqlServer, "sql server"); + VerifyMonotonic(CompareBigEndian, "postgresql"); + VerifyUuidV7(); + VerifyTimestampAndRange(CompareSqlServer, "sql server"); + VerifyTimestampAndRange(CompareBigEndian, "postgresql"); + VerifyTimestampAndRange(CompareBigEndian, "uuidv7"); + } + + static void VerifyUuid5() + { + Check( + Uuid5.Create(Uuid5.NamespaceDns, "example.com") == Guid.Parse("cfbff0d1-9375-5685-968c-48ce8b15ae17"), + "uuid5 DNS vector failed" + ); + Check( + Uuid5.Create(Uuid5.NamespaceUrl, "www.example.com") == Guid.Parse("b63cdfa4-3df9-568e-97ae-006c5b8fd652"), + "uuid5 URL vector failed" + ); + Check( + Uuid5.Create(Uuid5.NamespaceOid, "1.2.3.4") == Guid.Parse("67448b45-6d15-536b-bef7-8a78c3d10ac6"), + "uuid5 OID vector failed" + ); + Check( + Uuid5.Create(Uuid5.NamespaceX500, "CN=Bob") == Guid.Parse("0840e90d-9ca0-5f14-970e-26294d44cb1d"), + "uuid5 X500 vector failed" + ); + Check( + Uuid5.Create(Uuid5.NamespaceDns, string.Empty) == Guid.Parse("4ebd0208-8328-5d69-8c44-ec50939c0967"), + "uuid5 empty-name vector failed" + ); + + var value = Uuid5.Create(Uuid5.NamespaceDns, "example.com"); + Span bytes = stackalloc byte[16]; + value.TryWriteBytes(bytes, bigEndian: true, out _); + Check(value == Uuid5.Create(Uuid5.NamespaceDns, "example.com"), "uuid5 is not deterministic"); + Check(value != Uuid5.Create(Uuid5.NamespaceDns, "example.org"), "uuid5 ignores the name"); + Check((bytes[6] & 0xF0) == 0x50, "uuid5 version bits are wrong"); + Check((bytes[8] & 0xC0) == 0x80, "uuid5 variant bits are wrong"); + + var wellKnown = new Installation.WellKnownIdentifiers( + new Domain.InstallationId(Guid.Parse("11111111-1111-4111-8111-111111111111")) + ); + Check(wellKnown.SystemTenantId != wellKnown.SystemPrincipalId, "catalogue ids collide"); + } + + static void VerifyMonotonic(Func compare, string name) + where TProvider : ISequentialGuidProvider + { + var start = DateTime.UtcNow.AddMinutes(-1); + var values = new Guid[10_000]; + for (var index = 0; index < values.Length; index++) + { + values[index] = TProvider.NewGuid(start.AddTicks(index * TimeSpan.TicksPerMillisecond)); + } + + Check(Inversions(values, compare) == 0, $"{name} provider is not monotonic in store order"); + } + + static void VerifyUuidV7() + { + var start = DateTime.UtcNow.AddMinutes(-1); + var values = new Guid[10_000]; + for (var index = 0; index < values.Length; index++) + values[index] = UuidV7SequentialGuidProvider.NewGuid(start.AddMilliseconds(index)); + + Check(Inversions(values, CompareSqlServer) > 1_000, "uuidv7 is unexpectedly monotonic on SQL Server"); + Check(Inversions(values, CompareBigEndian) == 0, "uuidv7 is not monotonic on PostgreSQL"); + } + + static void VerifyTimestampAndRange(Func compare, string name) + where TProvider : ISequentialGuidProvider + { + var requested = DateTime.UtcNow.AddMinutes(-1).AddTicks(12_345); + var value = TProvider.NewGuid(requested); + var actual = TProvider.GetTimestamp(value); + + Check( + Math.Abs((actual - requested).Ticks) <= TimeSpan.TicksPerMillisecond, + $"{name} timestamp round-trip exceeded 1 ms" + ); + Check(compare(TProvider.MinValueFor(requested), value) <= 0, $"{name} lower bound failed"); + Check(compare(value, TProvider.MaxValueFor(requested)) <= 0, $"{name} upper bound failed"); + + var threw = false; + try + { + TProvider.NewGuid(DateTime.SpecifyKind(requested, DateTimeKind.Unspecified)); + } + catch (ArgumentException) + { + threw = true; + } + + Check(threw, $"{name} accepted an unspecified timestamp"); + } + + static int CompareSqlServer(Guid left, Guid right) => new SqlGuid(left).CompareTo(new SqlGuid(right)); + + static int CompareBigEndian(Guid left, Guid right) + { + Span leftBytes = stackalloc byte[16]; + Span rightBytes = stackalloc byte[16]; + left.TryWriteBytes(leftBytes, bigEndian: true, out _); + right.TryWriteBytes(rightBytes, bigEndian: true, out _); + return leftBytes.SequenceCompareTo(rightBytes); + } + + static int Inversions(Guid[] values, Func compare) + { + var inversions = 0; + for (var index = 1; index < values.Length; index++) + { + if (compare(values[index - 1], values[index]) > 0) + inversions++; + } + + return inversions; + } + + static void Check(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} diff --git a/samples/Directory.Packages.props b/samples/Directory.Packages.props new file mode 100644 index 0000000..1e0b2aa --- /dev/null +++ b/samples/Directory.Packages.props @@ -0,0 +1,31 @@ + + + + true + + + + + + + + + + + + + + + + + + diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..c7219bf --- /dev/null +++ b/samples/README.md @@ -0,0 +1,161 @@ +# Acme persistence sample + +This is the complete, runnable implementation of the persistence conventions in the Engineering +Playbook: + +1. **Well-known identifiers** — UUIDv5 over an installation-scoped namespace, for identities whose value + is known before storage. +2. **Sequential GUID keys** — a provider-specific strategy applied to every GUID key by a model-finalising + convention, for everything else. +3. **EF Core integration** — the context, provider choice, options extension, model cache key and + composition that carry those two decisions. + +The names and identifiers are the generic ones the playbook uses: `Acme`, `Installation`, `Customer`, +`Order`. Copy a file, replace the namespace, and the shape still holds. + +The sample is deliberately outside `src/`. The reference implementation under `src/` is in-memory by +design and does not take a database dependency; this sample is where EF Core, the providers and the +complete wiring live. It is built and run by `just samples`, not by the canonical `just build`. + +## Run it + +```text +just samples +just samples --provider postgresql +just samples --provider uuidv7 +just samples --print-constants +``` + +Without `just`: + +```text +dotnet run --project samples/Acme.Persistence -- --provider sqlserver +``` + +The default provider is `sqlserver`, and with no `--connection` it runs on EF Core's in-memory store with +the SQL Server strategy. The store is in-memory; the strategy is still the real one: the generated values +have the layout the named database sorts by. Pass `--connection` to exercise the real provider. + +Against a real store, pass a connection string: + +```text +dotnet run --project samples/Acme.Persistence -- \ + --provider sqlserver \ + --connection "Server=(localdb)\mssqllocaldb;Database=AcmeSample;Trusted_Connection=True" + +dotnet run --project samples/Acme.Persistence -- \ + --provider postgresql \ + --connection "Host=localhost;Database=acme_sample;Username=postgres;Password=postgres" +``` + +`uuidv7` is the portable fallback. It is genuinely sequential on PostgreSQL and not sequential on SQL +Server; the sample therefore refuses a connection string for it and keeps it on the in-memory store. + +## What the run proves + +The console does more than print values; it fails with a non-zero exit code if any check is untrue. + +| Check | What it demonstrates | +| -- | -- | +| Identifier checks | RFC 4122 vectors, store-order monotonicity for all three strategies, the UUIDv7 trap, timestamp round-trips and range bounds. | +| Bootstrap twice | The second run creates no installation, tenant or principal. | +| Stable derived ids | The tenant and principal ids are identical on both runs. | +| Generated keys | A customer and an order receive keys from the configured strategy. | +| Value-object key | The order's `OrderId` receives an `OrderId`, not a raw `Guid`. | +| Foreign key excluded | The order's `CustomerId` is the saved customer's generated key, not a value generated for the foreign key. | +| Explicit configuration | A `Setting` with a supplied key stays `ValueGeneratedNever`, and the key survives `SaveChanges`. | +| Indexed non-key column | The nullable indexed GUID is generated by the widened predicate. | +| Factory round-trip | The application-facing factory returns a value and its timestamp. | + +## The catalogue and its ids + +The default installation id is fixed so the derived values are reproducible. Change it with +`--installation ` and every derived value changes, which is the isolation boundary working. + +| Identity | Namespace | Name | Derived value | +| -- | -- | -- | -- | +| System tenant | `11111111-1111-4111-8111-111111111111` | `system-tenant` | `598f4dd5-4881-5833-8615-2915ab6f7ac9` | +| System principal | `11111111-1111-4111-8111-111111111111` | `system-service-principal` | `62331408-8c66-5cd0-82e1-d8fc91642fb4` | +| Seeded role | `11111111-1111-4111-8111-111111111111` | `role:admin` | `6809511c-6d6d-5d62-a381-3a631c4f7ec3` | + +`--print-constants` prints exactly these values, ready to paste into a migration's `HasData` or +`InsertData` call. The migration cannot call `Uuid5.Create` itself; the test that guards the literal is +on the *Well-Known Identifiers* page. + +## Migration constants + +`--print-constants` is the helper for `HasData` and migration SQL, which cannot call `Uuid5.Create` +themselves. Paste the printed value into the migration: + +```csharp +migrationBuilder.InsertData( + table: "Tenants", + columns: ["Id", "Name"], + values: [Guid.Parse("598f4dd5-4881-5833-8615-2915ab6f7ac9"), "System"]); +``` + +And assert the literal in a test, so a namespace or name change cannot silently diverge: + +```csharp +[Test] +public async Task SeededTenant_GivenTheMigrationConstant_MatchesTheDerivation() +{ + var namespaceId = Guid.Parse("11111111-1111-4111-8111-111111111111"); + var seeded = Guid.Parse("598f4dd5-4881-5833-8615-2915ab6f7ac9"); + + await Assert.That(seeded).IsEqualTo(Uuid5.Create(namespaceId, "system-tenant")); +} +``` + +The sample has no migrations: the constants and the guard are documented here and on the *Well-Known +Identifiers* page, and the executable prints them, but applying them to a database is the adopting +repository's step. + +## Where each convention lives + +Paths in this table are relative to `samples/Acme.Persistence`. + +| Convention | Files | +| -- | -- | +| UUIDv5 helper | `Identifiers/Uuid5.cs` | +| The catalogue | `Identifiers/WellKnownIdentifiers.cs` | +| Idempotent bootstrap | `Persistence/InstallationBootstrap.cs` | +| Strategy witness | `ValueGeneration/ISequentialGuidProvider.cs` | +| SQL Server, PostgreSQL and UUIDv7 providers | `ValueGeneration/SequentialGuidProviders.cs` | +| Options extension | `ValueGeneration/SequentialGuidOptionsExtension.cs` | +| Generator and converter factory | `ValueGeneration/SequentialGuidValueGenerator.cs` | +| Model-finalising convention | `ValueGeneration/SequentialGuidConvention.cs` | +| Application-facing factory | `ValueGeneration/SequentialGuidFactory.cs` | +| Options-builder wiring | `ValueGeneration/SequentialGuidDbContextOptionsBuilderExtensions.cs` | +| Model registration | `Persistence/AcmeModelConventions.cs` | +| Model cache key, customizer and composition | `Persistence/AcmeModel*.cs`, `Persistence/AcmeServiceCollectionExtensions.cs` | +| Model and entities | `Persistence/AcmeDbContext.cs`, `Domain/Entities.cs` | +| Executable identifier checks | `Verification/IdentifierVerification.cs` | + +## Copying it into a repository + +1. Copy `Identifiers/Uuid5.cs` and `Identifiers/WellKnownIdentifiers.cs`. +2. Copy `Persistence/InstallationBootstrap.cs` and keep the read-or-insert sequence and the mismatch + guard. +3. Copy `ValueGeneration/` and change the namespace. +4. Register the strategy where the provider is known: + `options.UseSequentialGuids()`. +5. Keep `ValueGeneratedNever()` on every derived key. The convention deliberately leaves it alone. +6. Copy the test shapes from the *Well-Known Identifiers*, *GUID Key Strategy* and *EF Core Integration* + pages. `Verification/IdentifierVerification.cs` is their executable version: vectors, monotonicity, + timestamp round-trip and range bounds; the model and foreign-key checks are in `Program.cs`. +7. In a single-provider application, make `AcmeDbContext` non-generic and hard-code the provider in + `ConfigureConventions`, as the *GUID Key Strategy* page shows. The sample closes the context over + the strategy so one executable can demonstrate all three. + +## Notes + +- `just pipeline-pr` builds, lints and tests `src/` only. Run `just samples` when you change the sample; + that is its verification. +- The project has no analyzers because it sits outside `src/` and does not import the Purview SDK. + CSharpier still formats it through `just lint-check`. +- `System.Security.Cryptography.Pkcs` is pinned to a patched 10.x version because the SQL Server + provider's SQL client resolves a version with two published advisories. Remove the pin when the client + ships a fixed dependency. +- The sample does not contain migrations. The derived constants and the migration guard are documented, + not exercised against a database, so the sample can run anywhere. diff --git a/skills/purview-engineering-principles/SKILL.md b/skills/purview-engineering-principles/SKILL.md index ba4dd9b..d193187 100644 --- a/skills/purview-engineering-principles/SKILL.md +++ b/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,8 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, and the conditional persistence rules. Each rule names the + failure it prevents and whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -164,6 +164,13 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the ten because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a diff --git a/skills/purview-persistence/SKILL.md b/skills/purview-persistence/SKILL.md index fabf957..7d6ad91 100644 --- a/skills/purview-persistence/SKILL.md +++ b/skills/purview-persistence/SKILL.md @@ -1,14 +1,14 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" repository: purview-dev/engineering-playbook --- @@ -41,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -73,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -86,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -97,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/skills/purview-persistence/references/ef-core-integration.md b/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 0000000..77c7573 --- /dev/null +++ b/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/skills/purview-persistence/references/guid-generation.md b/skills/purview-persistence/references/guid-generation.md index 432053a..fb644bb 100644 --- a/skills/purview-persistence/references/guid-generation.md +++ b/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/skills/purview-persistence/references/postgresql.md b/skills/purview-persistence/references/postgresql.md index cd557ec..603987d 100644 --- a/skills/purview-persistence/references/postgresql.md +++ b/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/skills/purview-persistence/references/sqlserver.md b/skills/purview-persistence/references/sqlserver.md index c728295..2262972 100644 --- a/skills/purview-persistence/references/sqlserver.md +++ b/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/skills/purview-persistence/references/well-known-identifiers.md b/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 0000000..372d585 --- /dev/null +++ b/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 4fc3215..e87b4ed 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -87,6 +87,29 @@ This repository contains `Acme.`, ` or a unit-of-work wrapper for testability; + `DbContext` already is both, and `Purview.Containers` gives you a real database in tests. +- **One owner per identifier.** A value known before storage is derived with UUIDv5 over an + installation-scoped namespace and keyed `ValueGeneratedNever()`; every other GUID key uses one + strategy per store, applied by a model-finalising convention. SQL Server and PostgreSQL order GUIDs + differently. See [Well-Known + Identifiers](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Well-Known-Identifiers.md) + and [GUID Key + Strategy](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Guid-Key-Strategy.md). +- **Map a value object once.** Configure the converter and comparer in the model, not per property. A + value-object key also needs a converter-aware value generator, or it receives a raw `Guid`. +- **Design time matches runtime.** The design-time factory or host registration must make the same + provider, strategy and `ReplaceService` choices as the application. +- **Migrations are code.** Persistence owns the definitions and a separate step applies them; do not + migrate at API startup in production. +- **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes or computed columns. See [EF Core + Integration](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/EF-Core-Integration.md). + ## Local commands ```text