ddd-ts-kit 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +1294 -0
  2. package/README.fr.md +11 -6
  3. package/README.md +9 -5
  4. package/dist/check/authz/is-granted.d.ts +8 -0
  5. package/dist/check/authz/is-granted.d.ts.map +1 -0
  6. package/dist/check/authz/is-granted.js +11 -0
  7. package/dist/check/authz/is-granted.js.map +1 -0
  8. package/dist/check/authz/permission-sets.d.ts +13 -0
  9. package/dist/check/authz/permission-sets.d.ts.map +1 -0
  10. package/dist/check/authz/permission-sets.js +19 -0
  11. package/dist/check/authz/permission-sets.js.map +1 -0
  12. package/dist/check/catalogue/architecture-rules.d.ts.map +1 -1
  13. package/dist/check/catalogue/architecture-rules.js +4 -0
  14. package/dist/check/catalogue/architecture-rules.js.map +1 -1
  15. package/dist/check/catalogue/default-severities.d.ts.map +1 -1
  16. package/dist/check/catalogue/default-severities.js +1 -0
  17. package/dist/check/catalogue/default-severities.js.map +1 -1
  18. package/dist/check/explain/authz-docs.d.ts.map +1 -1
  19. package/dist/check/explain/authz-docs.js +15 -0
  20. package/dist/check/explain/authz-docs.js.map +1 -1
  21. package/dist/check/explain/events-docs.d.ts.map +1 -1
  22. package/dist/check/explain/events-docs.js +13 -2
  23. package/dist/check/explain/events-docs.js.map +1 -1
  24. package/dist/check/fixtures/authz-granted-fixtures.d.ts +3 -0
  25. package/dist/check/fixtures/authz-granted-fixtures.d.ts.map +1 -0
  26. package/dist/check/fixtures/authz-granted-fixtures.js +71 -0
  27. package/dist/check/fixtures/authz-granted-fixtures.js.map +1 -0
  28. package/dist/check/fixtures/event-sourcing-fixtures.js +3 -3
  29. package/dist/check/fixtures/event-sourcing-fixtures.js.map +1 -1
  30. package/dist/check/fixtures/events-fixtures.d.ts.map +1 -1
  31. package/dist/check/fixtures/events-fixtures.js +30 -1
  32. package/dist/check/fixtures/events-fixtures.js.map +1 -1
  33. package/dist/check/fixtures/fixtures.d.ts.map +1 -1
  34. package/dist/check/fixtures/fixtures.js +2 -0
  35. package/dist/check/fixtures/fixtures.js.map +1 -1
  36. package/dist/check/fixtures/language-fixtures.d.ts.map +1 -1
  37. package/dist/check/fixtures/language-fixtures.js +1 -1
  38. package/dist/check/fixtures/language-fixtures.js.map +1 -1
  39. package/dist/check/fixtures/vocabulary-fixtures.d.ts.map +1 -1
  40. package/dist/check/fixtures/vocabulary-fixtures.js +4 -0
  41. package/dist/check/fixtures/vocabulary-fixtures.js.map +1 -1
  42. package/dist/check/language/declared-vocabularies.d.ts.map +1 -1
  43. package/dist/check/language/declared-vocabularies.js +8 -28
  44. package/dist/check/language/declared-vocabularies.js.map +1 -1
  45. package/dist/check/rules/authz-granted.d.ts +11 -0
  46. package/dist/check/rules/authz-granted.d.ts.map +1 -0
  47. package/dist/check/rules/authz-granted.js +45 -0
  48. package/dist/check/rules/authz-granted.js.map +1 -0
  49. package/dist/check/rules/events-name-declared-once.d.ts +3 -0
  50. package/dist/check/rules/events-name-declared-once.d.ts.map +1 -0
  51. package/dist/check/rules/events-name-declared-once.js +51 -0
  52. package/dist/check/rules/events-name-declared-once.js.map +1 -0
  53. package/dist/check/text/depth-at.d.ts +3 -0
  54. package/dist/check/text/depth-at.d.ts.map +1 -0
  55. package/dist/check/text/depth-at.js +11 -0
  56. package/dist/check/text/depth-at.js.map +1 -0
  57. package/dist/commands/generate.js +2 -0
  58. package/dist/commands/generate.js.map +1 -1
  59. package/dist/generate/access-vars.d.ts.map +1 -1
  60. package/dist/generate/access-vars.js +5 -0
  61. package/dist/generate/access-vars.js.map +1 -1
  62. package/dist/generate/grant-permission.d.ts +9 -0
  63. package/dist/generate/grant-permission.d.ts.map +1 -0
  64. package/dist/generate/grant-permission.js +111 -0
  65. package/dist/generate/grant-permission.js.map +1 -0
  66. package/dist/generate/kind.d.ts +2 -0
  67. package/dist/generate/kind.d.ts.map +1 -1
  68. package/dist/generate/kinds/command-kind.js +1 -1
  69. package/dist/generate/kinds/command-kind.js.map +1 -1
  70. package/dist/generate/kinds/file-upload-kind.js +2 -2
  71. package/dist/generate/kinds/file-upload-kind.js.map +1 -1
  72. package/dist/generate/kinds/query-kind.js +2 -2
  73. package/dist/generate/kinds/query-kind.js.map +1 -1
  74. package/dist/generate/shared-writes.d.ts.map +1 -1
  75. package/dist/generate/shared-writes.js +10 -3
  76. package/dist/generate/shared-writes.js.map +1 -1
  77. package/dist/init/manifest/versions.js +24 -24
  78. package/dist/init/run-init.js +1 -1
  79. package/dist/init/run-init.js.map +1 -1
  80. package/dist/progress/format-progress.js +4 -0
  81. package/dist/progress/format-progress.js.map +1 -1
  82. package/dist/progress/progress.d.ts +2 -0
  83. package/dist/progress/progress.d.ts.map +1 -1
  84. package/dist/progress/read-progress.js +1 -0
  85. package/dist/progress/read-progress.js.map +1 -1
  86. package/dist/progress/read-slices.d.ts +5 -1
  87. package/dist/progress/read-slices.d.ts.map +1 -1
  88. package/dist/progress/read-slices.js +17 -7
  89. package/dist/progress/read-slices.js.map +1 -1
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/package.json +6 -4
  93. package/templates/init/rules/application.md.tpl +2 -2
  94. package/templates/init/rules/domain.md.tpl +1 -1
  95. package/templates/pipeline/package-scripts.json +1 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,1294 @@
