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