1
+ # Changelog
2
+
3
+ What each version changes for someone who uses the kit. The packages of `packages/` are released
4
+ together, under one version; the notes of each [GitHub release](https://github.com/gbourgeat/ddd-ts-kit/releases)
5
+ list the pull requests behind it.
6
+
7
+ ## 0.20.0
8
+
9
+ **A permission no role holds is said.** In a project whose rights are roles (`stack.authz`
10
+ `rbac`), `authz/granted` (`ddd-ts-kit check`) reports each permission a context declares that no
11
+ role names in `platform/auth/roles.ts`, nor `platform/auth/signed-in-grants.ts`: the super-admin
12
+ `*` was the only one to reach it, so the checks stayed green and every other person was refused
13
+ at run time. It is a warning: `check` does not fail on it. A grant counts by the entry of the set
14
+ (`billingPermissions['invoice.issue']`), by the id (`'billing:invoice.issue'`) or by the wildcard
15
+ of the context (`'billing:*'`); a grant to `Role.System`, written by `generate job`, counts too.
16
+ The rule is silent under attributes, relationships or an authorizer of the project's own.
17
+
18
+ **`generate command`, `query` and `file-upload` take `--grant <role>`.** With `--permission`, it
19
+ writes the grant in `platform/auth/roles.ts` and adds the role to `platform/auth/role.ts` when it
20
+ is new. It is refused with `--public`, and when the rights are not roles. Without it, a `next`
21
+ step points at the roles to edit. `init --force` writes the roles from their template again, and
22
+ the warning names the grants to give back. To move, run `ddd-ts-kit check --rule authz/granted`
23
+ and grant each permission it lists to the roles that hold it, by the entry of its set, in
24
+ `roles.ts`; a permission meant for administrators only is granted to `Role.Admin` by name. A
25
+ context worker never passes `--grant` (`--local-only` refuses it): the conductor grants on the
26
+ trunk.
27
+
28
+ ## 0.19.0
29
+
30
+ **`status` and `worker-scope` read every slices table.** `docs/architecture/slices.md` may plan
31
+ the work in several tables, one per milestone or story, under the same header: only the first was
32
+ read, so `status` counted a part of the plan and a worker started on a later table got an empty
33
+ briefing. Every table is now read, in the order of the file, and `status` names a row number that
34
+ more than one row carries (`duplicates` in `--json`).
35
+
36
+ **The notes travel with the CLI, and a version gap is said.** The `ddd-ts-kit` package ships this
37
+ file, so an agent finds the steps of a version in `node_modules/ddd-ts-kit/CHANGELOG.md`, offline.
38
+ The plugin states the version of the packages its skills describe: when the installed
39
+ `ddd-ts-kit` is another one (below 1.0.0, another minor version), the session start says so and
40
+ what to do, upgrade the packages together or update the plugin. It never blocks.
41
+
42
+ **Every domain event declares its name once, consumed or not.** `events/name-declared-once`
43
+ (`ddd-ts-kit check`) reports a class of `domain/events/` without `static readonly eventName`, and
44
+ one whose `readonly name` writes the name again instead of reading it. Until now an event that no
45
+ handler or projection reacted to could keep the form 0.5 wrote, `readonly name = '..'` alone, and
46
+ the first reaction attached to it failed to compile. To move, give each reported class
47
+ `static readonly eventName = '<context>.<aggregate>.<fact>'` and `readonly name = <Event>.eventName`.
48
+
49
+ ## 0.18.0
50
+
51
+ **The tenant check of `RoleBasedAuthorizer` follows memberships and the tenant of the work.**
52
+ With `tenantOf`, an actor of several tenants (its `tenants`, from a list claim or the
53
+ `organization` claim of Keycloak) was refused in each of its own tenants and listed nothing, and
54
+ a system actor of a per-tenant job was refused in the tenant it ran for. The guard of the bus and
55
+ `PolicyAccess` now give the authorizer the tenant of the work (`workTenantId` on `CheckRequest`
56
+ and `ListRequest`), read from the execution scope that `authorizationModule` wires. A request is
57
+ refused when the actor does not belong to the tenant it targets, when that target is not a tenant
58
+ id, and when it is another tenant than the one the work runs in; a system actor belongs to the
59
+ tenant its work runs in. A list is filtered on the tenant of the work, and is empty for an actor
60
+ of several tenants outside of a tenant's work. `RoleBasedAuthorizer` now refuses at construction
61
+ a cross-tenant role or a system role that no role declares, and `crossTenantRoles` without
62
+ `tenantOf`: each silently did nothing. To move, declare the role named by `systemRole` or
63
+ `crossTenantRoles` in `roles`, and give `tenantOf: tenantOfContext` where `crossTenantRoles` is
64
+ set. `authorizationMiddleware` and `PolicyAccess` built by hand take the `scope` option to do the
65
+ same.
66
+
67
+ **A policy is asked, or the use case fails.** The bus decides the permission of a use case before
68
+ its handler runs, with the identifier of the element at most; a policy, the part of the right
69
+ that needs the element, applies only when the handler asks `Access` once the element is loaded.
70
+ Forgetting to ask is now caught twice. `authz/policy-asked` (`ddd-ts-kit check`) reports the
71
+ handler of a command or query whose permission a policy refines (`@Policy` or `definePolicy`)
72
+ when it never asks `Access` about that permission. At run time, `authorizationModule` refuses the
73
+ result of such a handler with `PolicyNotAskedError`, inside the unit of work, so that its change
74
+ is rolled back: the guard gains a step that wraps the handler alone, `UseCaseGuard.within`, which
75
+ the buses of `kernelModule()` run with `aroundHandler`, a new method of the in-process buses that
76
+ places a middleware right around the handler, inside every other one: a middleware that answers
77
+ in place of the handler, as a spec that records the commands a job sends, is not judged. Without
78
+ the container, `policiesAskedMiddleware({ map, refined })` is the same check. The generators
79
+ follow: a command or a plain query generated once a policy refines its permission receives
80
+ `Access` and asks it, about the message until the element is loaded, with a `TODO(domain)` to
81
+ ask about that element instead; `generate policy` writes a use case of its permission again that
82
+ way when its files are still as generated, and otherwise leaves a `TODO(domain)` where the policy
83
+ is asked, which the rule accepts and `ddd-ts-kit todo` lists.
84
+
85
+ To move: a handler whose permission a policy refines calls `await this.access.authorize(actor,
86
+ permission, element)` before its change; `npx ddd-ts-kit check --rule authz/policy-asked` lists
87
+ the ones that do not. A project that binds its own `Access` instead of `PolicyAccess` passes
88
+ `policiesAsked: false` to `authorizationModule`, since only `PolicyAccess` records the questions.
89
+ A bus of your own, given through `kernelModule({ commandBus })`, runs `UseCaseGuard.within` with
90
+ `aroundHandler`.
91
+
92
+ **A domain event handler names its event once.** `@DomainEventHandler` and `@On` no longer take
93
+ the name of the event: they read the one its class declares, `static readonly eventName`. A
94
+ handler is now `@DomainEventHandler(InvoiceIssued)`, `@DomainEventHandler(InvoiceIssued, { phase:
95
+ 'after-commit' })` or, with a dependency list, `@DomainEventHandler(InvoiceIssued, [Mailer])`; a
96
+ method of a projection applies a domain event with `@On(AppointmentBooked)`, as it already did an
97
+ integration event. The compiler refuses an event class without a `static readonly eventName`, or
98
+ one that declares it empty, and the decorator refuses at declaration a name that is empty when it
99
+ is read. `NamedDomainEventType` (`@ddd-ts-kit/kernel/declarations`) is the type of such a class.
100
+ Since a handler can no longer be written under another name than the one its event carries,
101
+ `declarations/role-declared` (in `ddd-ts-kit check` and `@ddd-ts-kit/eslint-plugin`) no longer
102
+ judges that name. In the declared style, `generate event-handler` and `generate integration-event`
103
+ refuse an event class that does not declare its name, and say the two lines to write.
104
+ `@IntegrationEventHandler(definition, { consumer })` is unchanged: the consumer name is the
105
+ identity of the handler, not of the event.
106
+
107
+ To move:
108
+
109
+ 1. give every domain event class that declares only `readonly name = '..'` its
110
+ `static readonly eventName = '..'` and `readonly name = <Event>.eventName` (the classes
111
+ generated since 0.7 already have them);
112
+ 2. remove `name: ..` from the options of `@DomainEventHandler` and `@On`, and the options object
113
+ when nothing is left in it: `@DomainEventHandler(X, { name: X.eventName })` becomes
114
+ `@DomainEventHandler(X)`, `@DomainEventHandler(X, { name: X.eventName }, [Mailer])` becomes
115
+ `@DomainEventHandler(X, [Mailer])`;
116
+ 3. `npx tsc --noEmit` lists what is left.
117
+
118
+ **A quieter, more legible documentation website.** The site takes the pine green of the domain
119
+ layer of its diagrams as its accent instead of the default blue, cool slate greys, and IBM Plex
120
+ Sans and Plex Mono served with the site (no request to a font service). Headings, links, tables,
121
+ inline code, the current page of the sidebar and the cards of the home page are restyled; keyboard
122
+ focus stays visible and reduced motion is respected.
123
+
124
+ **Codex and GitHub Copilot, beside Claude Code.** `init` writes the guide of the project in
125
+ `AGENTS.md`, which Codex, Copilot and most coding agents read, and `CLAUDE.md` imports it; a
126
+ project initialised before keeps its `CLAUDE.md`, and `ddd-ts-kit sdk` writes in whichever of the
127
+ two it has. The rules by layer of `.claude/rules/` are also written for Copilot in
128
+ `.github/instructions/<layer>.instructions.md`, scoped by `applyTo`;
129
+ `.github/copilot/settings.json` declares the plugin of the kit, which the Copilot cloud agent
130
+ installs and VS Code recommends; with `--ci github`, `copilot-setup-steps.yml` installs the
131
+ project before the cloud agent starts. The plugin installs in Codex
132
+ (`codex plugin marketplace add gbourgeat/ddd-ts-kit`, from `.agents/plugins/marketplace.json`)
133
+ and in the Copilot CLI (`copilot plugin marketplace add gbourgeat/ddd-ts-kit`). Its guard reads
134
+ the patches of Codex and the tool calls of Copilot: a hand-written file of a generated role is
135
+ refused there as in Claude Code, and a stop with architecture errors is refused once per session
136
+ on a host that does not say it already was. Codex cannot ask the user, so a suppression or a
137
+ change of the rules is announced there instead of put to the project owner. The agent
138
+ configuration of Copilot and of Codex joins the files whose change the guard puts to the owner.
139
+
140
+ ## 0.17.0
141
+
142
+ **Names of the business, never `target`.** The command handler templates named the aggregate
143
+ they load or build `target`, and every project generated from them inherited the word. A
144
+ generated handler now names it after its concept, `const invoice = Invoice.create(..)`, and a
145
+ generated search names its lines after what it searches (`invoice`, `invoices`, never `item`).
146
+ A new rule, `naming/business-name`, held by `ddd-ts-kit check` and by `@ddd-ts-kit/eslint-plugin`
147
+ (`ddd-ts-kit/naming-business-name`), reports a variable, a loop variable, a destructured binding,
148
+ a parameter or a property of `domain/`, `application/` and the shared kernel named `target`,
149
+ `targets`, `data`, `item`, `items`, `obj`, `object`, `thing`, `things`, `stuff`, `info`,
150
+ `element`, `el`, `tmp`, `temp`, `foo`, `bar` or `baz`. ESLint fixes what it can name: a variable
151
+ built by `X.create(..)` or `new X(..)`, read from a repository (by the type of the call when the
152
+ parser has types, as the presets give it, by `xRepository.findById(..)` otherwise), or both
153
+ (`found ?? X.create(..)`) is renamed with every use to the camel name of the concept, unless that
154
+ name is taken; a loop variable is reported with the singular of its collection. The list is
155
+ `businessNames` of the config (`{ "words": [..], "allow": [..] }`), which the `words` and `allow`
156
+ options of the ESLint rule come over. `ddd-ts-kit explain naming/business-name` gives the reason
157
+ and both forms; the clean code and ubiquitous language pages say why `target`, `data` and `item`
158
+ hide the language.
159
+
160
+ The rule is on in every project, new or existing: a config without `businessNames` reads the
161
+ default list. To move:
162
+
163
+ 1. `npx eslint . --fix` renames the bindings whose initializer names the concept (in the example
164
+ application, 23 of 27);
165
+ 2. `npx eslint .` and `npx ddd-ts-kit check` list what is left, each message with the name to
166
+ write when the code says it (a loop variable, the result of a domain service): rename them;
167
+ 3. a word that is the term of your business (`item` of a catalogue, `object` of a relationship
168
+ model) goes in `businessNames.allow` of the config.
169
+
170
+ **The documentation is a website.** Every page of the documentation, the README of every package,
171
+ the page of every ESLint rule and the documents of the example are published at
172
+ <https://gbourgeat.github.io/ddd-ts-kit/>, in English and in French under `/fr/`, with search,
173
+ a sidebar that follows the reading path of the concepts, a dark theme and the diagrams of the
174
+ pages. The Markdown files of the repository stay the only source: `site/` assembles them at each
175
+ build (a private workspace, never published), `npm run site:dev` previews it, and the site is
176
+ deployed to GitHub Pages from `main`. A link or an anchor that leads nowhere fails its build,
177
+ which the CI runs on every pull request that touches the documentation.
178
+
179
+ ## 0.16.1
180
+
181
+ **The documentation is in French too.** Every reader-facing page of the repository (the root
182
+ pages, `docs/`, the README of every package, the page of every ESLint rule and the docs of the
183
+ example) has a French translation next to it, `X.fr.md`, reached by a switcher on its first
184
+ line; start at `README.fr.md` or `docs/README.fr.md`. A French page records the version of the
185
+ English page it translates, so a change to the English page without its translation is
186
+ reported. Release notes, agent instructions (`CLAUDE.md`, `.claude/`, `plugin/`), generated
187
+ files and the partly generated `CONTEXT.md` stay in English only.
188
+
189
+ **The pages teach with diagrams and samples that compile.** The concepts, guides, tools pages,
190
+ package READMEs and the example docs are enriched with worked examples and hand-written SVG
191
+ diagrams (one English and one French drawing each, readable in dark mode and on npm). A
192
+ TypeScript block marked `<!-- checked sample -->` is compiled against the packages of the
193
+ workspace, in both languages, so a sample cannot drift from the API; several samples that no
194
+ longer matched the API were corrected on the way.
195
+
196
+ **Two new checks.** `npm run docs:translations` (in `ci:docs`) reports a page without its
197
+ switcher, a missing or stale French page, a link or anchor that leads nowhere, a French page
198
+ that links to an English page whose French sibling exists, and a diagram without its French
199
+ drawing or malformed. `npm run docs:samples` (in `ci:typecheck`) compiles every checked sample.
200
+ [docs/translation.md](docs/translation.md) says what is translated, the shape of a French page,
201
+ the glossary, the style of the diagrams and how to keep it all current.
202
+
203
+ ## 0.16.0
204
+
205
+ **A new project writes its values as classes, and the kit checks it.** `init` writes
206
+ `"stack.values": "classes"` (`--values classes|free`), under which a new rule,
207
+ `domain/value-object-class`, held by `ddd-ts-kit check` and by `@ddd-ts-kit/eslint-plugin`
208
+ (`ddd-ts-kit/domain-value-object-class`), judges the model. A file of `domain/value-objects/` (or
209
+ a `*.vo.ts` of the shared kernel) exports exactly one class extending `ValueObject`, directly or
210
+ through another value object of the project, with a private or protected constructor and static
211
+ factories, and no function, arrow constant or object literal, inline, by an export list or by
212
+ default; the types of its props may sit next to it. In `domain/` and in the shared kernel outside
213
+ `presentation/`, an exported function that answers a record of data,
214
+ `export const money = (amount, currency) => ({ amount, currency })`, is reported with the command
215
+ that writes the class (`npx ddd-ts-kit generate value-object`): an object is a record of data when
216
+ it holds data (a literal, a parameter, a call of the language), no spread and nothing a call or
217
+ `new` of the model built. A domain service, a state machine, a specification answering a boolean
218
+ or functions only, a record of value objects the function builds, a reducer that spreads its state
219
+ and a function declaring a return type of a package are not concerned; a decision answered as a
220
+ plain record is. `ddd-ts-kit explain domain/value-object-class` gives the reason and both forms.
221
+
222
+ A config without the key reads as `free`: an existing project upgrades without a change, and
223
+ nothing is reported. To move: set `"stack.values": "classes"`, run
224
+ `npx ddd-ts-kit check --rule domain/value-object-class` and ESLint, write each reported value as a
225
+ class with `generate value-object` (its checks in the static factory, its helpers as methods, its
226
+ callers on `Money.of(..)` and `equals`), make each reported constructor private and add the
227
+ factory the rule names. The steps are in `docs/CONFIG.md`; until they are done, the rule can stay
228
+ at `warn` under `rules`, recorded in `deviations`.
229
+
230
+ **The text checker reads a class whose heritage holds an object type.** `class Money extends
231
+ ValueObject<{ readonly cents: number }> {` was read as a class whose body was the object type:
232
+ its methods and constructor are now found, for every rule that reads them.
233
+
234
+ **Why a value object is a class.** `docs/concepts/entities-and-value-objects.md` explains it with
235
+ three diagrams (the two forms, what the rule reads, how an answered object is read entry by
236
+ entry) and both forms side by side: equality without identity, invariants checked once in
237
+ the factory, behaviour next to the data, refusals from static factories, immutability and
238
+ `instanceof` in the boundary mappers. The domain rules `init` writes and the `scaffold` and
239
+ `clean-code` skills say the same.
240
+
241
+ **The example models its values as classes.** In the analytics context, the outcome the occupancy
242
+ measure reads and the figures it answers are value objects, `MeasuredOutcome` and
243
+ `OccupancyFigures`, each with its spec. Each checks its rules in its factory: an outcome never
244
+ ends before it starts, figures are whole numbers, none below zero, with at least as many
245
+ appointments held as completed and missed ones, refused through `OccupancyError`.
246
+ `CalendarDesk` and `FreedSlot` stay parameter objects: they group the arguments of one factory,
247
+ and `FreedSlot` holds a `Calendar`, an entity no equality by value can compare.
248
+
249
+ ## 0.15.0
250
+
251
+ **A context written whole: the identity preset** ([Bootstrap with well-designed contexts](docs/guides/bootstrap-with-well-designed-contexts.md)).
252
+
253
+ - `init --preset identity` and `generate context <name> --preset identity` write a generic
254
+ context instead of an empty one: accounts provisioned from the identity provider at the first
255
+ sign-in (no password kept), organisations (the tenants under `--tenancy shared-table`, their
256
+ tables then under a decision record), members with roles of a declared vocabulary, invitations
257
+ by token with an expiry, delegations of rights for ninety days at most; seven policies, the
258
+ published language (`member.joined`, `member.role-changed`, `account.deactivated`...), a
259
+ search, controllers, specs with builders and fakes, the Knex or Drizzle adapters, and the port
260
+ `IdentityProviderAdmin`, kept in line at least once by consumers of the published facts, with a
261
+ Keycloak 26 adapter given as a recipe (run once by hand against Keycloak 26.8.0, not in the
262
+ pipeline). Nobody changes their own role or acts above it, whatever
263
+ a delegation lends, and a change decided under a delegation names it (`delegationId`); every
264
+ change of a member saves its organisation, so two owners who remove each other at once cannot
265
+ both commit; a deactivated account leaves its organisations; an address the provider did not
266
+ verify (`email_verified`) provisions no account. With tenants and Keycloak, the authenticator
267
+ reads the `organization` claim as the tenants of the person. Written for the declared style on
268
+ Knex or Drizzle on PostgreSQL, Zod or class-validator, Express or Fastify, with a token
269
+ verifier, and for both `stack.schemas`: shared, its identifier answers are `acceptedSchema` and
270
+ the token of an invitation one shared schema; per route, each route keeps its own; another stack is refused with the flag to change, and so is a second context of the
271
+ same preset. Nothing is recorded in the config. Which other contexts deserve one:
272
+ [Context presets](docs/proposals/context-presets.md).
273
+ - `@ddd-ts-kit/auth`: `SignedInGrantsAuthorizer({ grants, otherwise })` grants every signed-in
274
+ person (an actor of type `user`) the same permissions before the model of the project decides
275
+ the rest, whatever that model is. The preset wraps `createAuthorizer` in it, its grants in
276
+ `platform/auth/signed-in-grants.ts`.
277
+ - `@ddd-ts-kit/auth`: the default mappings of `JwtAuthenticator` and `keycloakAuthenticator` read
278
+ a tenant claim that lists several tenants (the `organization` claim of a member of several
279
+ organizations) as the `tenants` attribute of the actor, among which a request names one, where
280
+ they gave no tenant; and both give the attributes `username`, `email` and `emailVerified`
281
+ (`email_verified`), where `JwtAuthenticator` gave none.
282
+ - The barrels and dependency files the CLI writes set a call that takes several lines apart by
283
+ blank lines, as `@stylistic/padding-line-between-statements` asks.
284
+ - Plugin 0.22.0: the skill `bootstrap` proposes the preset for a generic identity context.
285
+
286
+ **The example serves several networks of practices, one tenant each** (its ADR-0003, accepted).
287
+
288
+ - `@ddd-ts-kit/knex`: `addTenantColumn(knex, { schema, table, tenantOfExistingRows })`, the
289
+ migration of a table of a project that becomes multi-tenant: the rows already there go to the
290
+ tenant named (refused when there are rows and none), then the column is required and indexed; a
291
+ table that has the column is left alone. `derivedTable(context, query, alias)` selects from a
292
+ query confined by `tenantTable`, for a measure grouped then paged by its output columns.
293
+ - `@ddd-ts-kit/knex`: `addOutboxTenantColumn(knex, { schema, tenantOfUnpublished })` gives the
294
+ events not published yet (pending or dead) the tenant the database served, so that their
295
+ consumers run in it.
296
+ - `@ddd-ts-kit/http`: `servedTenantOf(resolver, served)` answers 404 to a tenant the deployment
297
+ does not serve, whoever names it, and reads the headers its resolver reads, so that a cached
298
+ answer still varies on them.
299
+ - `@ddd-ts-kit/knex`: `createProcessTable(knex, { tenant: true })` adds `tenant_id` to the
300
+ instances of the process managers, and `KnexProcessStore(context, { tenant: scope })` keeps each
301
+ one in the tenant of the work that saves it: an instance of another tenant is neither found nor
302
+ changed, and work that names no tenant is refused (`NoCurrentTenantError`).
303
+ - `@ddd-ts-kit/di`: `kernelModule({ tenants })` binds a `TenantDirectory` and gives it to the
304
+ default scheduler, so that a job declared `tenancy: 'per-tenant'` can be registered; the test
305
+ application takes `tenants` too, by default the tenant of its execution context, and composes
306
+ with none named (such a job then runs for no tenant), where it refused the job before.
307
+ - `tenancy/scoped-statement` reads the literal text of a raw call: a statement of the schema with
308
+ no word of rows, and an expression that holds no word reading or writing rows or starting a
309
+ statement (`table`, `with`, `values`, `call`, `do`...) and calls only known expression
310
+ functions (aggregates, casts, arithmetic, text), are no longer reported; raw SQL whose text is
311
+ not a literal, or a template that interpolates, still is. Bind a list as one value
312
+ (`state = any(?)`) instead of interpolating its placeholders.
313
+ - Plugin 0.22.0: the guard lets `test/e2e/journey-*.e2e.spec.ts` be written by hand, since a
314
+ journey crosses several routes and no generator writes it.
315
+ - Example: `"stack.tenancy": "shared-table"`, a tenant column on its twelve tables and the
316
+ migration that adds it to an existing database, `tenantIsolationContract` on every Knex
317
+ repository, the jobs run per network (`NETWORKS`), the audit trail read by platform staff
318
+ through a recorded elevation, `x-tenant-id` refused at the edge for another network than the
319
+ caller's, and a network missing from `NETWORKS` not found. Its HTTP contract changed: `POST
320
+ /practices` asks the role `network-admin`, `GET /audit/entries` takes `network`, every request
321
+ that reaches data needs the `tenant_id` claim, a token or header naming a network not served is
322
+ answered 404, and a person registered in one network who registers in another is refused
323
+ (422, `CONCURRENCY_CONFLICT`). What 0.11 to 0.14 added to it is confined the same way: the
324
+ instances of the confirmation deadline keep their network, the migration gives the network to
325
+ the instances and the unfinished background jobs already there, a clearance is read in the
326
+ network of its job, and the public opening hours are tagged and purged per network
327
+ (`cacheTags.perTenant`, `PublishedCacheInvalidation` with `perTenant`), named by `x-tenant-id`
328
+ for an anonymous caller and varying on it; their tags read `<network>/calendar:<id>`.
329
+
330
+ ## 0.14.0
331
+
332
+ **A route answers in JSON, XML or CSV, as the request asks.** `Representations.standard()` of
333
+ `@ddd-ts-kit/http`, given to the adapters (`representations` of the mount options) and to
334
+ `generateOpenApi`, chooses per request the media type the `Accept` header prefers among those
335
+ the route declares in `produces` (on `@Controller` or `defineRoute`), JSON first; none
336
+ acceptable is `406 NOT_ACCEPTABLE`, before the handler runs. A refusal is written as
337
+ `application/problem+xml` when XML was negotiated. A route that declares `consumes:
338
+ ['application/xml']` reads an XML body into the value the JSON path produces, validated by the
339
+ same schema; another media type is `415 UNSUPPORTED_MEDIA_TYPE`. XML follows one convention,
340
+ the default of the XML object of OpenAPI, with a parser of the package's own that refuses any
341
+ document type declaration (no new dependency). CSV is written for a page or a list of flat rows,
342
+ and a handler that answers an `AsyncIterable` under `streamSchemaOf(item)` is streamed in every
343
+ representation; one that fails once its status is sent is reported to `onError` with
344
+ `interrupted: true`. `everyPage(readPage)` streams a search page after page, each page a query
345
+ sent through the bus, so each one runs under the middlewares of the bus (transaction, tenant
346
+ session, timeout, tracing). A route that declares no body schema (a file, a redirect, no
347
+ content) is never refused 406 and its answer, a `Readable` included, goes out as the handler
348
+ wrote it. The contract follows the representations it is given, which must be those the
349
+ server is mounted with: every media type of each route, `406 NOT_ACCEPTABLE` on every route a
350
+ representation writes, `415 UNSUPPORTED_MEDIA_TYPE` on every route that reads a body; without
351
+ them, JSON only and neither status. Mounting a route that declares `produces` or `consumes`
352
+ without representations is refused. Both codes join the default error mapping and the codes
353
+ of the kit. Without the option nothing changes: every answer stays JSON. `money` and
354
+ `decimalText` write money and decimals as strings, the rule every representation follows.
355
+ The documentation page of a generated project (`withDocumentation`) is written with the error
356
+ mapper and the representations of the boundary; the contract script looks for stale files in
357
+ `openapi/` itself and, with forms, in `openapi/forms/` only.
358
+
359
+ **A generated project negotiates its representations and serves its forms.** Two keys of
360
+ `stack`, chosen at `init`: `representations` (`negotiated`, `json`) and `forms` (`on`, `off`),
361
+ both on by default with an HTTP stack. `negotiated` writes `src/platform/http/presentation-of.ts`,
362
+ which gives the HTTP boundary and the contract script `Representations.standard()` and a field
363
+ decider over the `Access` of the application; `forms: on` writes `with-forms.ts`, which serves
364
+ `GET /forms/<operationId>` for each route that declares `form`, and `npm run docs:generate`
365
+ writes the forms to `openapi/forms/`. A config without the keys is read as `json` and `off`:
366
+ nothing changes until `init --force --representations negotiated --forms on`. `generate
367
+ representation <context> <UseCase> xml,csv` adds `produces` (and `consumes` of XML for a
368
+ route with a body) to a controller, `generate form <context> <UseCase>` its `defineForm`
369
+ hints, and `describe` lists the media types and the form of each route.
370
+
371
+ **A route describes its body as a form.** A route that declares `form` (on `@Controller` or
372
+ `defineRoute`, `defineForm<Body>(hints)` next to the schemas of the controller) is served by `formRoutes(routes,
373
+ { fieldAccess })` at `GET /forms/<operationId>`: JSON Forms by default (schema, UI schema,
374
+ data), or a neutral tree of groups and fields with `?format=blocks`. Fields, constraints and
375
+ options come from the request schema and its closed vocabularies, layout, widgets, lookups and
376
+ conditions (`visibleWhen`, `requiredWhen`) from the hints, labels are translation keys, and
377
+ values given in the query prefill it. The form respects the caller: given only to who may run
378
+ its use case when it declares `requires` (the permission asked as `@Requires` asks it, from the
379
+ values of the query; 403 otherwise), a field they may not see is absent (and not echoed in
380
+ `data`), one they may not set is read-only, an object with every field it holds; the server
381
+ still validates and the use case still decides. `formDocumentsOf(routes)` writes every form whole for the documentation.
382
+
383
+ **A field of an answer can be reserved to some callers.** `withFieldAccess(schema, { field: {
384
+ read, write, subject } })` of `@ddd-ts-kit/http` marks a field with a permission or a policy:
385
+ the pipeline leaves it out of every representation (JSON, XML, CSV, streamed lists) for an actor
386
+ who may not read it, refuses it with 403 in a request body from one who may not set it, and the
387
+ contract marks it with `x-field-access` and no longer lists it as required. `fieldDeciderOf({
388
+ access, clock })` decides through the `Access` of `@ddd-ts-kit/auth`, once per element and per
389
+ request, remembering the last 256 elements per requirement; without a decider a restricted
390
+ field is refused to everybody. A field shown or hidden is decided without being recorded, a
391
+ field set in a body is recorded: `Access.decideOn({ actor, permission, subject, context,
392
+ recorded })`, new and optional on `Access`, implemented by `PolicyAccess`. The new rule
393
+ `authz/fields-by-schema` (checker and ESLint) refuses a test on the roles of the actor under
394
+ `application/queries/`, the hand-written way of shaping an answer.
395
+
396
+ **The JSON Schema of a class-validator class holds what it inherits.** A class that extends
397
+ another one, `class SearchPracticeAgendaLine extends SearchAppointmentsLine {}`, was described as
398
+ an empty object: the contract said nothing of its fields. `classSchema` now gives it the
399
+ properties and the required names of its ancestors. A project with such classes sees its
400
+ contract grow on the next `npm run docs:generate`. The end-to-end client of
401
+ `@ddd-ts-kit/testing` sends a string body as it is when the call names another content type
402
+ (`{ headers: { 'content-type': 'application/xml' } }`), as JSON otherwise.
403
+
404
+ The concepts and the tools are explained in two new pages, `docs/concepts/representations-and-forms.md`
405
+ and `docs/tools/representations-and-forms.md` (JSON Forms renderers for React, Angular and Vue,
406
+ the alternatives, why no XML or CSV library), and parts 2 and 3 of `docs/proposals/read-side.md`
407
+ are marked built.
408
+
409
+ **Representations, caching and the four adapters together.** `mountHono` serves what
410
+ `mountExpress` and `mountFastify` serve: a body in a media type the route consumes is read as
411
+ text under `bodyLimit`, a streamed list is streamed, and the mount refuses `produces` or
412
+ `consumes` without representations. A cached answer written in XML or CSV keeps the validators
413
+ and tags of its policy: the version, the instant and the tags are read from what the handler
414
+ answered, a strong `ETag` digests the bytes of the media type sent (none on a streamed list),
415
+ and `Vary` holds the headers of the policy and `accept`, in the answer and in the contract. A
416
+ public answer chosen by `Accept` is refused at the mount behind a cache that ignores `Vary`, and
417
+ so is a public answer with fields reserved to some callers whose `vary` names neither
418
+ `Authorization` nor `Cookie`, even on a route anybody may call: a shared cache would hand the
419
+ fields one caller may read to the next. A shared schema keeps its restricted fields whichever
420
+ wraps the other (`withFieldAccess(sharedSchema(...))` or `sharedSchema(name,
421
+ withFieldAccess(...))`, through `schemaBehind` of `@ddd-ts-kit/validation`), in the definition it
422
+ refers to and in the answer, and the page of a shared item keeps the restricted fields of the item.
423
+
424
+ ## 0.13.0
425
+
426
+ **A schema shared by several routes is documented once.** Declared once, a schema used whole or
427
+ inside other schemas by several routes lands once in `components.schemas` of the OpenAPI
428
+ contract and every route points at it with `$ref`: a zod schema with `.meta({ id })`,
429
+ `classSchema(Dto, { shared: true })`, or any schema through `sharedSchema(name, schema)` of
430
+ `@ddd-ts-kit/validation`. class-validator gains `pickClass`, `omitClass` and `partialClass` to
431
+ reuse a DTO class in part, and the Standard Schema adapter documents a schema its library
432
+ converts itself (Standard JSON Schema, verified with zod 4) when no JSON Schema is given. The
433
+ platform shares its own: `acceptedSchema` (`Accepted`, the `{ id }` a command route answers),
434
+ the page of a shared item (`<Item>Page`) and the problem document. A shared `params`, `query`
435
+ or `headers` schema still gives one parameter per property.
436
+
437
+ **The contract of a project on the Standard Schema adapter can change.** `standardSchema(schema)`
438
+ without a JSON Schema documented `{}` (any value); it now documents what the library converts
439
+ itself, so a project on `"stack.validation": "standard-schema"` whose schemas implement Standard
440
+ JSON Schema (zod 4 does) sees its OpenAPI contract and client SDK grow the real shapes, and
441
+ `openapi --check` or `sdk --check` report drift until they are regenerated. To move: run
442
+ `npm run docs:generate` and commit the contract and the client; to keep a schema documented as
443
+ any value, pass `{}` as its second argument. A schema the library cannot convert (a date, a
444
+ custom check) is still documented `{}`, never a failure.
445
+
446
+ **A new project declares a shape once.** `init` writes `"stack.schemas": "shared"`: a generated
447
+ command route answers its identifier with `acceptedSchema` instead of a schema of its own,
448
+ `generate schema <context> <Name>` writes a shape several routes use in
449
+ `presentation/http/shared/<name>.schema.ts` (`generate schema shared-kernel <Name>` in
450
+ `presentation/` of the shared kernel, for what several contexts answer with), and two rules hold
451
+ it: `schema/no-duplicate-shape` reports a DTO class or an object schema that declares by hand
452
+ the shape of another one of its context or of the shared kernel (two contexts may say the same
453
+ thing in their own words; a placeholder the generator left to fill is not judged), `schema/presentation-only` keeps every schema in a presentation layer
454
+ (the second in ESLint too). A config without the key reads as `per-route`: nothing changes and
455
+ nothing is reported; the steps to move are in `docs/CONFIG.md`. The example now shares its
456
+ schemas: its twenty identifier answers are the one `Accepted` component, its two agenda
457
+ searches answer `AgendaLinePage` pages of one `AgendaLine`, and one `TimeSpan` is the body of
458
+ the time off route whole and the start of a booking or a rescheduling through
459
+ `pickClass(TimeSpan, ['startsAt'])`; the bytes on the wire are unchanged.
460
+
461
+ **Hono and TypeBox.** `@ddd-ts-kit/http/hono` mounts the routes on Hono 4 (`mountHono`), through
462
+ the same pipeline and the same behaviour suite as Express, Fastify and NestJS, uploads and signed
463
+ file links included. The adapter reads a JSON body chunk by chunk up to `bodyLimit` (1 MiB by
464
+ default, as Fastify): a larger one is answered 413 `BODY_TOO_LARGE`, one that is not JSON 400
465
+ `MALFORMED_BODY`, both as problems. `@ddd-ts-kit/validation/typebox` validates with TypeBox 1
466
+ (`typeboxSchema`, compiled once, defaults filled, undeclared properties dropped, the failed
467
+ keyword as the issue code and a missing property at its own path, `{ coerce: true }` for query
468
+ strings) and documents the schema as it is, a nested schema with an `$id` shared once in the
469
+ contract (`Type.Intersect` extends it and keeps the reference). Both libraries are optional peers.
470
+
471
+ **Four contract pairs a project starts from.** `init --http hono` serves the routes with Hono on
472
+ Node (`@hono/node-server`, the limiter and the readiness probe in front when the cache is
473
+ shared), and `init --validation typebox` writes every schema of a route (the line of a search
474
+ included), a function route and a shared schema with TypeBox. A new end-to-end spec,
475
+ `contract-pairs.spec.ts`, generates one project per pair (Express and zod, Fastify and TypeBox,
476
+ Hono and zod, NestJS and class-validator), shares one shape between two routes, adds a search,
477
+ and holds each to zero finding, its specs and end-to-end specs (401, 403, 201 and the trace of
478
+ the request), a current contract that documents the shape once, and a client generated from it
479
+ that names `Money` and `Accepted` once and compiles. NestJS with class-validator, generated before but never run end to end, is now.
480
+
481
+ **Which pair for which team.** `docs/tools/contract-pairs.md` compares the four pairs (type
482
+ inference, composition of shapes, contract fidelity, issue codes, ecosystem, maintenance) with
483
+ numbers measured by `pipeline/monorepo/measure-contract-pairs.mjs`: the pipeline of the kit costs
484
+ about a microsecond per request before validation, zod parses in about half a microsecond,
485
+ class-validator and TypeBox (through the adapter) in five to ten. oRPC was run and is documented
486
+ as not fitting: its procedures, error format and second contract generator would sit next to the
487
+ kit's, and the typed client it offers comes here from the generated contract.
488
+
489
+ ## 0.12.0
490
+
491
+ **Cache policies on routes, and `no-store` for authenticated answers.** A controller, a route
492
+ group or a function route declares `cache: { scope, maxAge, sMaxAge, staleWhileRevalidate,
493
+ staleIfError, mustRevalidate, vary }` of `@ddd-ts-kit/http`: the pipeline answers its
494
+ `Cache-Control` and `Vary`, the contract documents them, and a policy that would let a cache keep
495
+ what it must not is refused when the route is declared, above all the `public` answer of a route
496
+ that knows its caller without the request headers it varies on. When the routes are mounted, a
497
+ public answer is held to the options of the application: on a route that knows its caller, `vary`
498
+ names `Authorization`, `Cookie` or the header its tenant is read from; behind a cache that ignores
499
+ `Vary` (Cloudflare, Bunny; `cacheTags.honoursVary` to say otherwise) a varying answer is refused;
500
+ and every kept answer varies on the headers `resolveTenant` reads (`TenantResolver.headers`, set by
501
+ `tenantFromHeader` and `firstTenantOf`). The policy of a route group reaches its reads only: a
502
+ write of the group keeps the default of a write. A route that is not public and
503
+ declares nothing now answers `Cache-Control: no-store`, refusals included (before, nothing): a
504
+ generated contract gains that header on every authenticated operation, regenerate it with
505
+ `npm run docs:generate`. The ESLint rule `presentation/shared-cache-varies` reports in the editor a
506
+ public policy, without `vary`, on a declaration that writes guards or security schemes. See
507
+ `docs/concepts/http-caching.md`.
508
+
509
+ **Conditional requests.** A cache policy names the version of an answer, `etag: 'representation'`
510
+ (a strong tag hashed from the bytes) or `etag: { version: 'version' }` (a weak tag from a field of
511
+ the body), and `lastModified: 'changedAt'`: the pipeline answers `ETag` and `Last-Modified`, and
512
+ `304 Not Modified` to `If-None-Match` and `If-Modified-Since`. A write declares
513
+ `ifMatch: 'optional' | 'required'`: the version named in `If-Match` reaches the handler as
514
+ `context.expectedVersion`, `expectVersion(aggregate, expected)` of the kernel refuses a stale one
515
+ with `PreconditionFailedError` (`PRECONDITION_FAILED`, 412), and a required one that is missing
516
+ answers `PRECONDITION_REQUIRED` (428). Both codes join the default error mapping and the codes a
517
+ route may declare without a class of its own (`errors/known-code`). The contract documents the
518
+ parameters, headers and answers.
519
+
520
+ **Cache tags, purged after the commit.** A public policy names the resources its answer shows
521
+ (`tags: [{ resource: 'calendar', param: 'calendarId' }]`), sent in the header the shared cache
522
+ reads (`cacheTags: { header: CacheTagHeader.xkey }` of the options; `surrogate-key`, `cache-tag`,
523
+ `cdn-tag`), filed under the tenant of the request when the application says so (`perTenant`, on
524
+ the answers and on the purges). The kernel gains two ports: `CacheInvalidation`,
525
+ which a `before-commit` handler asks and which `kernelModule()` binds to purge requests published
526
+ on the outbox (a rollback purges nothing, a commit purges even across a crash), and `CachePurger`,
527
+ which the relay calls through `new PurgingPublisher(purger, broker)` and which
528
+ `domain-events/transactional-before-commit` now refuses before the commit. `@ddd-ts-kit/http/purgers`
529
+ holds `VarnishCachePurger` (xkey or ban, with the purge `key` of the VCL), `FastlyCachePurger` and
530
+ `CloudflareCachePurger`, all held to `cachePurgerContract` of `@ddd-ts-kit/testing/contracts` (the
531
+ CDN ones on a fake of the purge API each documents); the test application exposes
532
+ `cacheInvalidation`. `RepeatedCachePurger` purges once more a little later, for a read that reached
533
+ the cache after the purge. Filed per tenant, a tagged answer to a request naming no tenant is
534
+ `no-store`, a tagged public route anybody may call needs `resolveTenant`, and an invalidation asked
535
+ without a tenant throws `NoCurrentTenantError`. `CacheInvalidation` and `CachePurger` join the
536
+ standard interfaces (`standard/no-parallel-interface`: `CacheInvalidator`, `CdnPurger` and the
537
+ like). Varnish 9.1.0 runs in the integration job. See `docs/tools/http-caches.md`.
538
+
539
+ **Varnish in front of a generated project.** `init --http-cache varnish` (recorded as
540
+ `stack.httpCache`; a config without it is `none`) adds the Varnish 9.1.0 service to the compose
541
+ file with its VCL in `docker/varnish/default.vcl` (it keeps only what an answer allows by
542
+ `max-age` or `s-maxage`, and lets a purge in from its machine or with `VARNISH_PURGE_KEY`),
543
+ validates `VARNISH_URL` and `VARNISH_PURGE_KEY`, tags the public answers in `xkey` from the HTTP
544
+ boundary, and has the outbox relay of `main.ts` and of every `generate service` entry point purge
545
+ through `PurgingPublisher` and `VarnishCachePurger`; the end-to-end application records the purges
546
+ (`purges.purged`). It needs an HTTP framework and `--events outbox`. On `--di manual` and `nest`
547
+ the platform gains `cacheInvalidation`. With `--tenancy`, the answers and the purges are tagged
548
+ per tenant and the HTTP boundary reads the tenant of a request from `x-tenant-id`.
549
+ `generate controller --cache public|private|no-store` writes the cache policy of a controller.
550
+ The generated presentation rules say when to cache, purge and condition a write.
551
+
552
+ ## 0.11.0
553
+
554
+ **File import and export, designed.** `docs/concepts/file-import-export.md` says how a large
555
+ file is to be processed on the job queue and the file storage: an import read as a stream and
556
+ sent as commands batch by batch, with its checkpoint, its progress and a rejection report; an
557
+ export written in parts; chunks followed by a process manager beyond one attempt. Nothing of it
558
+ is built in this version; the page lists what would be added.
559
+
560
+ **A long background job in the example.** A practitioner going away clears a period of their
561
+ calendar: `POST /calendars/:calendarId/clearances` (202, the identifier of the clearance) enqueues
562
+ `ClearCalendarBackgroundJob` in the unit of work of the command, which cancels the appointments
563
+ of the period one by one as the person who asked, with a checkpoint and its progress after each,
564
+ and `GET /calendars/:calendarId/clearances/:clearanceId` answers its status (an enum of
565
+ `QueuedJobStatus`) and `{ done, total }`, for a clearance of that calendar only (the subject of
566
+ its job);
567
+ then the time off is accepted. Both routes are new, with the permission `scheduling:calendar.clear`
568
+ and the error code `UNKNOWN_CLEARANCE` (404); no existing route of the contract changes. The
569
+ end-to-end support of the example can start the worker (`startApplication({ jobs: true })`).
570
+
571
+ **Generators for background work.** `ddd-ts-kit generate background-job <context> <Name>
572
+ --command <UseCase>` writes a background job that sends its command as the actor that enqueued
573
+ it (`execution.actor`, new on `BackgroundJobExecution`), with one field of its payload per other
574
+ input of the command (a `Date` as its ISO text), registered by `@Background()` or on the builder
575
+ of the module file, and a spec that enqueues a payload of every field and runs what is due. `generate
576
+ state-machine <context> <Aggregate> --value <state>... --transition "<intention>:<from>>[<to>]"`
577
+ writes the status vocabulary (or adds the values it misses), the machine and its spec; a machine
578
+ without `refused` throws the new `TransitionError.forbidden` (code `FORBIDDEN_TRANSITION`), and
579
+ `lifecycle/status-through-machine` asks an aggregate to import its machine once it writes a
580
+ status. `generate process <context> <Name>` writes a process manager, the background job of its
581
+ timeouts and their specs. The two that run on the queue need `--di tsyringe`. `init --queue
582
+ database|redis|memory` chooses where jobs and process instances are kept (`stack.queue`):
583
+ `database` by default on Knex or Drizzle with tsyringe, the jobs and processes tables created by
584
+ the first migration and bound by the platform module; `redis` puts the jobs on the server of the
585
+ cache, a write lost after the commit logged and reported (`reportLostJobs`); a config without
586
+ the key is read as `memory`, as before. A queue kept in the memory of a process is consumed by
587
+ that process even with its jobs off, so the API of a project with a worker still runs the jobs
588
+ it enqueues. Under standard decorators,
589
+ `@Background` records the job once the class is defined, when its static `jobName` exists.
590
+
591
+ **Process managers.** A long-running process that reacts to events, keeps its own state and
592
+ sends commands is a class of `application/processes/` whose steps decide (`ProcessDecision`: the
593
+ next state, the commands, the timeouts armed or disarmed, the end). The new `ProcessRunner`
594
+ port (runner in `@ddd-ts-kit/scheduling`) runs a step in one unit of work: it loads the instance
595
+ an event correlates, saves its state with optimistic concurrency through `ProcessStore`
596
+ (`InMemoryProcessStore` in the kernel, `KnexProcessStore` and `createProcessTable`,
597
+ `DrizzleProcessStore` and `processesTable`, held by `processStoreContract`), arms its timeouts
598
+ as delayed background jobs and sends the commands. `givenProcess` of `@ddd-ts-kit/testing`
599
+ tests a step without infrastructure. The new rule `process/sends-commands-only` (checker and
600
+ ESLint) refuses a process that imports an aggregate, a repository, a port or infrastructure;
601
+ `CONTEXT.md` gains a `Process managers` section and `describe` lists them. In the example, the
602
+ confirmation deadline of a requested appointment is a process manager; the booking trace gains
603
+ one handler span, the HTTP contract is unchanged.
604
+
605
+ **State machines.** `defineStateMachine` (kernel) declares the lifecycle of an aggregate once,
606
+ in `domain/state-machines/<aggregate>.state-machine.ts`: its states, the states it starts in,
607
+ transitions named after the intention methods, guards, and the refusal of a forbidden transition
608
+ from a static factory of the concept error. The aggregate asks `machine.next(status, 'confirm',
609
+ context)`; the new rule `lifecycle/status-through-machine` (checker and ESLint) refuses a status
610
+ written by hand in an aggregate whose machine exists, and `ddd-ts-kit docs` draws each machine in
611
+ a new `State machines` section of `CONTEXT.md` (and lists the background jobs in a
612
+ `Background jobs` section); `describe` lists both. A CONTEXT.md written before gains the
613
+ sections by hand: copy them from a context generated by this version. In the example, the
614
+ appointment lifecycle is a state machine; its HTTP contract is unchanged.
615
+
616
+ **Background jobs.** A use case hands work to a worker with the new `JobQueue` port of the
617
+ kernel: `jobs.enqueue(CompileStatementsBackgroundJob, { month }, { delay, runAt, idempotencyKey, subject })`, the
618
+ job a class with a static `jobName` declared with `@Background({ maxAttempts, backoff, timeout,
619
+ lease })`, its payload typed by its `run`. With a database the job is a row of the jobs table of
620
+ its context, written by the transaction of the command (`KnexJobStore` and `createJobTable` in
621
+ `@ddd-ts-kit/knex`, `DrizzleJobStore` and `jobsTable` in `@ddd-ts-kit/drizzle`), so a command
622
+ that rolls back leaves no job and one that commits loses none; `RedisJobStore` and
623
+ `InMemoryJobStore` write it, and a cancellation or a revival, once the unit of work committed
624
+ (`whenCommitted`, new in the kernel). An idempotency key is scoped by the tenant of the job, and
625
+ work done for a tenant finds and cancels the jobs of that tenant only; `find` answers the tenant
626
+ and the `subject` of a job, never its payload, and its status is the closed vocabulary
627
+ `QueuedJobStatus`. The `QueueWorker` of `@ddd-ts-kit/scheduling` claims the jobs of the queues of its
628
+ contexts with a lease it extends by heartbeats, at least once, with retries and an exponential
629
+ backoff, dead jobs, cancellation, progress, checkpoints for long jobs, a concurrency per queue
630
+ and a graceful stop; each attempt runs as the actor, in the correlation, the tenant and the
631
+ trace of the work that enqueued it, is counted in `background_job_runs_total` and
632
+ `background_job_duration_ms`, and reports an unexpected failure to the error reporter.
633
+ `@ddd-ts-kit/di` registers the declared jobs and runs the worker in the processes that run the
634
+ jobs; the test application gains `jobQueue` and `backgroundJobs.runDue()`. One contract,
635
+ `jobStoreContract`, holds every store; the Knex and Drizzle stores are proven on PostgreSQL in
636
+ CI behind a pool of several connections, the Redis store on Dragonfly. Prisma and TypeORM get no
637
+ store yet. `givenProcess(..).thenState` fails a state that JSON would change, as the runner
638
+ stores it. See [Background jobs](docs/concepts/background-jobs.md).
639
+
640
+ ## 0.10.0
641
+
642
+ **Event sourcing, chosen per aggregate.** `EventSourcedAggregate` in the kernel: a command
643
+ checks its invariants and records an event, the state changes only in the apply method of each
644
+ event, and the aggregate is rebuilt by replaying its stream, from a snapshot when one was
645
+ taken. An `EventStore` port (append at an expected version, a concurrency conflict otherwise,
646
+ each stream in order, the whole store by position), `InMemoryEventStore`, stored events
647
+ declared with `defineStoredEvent` and read back through upcasters (a stored event is never
648
+ rewritten), `EventSourcedRepository` for the adapters, snapshots per aggregate, and
649
+ crypto-shredding of personal data (`personalData`, `PersonalDataCipher`, `SubjectKeyStore`).
650
+ `@ddd-ts-kit/testing` adds `eventStoreContract`, `eventStoreUnitOfWorkContract` (nothing is
651
+ published for an append rolled back), `givenEvents` (given events, when a command, then events,
652
+ without a database) and `expectSnapshotEquivalence`. The other aggregates of a context keep
653
+ their state in tables: nothing changes for a project that does not opt in.
654
+
655
+ **An event store on PostgreSQL through every ORM adapter.** One implementation in the kernel
656
+ (`PostgresEventStore`, with the snapshot, checkpoint and data-subject key stores), run by
657
+ `createKnexEventSourcing`, `createDrizzleEventSourcing`, `createPrismaEventSourcing` and
658
+ `createTypeOrmEventSourcing`, each held to `eventStoreContract` and to
659
+ `eventStoreUnitOfWorkContract`. The tables come with the migrations of the project:
660
+ `createEventStoreTables` (knex), `eventStoreTables` (drizzle-kit), the models of
661
+ `prisma/event-store.postgresql.prisma`, `eventStoreEntities` (TypeORM), or the statements of
662
+ `eventStoreTablesSql`. An append locks the head of the store until its transaction ends, so
663
+ positions follow the commits without gaps; two appends never run side by side.
664
+
665
+ **Projections fed by the event store.** `@Projection(name, { feed: 'event-store' })` is applied
666
+ by a `CatchUpSubscription` with a stored checkpoint (one batch and its checkpoint per unit of
667
+ work), never by the dispatcher, and rebuilt from position zero by
668
+ `EventStoreProjectionRebuild` into a copy that takes the place of the table.
669
+
670
+ **Five rules for event-sourced aggregates**, in `ddd-ts-kit check` and in the ESLint plugin:
671
+ `event-sourcing/state-in-apply` (the state changes only in apply methods, which record nothing),
672
+ `event-sourcing/past-tense`, `event-sourcing/store-behind-repository` (the domain and the use
673
+ cases never read the store), `event-sourcing/projection-never-appends` (no projection names the
674
+ event store; one declared `{ feed: 'event-store' }` names no repository either) and
675
+ `event-sourcing/upcaster-required`: the data of a stored event is a named type declared in its
676
+ codec file with every named type it reaches, a version above 1 has an upcaster from each
677
+ version below, and the shape of each version is recorded by `ddd-ts-kit docs --stored-events`
678
+ in `stored-events.lock.json`, so a shape changed in place is refused. `ddd-ts-kit docs --check`
679
+ (`npm run docs:check` in the generated pipeline) fails while a declared version is not
680
+ recorded. The rules judge event-sourced code, the kernel's event store and projections declared
681
+ `{ feed: 'event-store' }`, all new in this release: a project that has none sees no new finding,
682
+ and a projection fed by the dispatcher may still name a repository port.
683
+
684
+ **`ddd-ts-kit generate aggregate <context> <Name> --event-sourced`** writes the aggregate with
685
+ its birth event (`<Name>Created`) and its apply method, the codec of its stored events
686
+ (`infrastructure/persistence/<name>-events.mapper.ts`), a builder that replays a history and a
687
+ given-events spec; `generate domain-event` on such an aggregate adds the event to its union, to
688
+ `appliers()` with an empty apply method, and to the codec. `CONTEXT.md`, `describe` and `list`
689
+ mark the event-sourced aggregates. `ddd-ts-kit docs --stored-events` records the stored shapes.
690
+ Like every reaction since 0.7.0, `appliers()` and the codec read the name an event declares
691
+ once, `[LedgerCredited.eventName]: ...`; the checker and `docs --stored-events` resolve that key.
692
+
693
+ **Documentation.** A concept page, [Event sourcing, per aggregate](docs/concepts/event-sourcing.md):
694
+ when to choose it and when not, what it costs, crypto-shredding, projections fed by the store,
695
+ and how to move an aggregate to it and back. The example keeps the attendance record of a
696
+ patient as its events (scheduling), with a history rebuilt from the store and the erasure of a
697
+ patient (`npm run erase:patient`: the key destroyed, the projections fed by the store rebuilt);
698
+ its only change of
699
+ HTTP contract is the additive route `GET /practices/{practiceId}/patients/{patientId}/attendance`.
700
+ Plugin 0.13.0: the persistence, testing and scaffold skills say how.
701
+
702
+ ## 0.9.0
703
+
704
+ **Files live in a storage, never in the database.** The kernel gains the ports `FileStorage`
705
+ (streams in and out, `stat`, `delete`, `copy`, `move`, `list`, short signed download and upload
706
+ links), `FileKeys` (keys generated on the server, under the tenant when there is one) and
707
+ `FileScanner` (an antivirus or content check before a file is accepted), the reference an
708
+ aggregate keeps, `StoredFile` (key, size, media type, checksum), and `FileStaging`, which stages a
709
+ file, moves it to its key as the last step of the transaction that records it
710
+ (`promoteOnCommit`, on the new `atCommit` step of the unit of work, undone on a rollback) and
711
+ purges the staged files, every one an orphan since no committed row points into `staging/`. A
712
+ command carries a client's file as a `FileUpload`, which `fileUploadsMiddleware` stages after
713
+ the permission and before the unit of work, so no transaction waits on an upload. A direct
714
+ upload is confirmed from the `PendingUpload` grant the server kept (`uploadUrl` answers the link
715
+ and the grant, `confirm(pending)` checks the size cap it carries), and the upload links of the
716
+ storages the application serves enforce that cap. Media types are checked on the bytes.
717
+ `InMemoryFileStorage` and `LocalFileStorage` pass the new `fileStorageContract` of
718
+ `@ddd-ts-kit/testing/contracts`; a signed download link is an attachment on every connector.
719
+
720
+ **`@ddd-ts-kit/s3`, the connector of a deployment.** `S3FileStorage` holds files in a bucket of
721
+ any provider of the S3 protocol (AWS, MinIO, OVHcloud, Scaleway, Cloudflare R2): multipart
722
+ uploads streamed part by part, presigned links whose upload signature covers the media type,
723
+ copy and move by the service. The AWS SDK v3 packages are optional peers. The contract runs on
724
+ MinIO in the integration job (`pgsty/minio`, pinned by digest: the upstream project no longer
725
+ publishes images).
726
+
727
+ **A controller declares the file it receives.** `upload: { field, maxBytes, mediaTypes }` on
728
+ `@Controller` puts the multipart form, the size and the media types in the OpenAPI contract
729
+ (with the 413, 415 and 422 refusals) and streams the file to `handle` as `upload`, checked on
730
+ its way: `multipartUploads()` of the new `@ddd-ts-kit/http/uploads` entry reads it (busboy, an
731
+ optional peer); a client that drops the connection part-way fails the file stream, so nothing
732
+ waits on it. `FILE_TOO_LARGE` answers 413, `UNSUPPORTED_MEDIA_TYPE` 415 and `FILE_NOT_FOUND`
733
+ 404 by default. `signedFileRoutes(storage)` answers the signed links of the local and in-memory
734
+ storages, and a handler may answer a stream.
735
+
736
+ **A generated project keeps files, from its first day.** `init --storage local|s3|none` (new key
737
+ `stack.storage`, `local` in what `init` writes, `none` when a config does not say it, so an
738
+ existing project changes nothing) writes `platform/files/` and `platform/config/read-files.ts`
739
+ (with `local`, `FILES_PUBLIC_URL` and `FILES_SIGNING_SECRET` are required in production, even
740
+ before the first file arrives):
741
+ a local directory in development, a bucket of the S3 protocol with a MinIO service in
742
+ `docker-compose.yml` for `s3`, the memory in the tests; the ports bound, the hourly purge of
743
+ staged files, the routes that answer the links of a served storage and the upload reader.
744
+ `generate file-upload <context> <UseCase> --path ...` writes the controller that declares the
745
+ file, the command that carries it as a `FileUpload`, the handler that records and promotes it,
746
+ and their specs.
747
+ Two rules: `persistence/no-file-content` (no binary or base64 column in a schema or a
748
+ migration) and `files/port-not-connector` (the model and the use cases name the port, never a
749
+ connector); `@ddd-ts-kit/s3` is refused in `domain/` and `application/`. To add files to an
750
+ existing project: `npx ddd-ts-kit init --force --storage local`.
751
+ `FileStaging.deleteAfterCommit` deletes, once committed, a file an aggregate no longer
752
+ references, and the refusals of a file are codes of the kit for `errors/known-code`.
753
+
754
+ **The example keeps the documents of appointments.** A practice attaches a document (`POST
755
+ /appointments/{appointmentId}/document`, multipart, at most 10 MiB of PDF, PNG or JPEG checked on
756
+ its bytes), reads it through a link of five minutes (`GET`, every read recorded in the audit
757
+ trail) and removes it (`DELETE`); the contract only gains these three operations. The local
758
+ directory serves development and the memory the tests; MinIO waits behind the `files` profile
759
+ of the compose stack.
760
+
761
+ ## 0.8.0
762
+
763
+ **The tenant travels with every unit of work** ([Multitenancy](docs/concepts/multitenancy.md)).
764
+
765
+ - `IntegrationEvent.tenantId`: `scopedPublisher` stamps the tenant of the work on every event
766
+ published without one, `executionContextOfEvent` runs the consumer in it, and the four brokers
767
+ carry it in the envelope and in a header (`x-tenant-id`; `tenant-id` for Redis Streams). The
768
+ outbox keeps it where asked: `createOutboxTables(knex, { tenant: true })` or
769
+ `addOutboxTenantColumn` with `KnexOutboxStore`'s `tenant: true`, `tenantOutboxTable()` for
770
+ Drizzle. Without them nothing changes: the tenant is not stored, as before.
771
+ - Jobs state `tenancy: 'global'` (the default, as before) or `'per-tenant'`: the in-process
772
+ scheduler, given a `TenantDirectory` (`tenants`), runs a per-tenant job once per tenant, each
773
+ run in that tenant's scope, and `JobExecution.tenantId` says which.
774
+ - One grammar for a tenant id, `isTenantId` (letters, digits, `_`, `-`, at most 63): the edge
775
+ answers 404 to any other, a broker treats an envelope carrying one as poison, and
776
+ `executionContext` throws `InvalidTenantIdError`. A project whose tenant ids hold other
777
+ characters (a `.`, a `:`) must map them before upgrading.
778
+ - Kernel: `tenantReachOf`, `tenantsOf`, `isTenantMember`, `tenantKey`, `tenantCacheKey`,
779
+ `TenantDirectory` and `InMemoryTenantDirectory`, and `acrossTenants`, the explicit elevation of
780
+ platform staff over tenants, refused without a reason and written to the audit trail before
781
+ the work runs (`ExecutionContext.elevation`, logged as `elevation`).
782
+ - `@ddd-ts-kit/testing`: `eventBusContract` checks that the tenant of an envelope arrives intact;
783
+ `outboxStoreContract` does with `carriesTenant: true`.
784
+
785
+ **Tenant isolation, four strategies, proven by one contract.**
786
+
787
+ - Shared tables: `tenantTable`, `ownTenantTable` and `whereTenant` (Knex), `tenantCondition` and
788
+ `ownTenantCondition` (Drizzle): reads reach the tenant of the work or the tenants of an
789
+ elevation, writes the one tenant of the work. The read model stores of both take `tenant`, and
790
+ `TenantAuditReader` (`@ddd-ts-kit/audit`) confines every search of the trail to the tenant.
791
+ - Row-level security on PostgreSQL, the second line: `enableTenantIsolation` and
792
+ `tenantIsolationStatements` (Knex), `tenantPolicies` and `forceRowLevelSecurity` (Drizzle), and
793
+ `tenantSession(scope)`, the new `begin` option of `createKnexUnitOfWork` and
794
+ `createDrizzleUnitOfWork`, which sets the tenant of each transaction only. One policy per
795
+ command: an elevated update or delete still touches only the tenant of the work.
796
+ - Schema per tenant (Knex): `tenantSchemaOf`, `tenantSchemaTable`, `migrateTenantSchema` with
797
+ `tenantOfMigration`, `tenantSearchPath`. Database per tenant (Knex): `TenantDatabases` (one
798
+ bounded pool per tenant held by a lease, at most `maxTenants` instances closing ones included,
799
+ an idle one closed first and work waiting rather than exceeding the bound),
800
+ `createTenantRoutedUnitOfWork` (statements inside the unit of work only, one tenant per
801
+ transaction), `migrateEveryTenant`.
802
+ - `@ddd-ts-kit/testing`: `tenantIsolationContract`, run against every strategy above on PGlite
803
+ and PostgreSQL; `asTenant(scope, tenantId, work)` and `anExecutionContext().elevatedOver(...)`.
804
+ - Prisma and TypeORM get the `tenant_id` column and the kernel helpers, applied by hand; no
805
+ helper, no row-level security, no contract run yet.
806
+
807
+ **The tenant of a request is resolved at the edge** (`@ddd-ts-kit/http`): `resolveTenant` with
808
+ `tenantFromSubdomain`, `tenantFromHeader`, `tenantFromPath`, `tenantFromClaim` or `firstTenantOf`;
809
+ the pipeline answers 403 to an authenticated caller that does not belong to the tenant named,
810
+ before any guard or handler. The guard `TenantMember` keeps routes for the members of the tenant
811
+ of the request. Without `resolveTenant` the tenant is the actor's, as before.
812
+
813
+ **`init --tenancy shared-table`** (Knex or Drizzle) records `"stack.tenancy"` and generates every
814
+ repository confined to the tenant through the helpers above, the outbox keeping the tenant of each
815
+ event, the search of the audit trail confined to the tenant of the work by `TenantAuditReader`; the new rule `tenancy/scoped-statement` reports a statement of a context that reaches a table
816
+ straight from the executor. `init` refuses the strategy with Prisma, TypeORM, `custom` or no ORM,
817
+ and refuses row-level security, schema per tenant and database per tenant, which are wired by hand
818
+ for now. `generate repository --tenant-scoped` writes the same helpers. A config without the key is
819
+ `none`: nothing changes for an existing project.
820
+
821
+ **Access control: a decision guide, scopes, and the refusals at the edge in the audit trail.**
822
+
823
+ - [Choosing access control](docs/guides/choosing-access-control.md): five questions that pick
824
+ roles, attribute policies or relationships (Zanzibar), a worked preset for each (typed
825
+ permissions and roles, a policy on one element, a scoped search), the combined model of the
826
+ example, and where relationships are evaluated.
827
+ - `Session.scopes`, filled by the JWT and Keycloak authenticators from `scope` (or `scp`);
828
+ `hasScope`, and `SessionScopes` to declare the vocabulary of a project; the guard `HasScope`
829
+ (`@ddd-ts-kit/http`) refuses a request whose session lacks a scope named.
830
+ - A request refused by a guard (`HasRole`, `HasScope`, `TenantMember`, a tenant the caller does
831
+ not belong to) is reported to the new `onGuardRefusal` of the HTTP pipeline, and
832
+ `auditGuardRefusals` (`@ddd-ts-kit/audit`) records it as a denied decision, except for a caller
833
+ without a credential, so that unauthenticated traffic cannot write to the trail without bound.
834
+ It is part of `--audit on` (`stack.audit`), with no key of its own. A generated project
835
+ with the audit trail wires it (`create-audit.ts`, `http-boundary.ts`, `compose-application.ts`);
836
+ a project initialised before adds `refusals: auditGuardRefusals(trail, { clock, ids })` to the
837
+ call of `httpBoundary` and `onGuardRefusal: refusals` to what it answers, as `init --force` writes.
838
+
839
+ **`@ddd-ts-kit/openfga`, a new package: relationships on OpenFGA.** `OpenFgaRelationTupleStore`
840
+ and `OpenFgaAuthorizer` answer the `RelationTupleStore` and `Authorizer` ports of
841
+ `@ddd-ts-kit/auth` on an OpenFGA server (OpenFGA 1.10 or later), with the mapping and the denial
842
+ codes of `RelationBasedAuthorizer`. The authorization model is generated from the relationship
843
+ schema (`authorizationModelOf`), written when it differs (`ensureAuthorizationModel`) and
844
+ compared (`modelDrift`): the schema stays the one source. `@openfga/sdk` 0.9.7 is an optional
845
+ peer, imported by the `/sdk` entry (`openFgaClient`). Both adapters pass the contract suites of
846
+ the in-process ones, in memory and against `openfga/openfga` v1.22.0 in the integration job. A
847
+ `ListObjects` answer as long as the server's cap (`listObjectsLimit`, 1000 by default) is refused
848
+ with `ReverseExpansionLimitError`, since the server cuts a longer list without an error.
849
+ [Authorization engines](docs/tools/authorization-engines.md) says when to move from the
850
+ PostgreSQL tuple store to OpenFGA.
851
+
852
+ ## 0.7.0
853
+
854
+ **A value object throws its refusals.** `init` writes `"stack.valueObjects": "throwing"`:
855
+ `generate value-object` writes a `create` that returns the value object or throws the refusal
856
+ built by the static factory of its concept (`throw PersonError.invalidEmail(raw)`), as the rest
857
+ of the domain refuses, and a spec that asserts it with `expectRefusal(() => Email.create(raw),
858
+ code)`; a caller no longer unwraps anything. `Result`, `ok`, `err` and `unwrap` stay in the
859
+ kernel: `--value-objects result`, or a config without the key, keeps the form of 0.5, so an
860
+ existing project upgrades without a change. `ddd-ts-kit value-objects` moves a project from one
861
+ to the other: it rewrites the factories, removes every `unwrap(..)` of them, turns the assertions
862
+ on a `Result` into assertions on the factory, tidies the imports, sets the key, and prints what
863
+ is left to a person.
864
+
865
+ **Closed vocabularies are declared once.** A status, a kind or a role is an object `as const`
866
+ and the union type of the same name (`AppointmentStatus.NoShow`), not an `enum`: Node's type
867
+ stripping and `erasableSyntaxOnly` refuse an `enum`, measured on Node 24. `generate vocabulary
868
+ <context> <Name> --value <value>...` writes one under `domain/vocabularies/`, a new canonical
869
+ role, and adds the values a file does not hold yet. `init` writes the roles a credential carries
870
+ as the vocabulary `Role` of the platform (`<platformDir>/auth/role.ts`), read by the grants
871
+ (`[Role.Admin]: ['*']`, `systemRole: Role.System`) and the attribute policies;
872
+ `generate route-group --role <role>` writes `HasRole(Role.<Key>)` and adds the role to it; the
873
+ grant `generate job` gives a system actor names the permission through its set
874
+ (`billingPermissions['invoice.cancel']`) instead of its id.
875
+
876
+ **A domain event names itself once.** `generate domain-event` writes `static readonly eventName =
877
+ 'billing.invoice.issued'` and `readonly name = InvoiceIssued.eventName`; the reactions
878
+ `generate event-handler` and `generate integration-event` write read it,
879
+ `@DomainEventHandler(InvoiceIssued, { name: InvoiceIssued.eventName })` and
880
+ `domainEvents.on(InvoiceIssued.eventName, ..)` in a module file, so a renamed event cannot leave a
881
+ handler on the old name. An integration event handler reads the type it handles from the
882
+ declaration of its upstream, `static readonly eventType = invoiceIssuedV1.type`, which the event
883
+ catalog script resolves. `events/name-format` judges `eventName`, and `declarations/role-declared`
884
+ accepts `{ name: <Event>.eventName }` when `<Event>` is the class the decorator is given. An event
885
+ written before, `readonly name = '..'`, and a handler that writes the name out are still read and
886
+ compared; a reaction generated for such an event writes the name out, so it compiles.
887
+
888
+ **Two rules against magic values.** `language/declared-vocabulary` (checker and ESLint) reports a
889
+ string literal whose value a vocabulary of the project declares, an object `as const` with the
890
+ union type of its name or a permission set of `definePermissions`, and names the key to write
891
+ instead: a vocabulary of a context in that context, the one of the platform in the platform and
892
+ in the route groups, a permission id and a vocabulary of the shared kernel everywhere; tests,
893
+ published languages and the relationship schema are not judged. A vocabulary is parsed, so a
894
+ documented entry, double quotes and `as const satisfies ..` are read. `clean/no-magic-number`
895
+ (ESLint) reports a number in `domain/` and `application/` other than 0, 1, -1, an index, a type or
896
+ the initializer of a module constant or a `static readonly`; the `allow` option changes the list.
897
+ Both rules hold under `"stack.literals": "named"`, which `init` writes (`--literals free` leaves
898
+ them out). A config without the key reads as `free`: an existing project upgrades without a
899
+ change, including the grants `generate job` wrote by their permission id, which a job generated
900
+ again recognises instead of granting twice. To turn the rules on, set the key and follow the fix
901
+ each finding gives ([Configuration](docs/CONFIG.md#stack)). The example declares 8 vocabularies and
902
+ replaced 227 raw values and 8 numbers, its OpenAPI contract unchanged.
903
+
904
+ **Every package carries the MIT notice.** Each published package now holds the `LICENSE` of the
905
+ repository next to its `dist/`, and `npm run package:check` refuses a tarball without it, or with
906
+ another text. Earlier versions were published without it.
907
+
908
+ ## 0.6.0
909
+
910
+ **A typed client of the contract, for the callers of the API.** `npx ddd-ts-kit sdk` (or
911
+ `init --sdk`) generates one client per document of the OpenAPI contract, a workspace package
912
+ under `clients/<document>` with no runtime dependency: each operation answers its outcome typed
913
+ by status, a refusal typed by the error codes its contract declares (`problem.code` is
914
+ `'UNKNOWN_TIME_ZONE' | 'VALIDATION_FAILED'`, not `string`), credentials are given per security
915
+ scheme, and `unwrap` with `isProblem` serve the code that prefers exceptions. A status the
916
+ contract does not declare throws `UnexpectedResponseError`, which reads the problem document of
917
+ a fault of the server: its `code` (`INTERNAL_ERROR`) and its `requestId`. An operation id that
918
+ is a reserved word or a name of the client is renamed (`deleteOperation`, `send2`). The command
919
+ records `"sdk"` in the config (`sdk.name`, `sdk.access`, see `docs/CONFIG.md`), tells the agents
920
+ in CLAUDE.md never to edit a client, and wires the project: `docs:generate` regenerates the
921
+ clients, `docs:check` fails while one differs from the contract, `ci:build` builds them, so the
922
+ GitHub and GitLab pipelines check them; publishing stays a manual step. Under npm, while
923
+ `package-lock.json` does not list a client, the command says to run `npm install` and
924
+ `sdk --check` fails, before `npm ci` refuses the lock in the pipeline. openapi-typescript with
925
+ openapi-fetch, hey-api and orval were measured on the example contract first: none reads the
926
+ error codes, and the first refuses TypeScript 6 (`docs/tools/client-sdk.md`). A project without
927
+ the key is unchanged: only with it does the ESLint preset leave out `clients/*/src/**`, and the
928
+ command add `!**/clients/*/src` to Biome.
929
+
930
+ ## 0.5.0
931
+
932
+ **Migrations are named by a timestamp and what they do.** The generated `drizzle.config.ts`
933
+ sets `migrations: { prefix: 'timestamp' }`, and every `db:generate` requires a name:
934
+ `npm run db:generate add-invoices` writes `migrations/20261012093000_add-invoices.sql` (Drizzle),
935
+ `migrations/20261012093000_add-invoices.js` (Knex, from its ES module stub now set in
936
+ `knexfile.js`; the former default stub wrote CommonJS, which an ES module project cannot load),
937
+ `prisma/migrations/20261012093000_add_invoices/` (Prisma); TypeORM takes the path
938
+ (`npm run db:generate migrations/add-invoices`) and fixes its own form,
939
+ `<milliseconds>-add-invoices.ts`. The first Knex migration of a new project is
940
+ `00000000000000_initial.js`. Timestamps keep two branches from writing the same file, but
941
+ drizzle-kit's journal still conflicts, and its migrator applies only the entries newer than the
942
+ last one a database recorded: a migration merged after a later one was deployed is skipped
943
+ without an error (measured). The new rule `persistence/migration-history` reports a migration
944
+ without its number, two sharing one, a journal that does not match the files, entries out of
945
+ the order they were generated in, and two snapshots generated from the same parent; the merge
946
+ procedure is in `docs/tools/persistence.md`. Projects initialised before keep their
947
+ `0001_initial.js`: an applied migration is never renamed.
948
+
949
+ **The generated pipelines use free tools only.** CodeQL and dependency review (free on public
950
+ GitHub repositories, paid on private ones) and GitLab's SAST, Secret Detection (findings in a
951
+ merge request on Ultimate only) and Dependency Scanning (Ultimate) are no longer written by
952
+ default: `init --ci-extras codeql,dependency-review,gitlab-sast,gitlab-secret-detection,gitlab-dependency-scanning`
953
+ adds the ones a team wants, recorded as `ci.extras` in the config. `npm audit` and gitleaks
954
+ still block a vulnerable dependency or a committed secret on every plan. The table of every
955
+ tool, its class and its source is in `pipeline/README.md`. To keep a removed job in a project
956
+ initialised before: `init --force --ci-extras <names>`.
957
+
958
+ **The contracts are YAML by default, and the OpenAPI contract can be split.**
959
+ `npm run docs:generate` writes `openapi/openapi.yaml` and `docs/integration/asyncapi.yaml`
960
+ (`"openapi": { "format": "json" }` keeps JSON), with a deterministic YAML writer of the CLI
961
+ (`ddd-ts-kit/dist/contracts.js`) that the scripts of the project import. `openapi.documents`
962
+ splits the contract by route groups, contexts and tags into self-contained documents,
963
+ `openapi/<name>.yaml`; a route in no document stops the generation; `/docs` offers a selector
964
+ between them. `@ddd-ts-kit/http` adds `routesByDocument`, `openApiDocumentsRoutes` and the
965
+ `groups` of a controller route. A project initialised before: compare `scripts/openapi.ts`,
966
+ `scripts/events.ts` and `src/platform/http/with-documentation.ts` with what `init --force`
967
+ writes, run `npx ddd-ts-kit docs` (it writes `src/platform/http/openapi-documents.ts`), then
968
+ `npm run docs:generate`, which replaces the JSON contracts.
969
+
970
+ **Onboarding.** The root README starts with the recommended steps; `init` ends with the next
971
+ step of the method, the README and `CLAUDE.md` it writes list the steps, `status` points at
972
+ step 1 when there is no plan, and the `start` and `bootstrap` skills say plainly when a step is
973
+ skipped. A new guide, `docs/guides/commands.md`, gives the command or skill for each moment of a
974
+ project and the reference of every command and flag, generated from the help of the CLI and
975
+ held to it by a test.
976
+
977
+ ## 0.4.1
978
+
979
+ - A project generated on a stack that tests against PGlite (knex, prisma, typeorm, audit) failed
980
+ `npm install` with `ERESOLVE`: `@electric-sql/pglite-socket` 0.2.12 requires exactly
981
+ `@electric-sql/pglite` 0.5.9, and both were written as caret ranges. The pair is now pinned to
982
+ exact, matching versions, and Dependabot and Renovate move it as one group. The workspace root
983
+ declares the pair as well: drizzle-orm and the copy of the example used by the conformance suite
984
+ resolve them from there.
985
+
986
+ ## 0.4.0
987
+
988
+ **A route is a controller class with one method.** One class per route; its decorator is the
989
+ single statement of the route, and the compiler holds `handle` to it:
990
+
991
+ ```ts
992
+ @RouteGroup({ prefix: '/appointments', tags: ['scheduling'], guards: [Authenticated], errors: ['FORBIDDEN'] })
993
+ export class AppointmentsRoutes {}
994
+
995
+ @Controller({
996
+ group: AppointmentsRoutes,
997
+ method: 'POST',
998
+ path: '/',
999
+ summary: 'Book appointment',
1000
+ body,
1001
+ response: { status: 201, body: accepted },
1002
+ errors: ['SLOT_TAKEN'],
1003
+ })
1004
+ export class BookAppointmentController {
1005
+ constructor(
1006
+ private readonly commands: CommandBus,
1007
+ private readonly mapper: BookAppointmentMapper,
1008
+ ) {}
1009
+
1010
+ async handle(request: HttpRequest<{ body: BookAppointmentBody }>): Promise<BookAppointmentAccepted> {
1011
+ return { id: await this.commands.execute(this.mapper.map(request)) };
1012
+ }
1013
+ }
1014
+ ```
1015
+
1016
+ - `@ddd-ts-kit/http`: `Controller`, `RouteGroup`, `Guard`, `Authenticated`, `Public`, `HasRole`,
1017
+ `RequestMapper`, `Serializer`, `HttpRequest`, `controllerRoutes`, `controllerRoute`. A
1018
+ controller becomes a `Route`: the adapters, validation, problem documents, the error mapper
1019
+ and tracing are unchanged. `defineRoute` stays supported, and both forms run in one project.
1020
+ - Route groups carry a prefix, tags, guards and error codes, and a child group inherits. A
1021
+ guard is a control at the edge; permissions stay on the use cases (`@Requires`). A controller
1022
+ with no group and no guard is refused: `guards: [Public]` says a route is open.
1023
+ - `"stack.routes": "controllers" | "functions"` in the config, `init --routes`. A new project
1024
+ writes controllers; a config without the key is read as `functions` and nothing changes for
1025
+ it. `generate route` follows the key (`--group`, `--mapper`, `--serializer`), `generate
1026
+ controller` always writes a class, `generate route-group` and `generate guard` are new, and
1027
+ `ddd-ts-kit controllers [<context>] [--dry-run]` turns the function routes of a context into
1028
+ controllers, files moved and imports rewritten.
1029
+ - `presentation/http/` has one folder per route, `<resource>/<use-case>/`, with the controller,
1030
+ its schemas (`*.params.ts`, `*.body.ts`, `*.accepted.ts`, `*.answer.ts`, `*.line.ts`), its
1031
+ `*.mapper.ts` and `*.serializer.ts`; groups are `<name>.routes.ts`, guards
1032
+ `guards/*.guard.ts`, shared schemas `shared/`.
1033
+ - Controllers, guards, mappers and serializers are declared classes: `Edge` in
1034
+ `@ddd-ts-kit/kernel/declarations`, `DeclaredEdges` in `@ddd-ts-kit/di`, and discovery reads
1035
+ `presentation/`. The generated file of a context registers what each one is given, and a
1036
+ declared context lists no route.
1037
+ - Rules: `presentation/controller-shape`, `presentation/controller-delegates`,
1038
+ `presentation/guard-at-the-edge`; `authz/declared` and `errors/known-code` read controllers.
1039
+ - `describe` and the reference tables of `CONTEXT.md` list each route with its group and its
1040
+ guards, next to its use case and its permission.
1041
+
1042
+ **To do when upgrading.** Nothing for a project that keeps its function routes. A project
1043
+ initialised before this version that writes its first controller adds one line to
1044
+ `compose-application.ts`, which `ddd-ts-kit controllers` prints.
1045
+
1046
+ **The `detail` of a problem no longer quotes a private fact.** The message of a refusal built
1047
+ by a factory is answered as `detail` unless it quotes a fact the factory did not expose; then
1048
+ the client reads a sentence made from the code (`INVALID_EMAIL` gives `Invalid email.`), and the
1049
+ message stays in logs and error reports. A client that parsed such a `detail` reads the `code`
1050
+ and the exposed `facts` instead. `RequestContext` gains an optional `rawHeaders`.
1051
+
1052
+ ## 0.3.0
1053
+
1054
+ **A domain error is built by a static factory named in the words of the business.** One error
1055
+ class per concept of the language, one factory per refusal, instead of one class per error
1056
+ built with `new`:
1057
+
1058
+ ```ts
1059
+ // domain/errors/appointment.error.ts
1060
+ export class AppointmentError extends ConceptError {
1061
+ static slotTaken(calendarId: string, startsAt: Date) {
1062
+ return AppointmentError.refusal(
1063
+ 'SLOT_TAKEN',
1064
+ `The slot starting at ${startsAt.toISOString()} on calendar ${calendarId} is already held.`,
1065
+ { calendarId, startsAt },
1066
+ ).exposing('startsAt');
1067
+ }
1068
+ }
1069
+
1070
+ throw AppointmentError.slotTaken(calendar.id, slot.startsAt);
1071
+ ```
1072
+
1073
+ - `ConceptError` is a new export of `@ddd-ts-kit/kernel`, a `DomainError`. Its constructor is
1074
+ protected and a subclass declares none: a refusal exists only through a factory, which calls
1075
+ the protected `refusal(code, message, facts?)`. The codes of a class are a closed vocabulary
1076
+ the compiler derives from its factories: `CodeOf<typeof AppointmentError>`,
1077
+ `RefusalOf<typeof AppointmentError, 'SLOT_TAKEN'>`, and `AppointmentError.is(error, code?)`
1078
+ narrows to a union a `switch` on `error.code` exhausts, each case with its facts.
1079
+ `isRefusal(error, code?)` knows a refusal by its code whatever its class.
1080
+ - The facts of a refusal travel with it, as data: `error.facts`. They are private. A factory
1081
+ names the ones a client may read, `.exposing('startsAt')`, and `@ddd-ts-kit/http` writes
1082
+ those, and no other, under `facts` in the problem document; the `Problem` schema of the
1083
+ OpenAPI document gains that optional member, which is the one change of a regenerated
1084
+ contract. `DomainError.toJSON()` answers `{ name, code, message }` and the exposed facts,
1085
+ never the stack or the cause. The audit trail, the spans and the error reports are unchanged:
1086
+ they key on the code.
1087
+ - `@ddd-ts-kit/testing`: `expectRefusal(subject, code, facts?)` on an action, a `Result` or a
1088
+ caught error; `thenError(code, facts)` in a scenario; `expectProblem(response, { status,
1089
+ code, facts })`.
1090
+ - `ddd-ts-kit generate error <context> <Concept> <refusal>` creates the error class of the
1091
+ concept when it is absent and adds a factory when it is there, with its case in the spec;
1092
+ run again, it changes nothing. The concept is a term of the glossary. It refuses a name such
1093
+ as `create`, a code that says something else than the name, and a code already set in the
1094
+ context.
1095
+ - Five rules, under `"stack.refusals": "factories"` only. ESLint:
1096
+ `errors/through-factory` (no `new` of a class of `domain/errors/`),
1097
+ `errors/constructor-not-public`, `errors/factory-name` (a business phrase, and a code that
1098
+ says the same words: `slotTaken` setting `BOOKING_FAILED` is reported). `ddd-ts-kit check`:
1099
+ `errors/code-unique` (one refusal per code in a context) and `errors/known-code` (a route
1100
+ declares in `errors` only codes that exist). `language/glossary-term` now holds the name of
1101
+ an error class too.
1102
+ - `CONTEXT.md` and `ddd-ts-kit describe` list the errors of a context: class, refusal, code
1103
+ and exposed facts.
1104
+
1105
+ **Nothing to do to upgrade.** `class SlotTakenError extends DomainError { readonly code =
1106
+ 'SLOT_TAKEN' }` and `new SlotTakenError(..)` keep working: `DomainError` is unchanged but for
1107
+ `toJSON`. A config without `stack.refusals` is read as `classes`: `generate domain-error` still
1108
+ writes one class per error there, and no `errors/*` rule reports. `init` writes
1109
+ `"stack.refusals": "factories"` in a new project (`--refusals classes` for the other form),
1110
+ where `generate domain-error` points to `generate error`.
1111
+
1112
+ **To move a project:** `npx ddd-ts-kit error-factories --plan` writes
1113
+ `error-factories.plan.json` with the concept it proposes for each class (the aggregate its name
1114
+ starts or ends with); give every entry its concept, then `npx ddd-ts-kit error-factories
1115
+ --apply` writes the classes of the concepts with a factory per error (same parameters, same
1116
+ message, the plain parameters as facts), merges the specs, rewrites every `new`, `thenError`,
1117
+ `toThrow`, `expect.any`, `toBeInstanceOf`, `instanceof`, import and type, and sets the key. Then
1118
+ `npm run format`. Left to a person, and listed by the command: which facts to expose, a
1119
+ property of an error that became a fact (`error.personId` is `error.facts.personId`), a class
1120
+ with members of its own.
1121
+
1122
+ Also:
1123
+
1124
+ - The `jest.config.js` that `init` writes compiled legacy decorators in every stack. It now
1125
+ follows the stack: `decoratorVersion: '2022-03'` where nothing needs the legacy ones,
1126
+ `legacyDecorator` and `decoratorMetadata` with class-validator or NestJS.
1127
+ - The example application imports with `import type` every port and kernel type it only uses
1128
+ as a type, as the templates do; the generated declarations import the values.
1129
+
1130
+ ## 0.2.0
1131
+
1132
+ **A class states its dependencies once, in its constructor.** In the declared style a
1133
+ decorator no longer repeats what the constructor takes:
1134
+
1135
+ ```ts
1136
+ @CommandHandler(RegisterPersonCommand)
1137
+ export class RegisterPersonHandler implements CommandHandler<RegisterPersonCommand> {
1138
+ constructor(
1139
+ private readonly personRepository: PersonRepository,
1140
+ private readonly clock: Clock,
1141
+ ) {}
1142
+ }
1143
+ ```
1144
+
1145
+ - The constructor signature is the single statement of what a declared class depends on, for
1146
+ every declaring decorator: `@CommandHandler`, `@QueryHandler`, `@DomainEventHandler`,
1147
+ `@IntegrationEventHandler`, `@Scheduled`, `@Projection`, `@Repository`, `@Gateway`,
1148
+ `@Adapter`, `@Policy`. Each parameter is typed by one class, which is its injection token.
1149
+ - No compiler metadata. `emitDecoratorMetadata` exists only with legacy decorators compiled by
1150
+ `tsc` or swc: it is absent with standard decorators and under `tsx` and esbuild, and it is
1151
+ `Object` for a port imported with `import type`. `ddd-ts-kit docs`, every `generate` and
1152
+ every start of `npm run dev` read the signatures from the sources and write
1153
+ `injected(Class, [Tokens])` statements in the generated file of each context:
1154
+ `<context>.declarations.ts`, after its exports, or `<context>.dependencies.ts` under
1155
+ discovery, which `discoverContext` loads with the role files. `@ddd-ts-kit/di` injects from
1156
+ there, in both decorator modes, whoever compiles. `injected` and `injectedDependencies` are
1157
+ new exports of `@ddd-ts-kit/kernel/declarations`, `declaredTokens` of `@ddd-ts-kit/di`.
1158
+ - Three guards against a stale generated list. `declarations/barrel-current` and
1159
+ `ddd-ts-kit docs --check` fail when a constructor changed without regeneration, and now name
1160
+ the class. The `dev` script loads `ddd-ts-kit/dist/refresh-declarations.js` before the
1161
+ application, on every start and reload: a port added to a constructor is injected on the
1162
+ reload that follows the save. And `createApplication` refuses, by name, a class that takes
1163
+ parameters and has no list, or a list of another length: new `WiringError` codes
1164
+ `MISSING_DEPENDENCIES` and `DEPENDENCY_ARITY`. Nothing is injected as `undefined`.
1165
+ - New rule `declarations/constructor-injection`, in `ddd-ts-kit check` and in the ESLint
1166
+ plugin: a constructor parameter of a declared class names exactly one class. An interface, a
1167
+ type alias, a union, a primitive, an optional or defaulted parameter, a rest parameter and a
1168
+ constructor inherited from a class of a package are refused, each with its fix; a value is
1169
+ carried, as before, by an abstract class bound in the platform.
1170
+ `ports/no-concrete-dependency` now also holds the types of constructor parameters.
1171
+ - **A 0.1 project keeps working untouched.** A list written in a decorator,
1172
+ `@CommandHandler(Message, [Ports])`, is still read, still checked by the compiler and wins
1173
+ over the signature. New config key `stack.dependencies`: `signature`, which `init` writes
1174
+ (`init --dependencies listed` for the other), or `listed`, which is what a config without the
1175
+ key means and under which lists are never reported. To migrate: set
1176
+ `"dependencies": "signature"`, run `npx eslint . --fix` (the rule removes every list that
1177
+ repeats its constructor and reports the ones that differ), run `npx ddd-ts-kit docs`, and add
1178
+ `--import ddd-ts-kit/dist/refresh-declarations.js` to the `dev` script. Under discovery, add
1179
+ `src/contexts/*/*.dependencies.ts` to the entries of knip (inside the pattern of the role
1180
+ files, as `init` writes it) and to the orphan exceptions of dependency-cruiser (`ddd-ts-kit depcruise-config --write` does the second).
1181
+ - In the types, the dependency list of every decorator is optional, and a decorator without a
1182
+ list accepts any constructor: the compiler no longer reports, at the decorator, a list that
1183
+ was forgotten. The list is derived instead, and the generated `injected(...)` is checked
1184
+ against the constructor as a written list was. A declaration read with `declarationOf`
1185
+ carries `dependencies: undefined` when its decorator lists nothing, where it carried `[]`.
1186
+ - The explicit style stays explicit: `.toClass(Adapter, [Ports])` and
1187
+ `.handledBy(Handler, [Ports])` list what a class is given. NestJS and manual wiring are not
1188
+ concerned.
1189
+ - A test assembles a context from its generated file, as the composition root does; to build
1190
+ one class alone, take it from that file
1191
+ (`contextModule('billing', { IssueInvoiceHandler: billing.IssueInvoiceHandler })`).
1192
+
1193
+ **Projections are a role of the CLI.** `application/projections/*.projection.ts` joins the
1194
+ canonical tree (`tree/closed`, `naming/suffix`, `naming/one-concept-per-file`,
1195
+ `test/spec-required`), the generated barrel exports a projector and registers what its
1196
+ constructor takes, discovery, knip and dependency-cruiser know the suffix, and
1197
+ `ddd-ts-kit describe` and the reference of `CONTEXT.md` list each projection with the events it
1198
+ applies. New generator `ddd-ts-kit generate projection <context> <Name> <event-type>`
1199
+ (declared style): a projector of an integration event of the context itself or of a declared
1200
+ upstream, and its spec. A project that declared `application/projections` in
1201
+ `extraDirectories` removes the entry and its deviation, runs `ddd-ts-kit docs` (its barrels
1202
+ gain one line per projection) and gives each projection a spec, which `test/spec-required`
1203
+ now asks for; a composition root or a test that
1204
+ added projections to a context by name removes them, the barrel now brings them, and a handler
1205
+ spec gives a fake to the read model a projection of its context writes.
1206
+
1207
+ **Imports without the `.js` extension, by default.** A new project imports its own files by
1208
+ their alias and nothing else:
1209
+
1210
+ ```ts
1211
+ import { PersonRepository } from '@/contexts/identity/domain/repositories/person.repository';
1212
+ ```
1213
+
1214
+ - `importExtension` in `ddd-ts-kit.config.json` says how an import ends: `none`, which `init`
1215
+ now writes, or `js`, the previous spelling (`init --import-extension js`). Everything `init`
1216
+ and `generate` write follows it: every template, the declarations barrels, the published
1217
+ language indexes, the wiring of the composition root.
1218
+ - With `none`, the generated `tsconfig.json` resolves as a bundler does (`"module": "ESNext"`,
1219
+ `"moduleResolution": "Bundler"`, `"customConditions": ["node"]`); a file that does not exist
1220
+ is still a compile error. Node needs the extension at run time, so `ddd-ts-kit build-aliases`,
1221
+ the second step of `npm run build`, now writes the full relative path with `.js` whichever
1222
+ spelling the sources use, and the output runs under plain `node` as before.
1223
+ - New rule `imports/extension`, in `ddd-ts-kit check` and in the ESLint plugin, with a fix: an
1224
+ import of a file of the project ends as the config says. Under `none` it also refuses an
1225
+ import that names a directory: write `.../published-language/index`.
1226
+ - `imports/alias-declared` holds what derives from the key: the two resolution options of
1227
+ `tsconfig.json`, the pattern of the jest mapping, and the build step.
1228
+
1229
+ **Nothing breaks.** A config file without `importExtension`, which is every project generated by
1230
+ 0.1.x, is read as `js`: its imports, its `tsconfig.json`, its jest mapping and its build are
1231
+ judged and rewritten exactly as before, and `init` run again keeps that spelling. To move such a
1232
+ project: set the key to `none`, write the two compiler options `ddd-ts-kit check` then asks
1233
+ for, run `eslint --fix` and the formatter ([docs/CONFIG.md](docs/CONFIG.md)).
1234
+
1235
+ One behaviour of `build-aliases` is stricter in both spellings: a specifier names one emitted
1236
+ file, by the path the config's spelling gives. It used to fall back to `<specifier>.js` and to
1237
+ `<specifier>/index.js`, which no generated project relied on, since the compiler refused both
1238
+ under `NodeNext`. It now says what to write instead.
1239
+
1240
+ Also:
1241
+
1242
+ - **`init --force` keeps the config.** It used to write the config anew: the declared contexts,
1243
+ the rules, the limits and the deviations were dropped, and the composition root no longer
1244
+ assembled anything. It now changes in the config the stack choices its flags name and nothing
1245
+ else, and wires the declared contexts again into the files it rewrites.
1246
+ - `init` run again on a pnpm project no longer adds every agent permission a second time to
1247
+ `.claude/settings.json`.
1248
+ - The checker and the ESLint plugin read an import written without its extension as the same
1249
+ source file as with it, whatever dots its name holds (`invoice.aggregate`): the layer and
1250
+ context rules judge both spellings alike.
1251
+
1252
+ ## 0.1.1
1253
+
1254
+ **A project is green from its first minute.** In 0.1.0, `npx ddd-ts-kit init`, the install and
1255
+ `verify`, which is what `init` tells you to run, failed on a project nothing had been generated
1256
+ in. Fixed in the generator, and held by an end-to-end spec that runs that exact sequence on both
1257
+ package managers and on every proven stack, with no context, one, several, and a first use case:
1258
+
1259
+ - `init` writes the generated contracts as the scripts of the project write them for a project
1260
+ without a route or an integration event of its own (`openapi/openapi.json`,
1261
+ `docs/integration/asyncapi.json`, `docs/integration/event-catalog.md`): `docs:check` passes at
1262
+ once and the first commit holds the contract. A contract that is already there is left alone.
1263
+ - The dead-code gate passes on an empty project. The helper `ownPolicy`
1264
+ (`platform/resilience/own-policy.ts`) is written with the first adapter that uses it
1265
+ (`generate adapter`) instead of by `init`, where nothing imported it on tsyringe; knip is told
1266
+ what it enters a context by with the first context, and reads the fakes as entries of the test
1267
+ runner, so that it has no remark about its own configuration either.
1268
+ - A project without a context has a spec to run and passes its linter: the proof that the
1269
+ platform wires alone is written by `init` (`test/architecture/extractable.spec.ts`), which each
1270
+ context then joins, and the composition root imports `contextModule` or `discoverContext` with
1271
+ the first context instead of before it.
1272
+ - The integration run of the pipeline passes while the project has no integration spec yet.
1273
+ - Under `--assembly discovery`, a context that was just declared and has no role file yet is
1274
+ discovered as an empty context (`@ddd-ts-kit/di`): it used to stop the application, the specs
1275
+ and the documents with `DISCOVERY_FAILED`.
1276
+
1277
+ In a project created with 0.1.0, nothing has to change: these fixes are about what `init` writes.
1278
+ `generate adapter` now counts as writing a shared file for the first adapter of a project wired on
1279
+ tsyringe (`--local-only` refuses it once, naming the file).
1280
+
1281
+ Also since 0.1.0:
1282
+
1283
+ - **pnpm by default.** `init` writes a project for pnpm, pinned in `package.json` and provided by
1284
+ corepack, unless `--package-manager npm`: every script, pipeline, hook, the Dockerfile, the
1285
+ guidance and the agent permissions are said in the words of the manager chosen.
1286
+ - **`ddd-ts-kit affected`.** A pull or merge request runs the unit specs, the integration specs
1287
+ and the lint of the contexts its change reaches through the context map; the generated
1288
+ pipelines call it, and the default branch and the nightly schedule run everything.
1289
+ - The release is published through npm trusted publishing (OIDC), with provenance and without a
1290
+ stored token.
1291
+
1292
+ ## 0.1.0
1293
+
1294
+ First publication to the npm registry: the CLI `ddd-ts-kit` and the 23 packages `@ddd-ts-kit/*`.