ddd-ts-kit 0.16.0 → 0.17.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/README.fr.md +1595 -0
- package/README.md +424 -5
- package/dist/check/business-names/closing-parenthesis.d.ts +3 -0
- package/dist/check/business-names/closing-parenthesis.d.ts.map +1 -0
- package/dist/check/business-names/closing-parenthesis.js +16 -0
- package/dist/check/business-names/closing-parenthesis.js.map +1 -0
- package/dist/check/business-names/declared-bindings.d.ts +4 -0
- package/dist/check/business-names/declared-bindings.d.ts.map +1 -0
- package/dist/check/business-names/declared-bindings.js +41 -0
- package/dist/check/business-names/declared-bindings.js.map +1 -0
- package/dist/check/business-names/generic-words-of.d.ts +4 -0
- package/dist/check/business-names/generic-words-of.d.ts.map +1 -0
- package/dist/check/business-names/generic-words-of.js +7 -0
- package/dist/check/business-names/generic-words-of.js.map +1 -0
- package/dist/check/business-names/generic-words.d.ts +7 -0
- package/dist/check/business-names/generic-words.d.ts.map +1 -0
- package/dist/check/business-names/generic-words.js +26 -0
- package/dist/check/business-names/generic-words.js.map +1 -0
- package/dist/check/business-names/identifier-at.d.ts +3 -0
- package/dist/check/business-names/identifier-at.d.ts.map +1 -0
- package/dist/check/business-names/identifier-at.js +6 -0
- package/dist/check/business-names/identifier-at.js.map +1 -0
- package/dist/check/business-names/judges-names.d.ts +7 -0
- package/dist/check/business-names/judges-names.d.ts.map +1 -0
- package/dist/check/business-names/judges-names.js +14 -0
- package/dist/check/business-names/judges-names.js.map +1 -0
- package/dist/check/business-names/named-binding.d.ts +12 -0
- package/dist/check/business-names/named-binding.d.ts.map +1 -0
- package/dist/check/business-names/named-binding.js +2 -0
- package/dist/check/business-names/named-binding.js.map +1 -0
- package/dist/check/business-names/named-bindings.d.ts +4 -0
- package/dist/check/business-names/named-bindings.d.ts.map +1 -0
- package/dist/check/business-names/named-bindings.js +8 -0
- package/dist/check/business-names/named-bindings.js.map +1 -0
- package/dist/check/business-names/opening-bracket.d.ts +3 -0
- package/dist/check/business-names/opening-bracket.d.ts.map +1 -0
- package/dist/check/business-names/opening-bracket.js +16 -0
- package/dist/check/business-names/opening-bracket.js.map +1 -0
- package/dist/check/business-names/parameter-bindings.d.ts +4 -0
- package/dist/check/business-names/parameter-bindings.d.ts.map +1 -0
- package/dist/check/business-names/parameter-bindings.js +28 -0
- package/dist/check/business-names/parameter-bindings.js.map +1 -0
- package/dist/check/business-names/parameter-lists.d.ts +14 -0
- package/dist/check/business-names/parameter-lists.d.ts.map +1 -0
- package/dist/check/business-names/parameter-lists.js +109 -0
- package/dist/check/business-names/parameter-lists.js.map +1 -0
- package/dist/check/business-names/pattern-bindings.d.ts +7 -0
- package/dist/check/business-names/pattern-bindings.d.ts.map +1 -0
- package/dist/check/business-names/pattern-bindings.js +31 -0
- package/dist/check/business-names/pattern-bindings.js.map +1 -0
- package/dist/check/business-names/property-bindings.d.ts +7 -0
- package/dist/check/business-names/property-bindings.d.ts.map +1 -0
- package/dist/check/business-names/property-bindings.js +31 -0
- package/dist/check/business-names/property-bindings.js.map +1 -0
- package/dist/check/business-names/suggested-name.d.ts +8 -0
- package/dist/check/business-names/suggested-name.d.ts.map +1 -0
- package/dist/check/business-names/suggested-name.js +28 -0
- package/dist/check/business-names/suggested-name.js.map +1 -0
- package/dist/check/business-names/top-level-separator.d.ts +3 -0
- package/dist/check/business-names/top-level-separator.d.ts.map +1 -0
- package/dist/check/business-names/top-level-separator.js +23 -0
- package/dist/check/business-names/top-level-separator.js.map +1 -0
- package/dist/check/catalogue/architecture-rules.d.ts.map +1 -1
- package/dist/check/catalogue/architecture-rules.js +2 -0
- package/dist/check/catalogue/architecture-rules.js.map +1 -1
- package/dist/check/explain/tree-docs.d.ts.map +1 -1
- package/dist/check/explain/tree-docs.js +11 -0
- package/dist/check/explain/tree-docs.js.map +1 -1
- package/dist/check/fixtures/business-name-fixtures.d.ts +3 -0
- package/dist/check/fixtures/business-name-fixtures.d.ts.map +1 -0
- package/dist/check/fixtures/business-name-fixtures.js +98 -0
- package/dist/check/fixtures/business-name-fixtures.js.map +1 -0
- 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/rules/naming-business-name.d.ts +3 -0
- package/dist/check/rules/naming-business-name.d.ts.map +1 -0
- package/dist/check/rules/naming-business-name.js +41 -0
- package/dist/check/rules/naming-business-name.js.map +1 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/parse-business-names.d.ts +4 -0
- package/dist/config/parse-business-names.d.ts.map +1 -0
- package/dist/config/parse-business-names.js +14 -0
- package/dist/config/parse-business-names.js.map +1 -0
- package/dist/config/parse-config.d.ts.map +1 -1
- package/dist/config/parse-config.js +2 -0
- package/dist/config/parse-config.js.map +1 -1
- package/dist/config/serialize-config.js +9 -1
- package/dist/config/serialize-config.js.map +1 -1
- package/dist/generate/kinds/query-kind.d.ts.map +1 -1
- package/dist/generate/kinds/query-kind.js +2 -1
- package/dist/generate/kinds/query-kind.js.map +1 -1
- package/dist/generate/kinds/route/write-controller.d.ts.map +1 -1
- package/dist/generate/kinds/route/write-controller.js +2 -1
- package/dist/generate/kinds/route/write-controller.js.map +1 -1
- package/dist/generate/kinds/route-kind.d.ts.map +1 -1
- package/dist/generate/kinds/route-kind.js +2 -1
- package/dist/generate/kinds/route-kind.js.map +1 -1
- package/dist/generate/searched-elements.d.ts +13 -0
- package/dist/generate/searched-elements.d.ts.map +1 -0
- package/dist/generate/searched-elements.js +63 -0
- package/dist/generate/searched-elements.js.map +1 -0
- package/dist/init/manifest/versions.js +24 -24
- package/dist/naming/index.d.ts +2 -0
- package/dist/naming/index.d.ts.map +1 -1
- package/dist/naming/index.js +2 -0
- package/dist/naming/index.js.map +1 -1
- package/dist/naming/plural.d.ts +3 -0
- package/dist/naming/plural.d.ts.map +1 -0
- package/dist/naming/plural.js +8 -0
- package/dist/naming/plural.js.map +1 -0
- package/dist/naming/singular.d.ts +3 -0
- package/dist/naming/singular.d.ts.map +1 -0
- package/dist/naming/singular.js +11 -0
- package/dist/naming/singular.js.map +1 -0
- package/dist/naming/to-name.d.ts.map +1 -1
- package/dist/naming/to-name.js +1 -6
- package/dist/naming/to-name.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/templates/command/handler.declared.ts.tpl +3 -3
- package/templates/command/handler.ts.tpl +3 -3
- package/templates/controller/controller.search.ts.tpl +1 -1
- package/templates/init/rules/application.md.tpl +1 -1
- package/templates/init/rules/domain.md.tpl +1 -1
- package/templates/init/scripts/events.ts.tpl +6 -6
- package/templates/init/scripts/openapi.ts.tpl +2 -2
- package/templates/pipeline/package-scripts.json +1 -1
- package/templates/policy/policy.declared.ts.tpl +1 -1
- package/templates/policy/policy.ts.tpl +1 -1
- package/templates/presets/identity/context/infrastructure/persistence/drizzle-search-members-reader.repository.drizzle.ts.tpl +2 -2
- package/templates/presets/identity/context/presentation/http/organisations/search-members/search-members.controller.ts.tpl +2 -2
- package/templates/presets/identity/context/test/integration/search-members-reader.spec.ts.tpl +1 -1
- package/templates/query/search.reader.fake.ts.tpl +1 -1
- package/templates/query/search.reader.in-memory.ts.tpl +20 -18
- package/templates/route/route.search.ts.tpl +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# ddd-ts-kit
|
|
2
4
|
|
|
3
5
|
Bootstrap, scaffold, document and verify Domain-Driven Design and Clean Architecture projects
|
|
@@ -11,6 +13,15 @@ dependency-cruiser, knip, coverage thresholds, git hooks and the CI of GitHub or
|
|
|
11
13
|
The generator and the checker share one definition of the canonical tree, and a test generates
|
|
12
14
|
every kind for every stack and requires zero violation: they cannot disagree.
|
|
13
15
|
|
|
16
|
+
The main commands fall into two families. The first writes the project: `init` once, `generate` for
|
|
17
|
+
each piece, `docs`, `sdk` and `depcruise-config` for what is derived from the code and the
|
|
18
|
+
config, and three migrations that move a project from an older form to the current one. The
|
|
19
|
+
second reads the project back without changing it: `check` judges it, `list` and `describe` state
|
|
20
|
+
what it holds, `status` and `todo` say what is left to do, `affected` says what a pull request has
|
|
21
|
+
to run.
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
14
25
|
No runtime dependency. Node 22.12 or later.
|
|
15
26
|
|
|
16
27
|
```
|
|
@@ -98,7 +109,7 @@ existed keeps its spelling. See [docs/CONFIG.md](../../docs/CONFIG.md).
|
|
|
98
109
|
|
|
99
110
|
Defaults: `express`, `zod`, `tsyringe` (`nest` with `--http nest`, and only then), `vitest`,
|
|
100
111
|
`drizzle` on `postgres`, `outbox` when there is an ORM, `swagger`, `github`, `jwt`, `rbac`, no broker,
|
|
101
|
-
the cache in the process, the audit trail `on` when there is an ORM, unexpected failures reported
|
|
112
|
+
the cache in the process, files in a local directory (`--storage local`), the audit trail `on` when there is an ORM, unexpected failures reported
|
|
102
113
|
nowhere (`--errors none`), JSON lines on the standard output (`--logger stdout`), and the
|
|
103
114
|
`declared` style with a `barrel` wherever the declarations are read: with `--di tsyringe`.
|
|
104
115
|
|
|
@@ -110,7 +121,7 @@ proven by a real install of a generated project, its build and its `verify`. The
|
|
|
110
121
|
from the registry either way.
|
|
111
122
|
|
|
112
123
|
**Pipelines.** A pull or merge request runs the unit specs, the integration specs and the lint of
|
|
113
|
-
the contexts its change reaches through the context map (`ddd-ts-kit affected`); types,
|
|
124
|
+
the contexts its change reaches through the context map ([`ddd-ts-kit affected`](#affected)); types,
|
|
114
125
|
architecture, import graph, dead code, end to end, build and documents always run on the whole
|
|
115
126
|
project, and the default branch and the nightly schedule run everything.
|
|
116
127
|
|
|
@@ -132,6 +143,104 @@ files it imports itself are compiled by the runner. Discovery is accepted behind
|
|
|
132
143
|
context listens on the subscriber `EventSubscribers.forContext(name)` hands it, in either assembly.
|
|
133
144
|
A config written before `stack.style` existed is explicit.
|
|
134
145
|
|
|
146
|
+
The same use case in both styles. In the declared style, the command and its handler carry their
|
|
147
|
+
decorator, and the handler's constructor says what it needs. The body of the handler is the one
|
|
148
|
+
`generate command billing IssueInvoice --aggregate Invoice --permission invoice.issue` writes,
|
|
149
|
+
before its `TODO(domain)` is answered; the input of the command is filled in:
|
|
150
|
+
|
|
151
|
+
<!-- checked sample -->
|
|
152
|
+
```ts
|
|
153
|
+
import { definePermissions } from '@ddd-ts-kit/auth';
|
|
154
|
+
import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, type IdGenerator, Repository } from '@ddd-ts-kit/kernel';
|
|
155
|
+
import { CommandHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
|
|
156
|
+
|
|
157
|
+
declare class Invoice extends AggregateRoot {
|
|
158
|
+
static create(id: string, now: Date): Invoice;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// domain/repositories/invoice.repository.ts
|
|
162
|
+
export abstract class InvoiceRepository extends Repository<Invoice> {}
|
|
163
|
+
|
|
164
|
+
// application/authorization/billing.permissions.ts
|
|
165
|
+
export const billingPermissions = definePermissions('billing', {
|
|
166
|
+
'invoice.issue': { description: 'Issue invoice' },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// application/commands/issue-invoice/issue-invoice.command.ts
|
|
170
|
+
@Requires(billingPermissions['invoice.issue'])
|
|
171
|
+
export class IssueInvoiceCommand extends AuthorizedCommand<string> {
|
|
172
|
+
constructor(
|
|
173
|
+
actor: Actor,
|
|
174
|
+
readonly customerId: string,
|
|
175
|
+
) {
|
|
176
|
+
super(actor);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// application/commands/issue-invoice/issue-invoice.handler.ts
|
|
181
|
+
@CommandHandler(IssueInvoiceCommand)
|
|
182
|
+
export class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> {
|
|
183
|
+
// each parameter is typed by one class, which is also its injection token
|
|
184
|
+
constructor(
|
|
185
|
+
private readonly invoiceRepository: InvoiceRepository,
|
|
186
|
+
private readonly ids: IdGenerator,
|
|
187
|
+
private readonly clock: Clock,
|
|
188
|
+
) {}
|
|
189
|
+
|
|
190
|
+
async execute(command: IssueInvoiceCommand): Promise<string> {
|
|
191
|
+
const invoice = Invoice.create(this.ids.next(), this.clock.now());
|
|
192
|
+
|
|
193
|
+
await this.invoiceRepository.save(invoice);
|
|
194
|
+
|
|
195
|
+
return invoice.id;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
In the explicit style, the same classes carry no decorator, and the module file of the context
|
|
201
|
+
binds each of them, with its permission and the ports it is given:
|
|
202
|
+
|
|
203
|
+
<!-- checked sample -->
|
|
204
|
+
```ts
|
|
205
|
+
import { definePermissions } from '@ddd-ts-kit/auth';
|
|
206
|
+
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
207
|
+
import { AggregateRoot, AuthorizedCommand, Clock, type CommandHandler, IdGenerator, Repository } from '@ddd-ts-kit/kernel';
|
|
208
|
+
|
|
209
|
+
// the classes of the declared sample, without their decorators
|
|
210
|
+
declare class Invoice extends AggregateRoot {}
|
|
211
|
+
declare class IssueInvoiceCommand extends AuthorizedCommand<string> {}
|
|
212
|
+
declare class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> {
|
|
213
|
+
constructor(invoiceRepository: InvoiceRepository, ids: IdGenerator, clock: Clock);
|
|
214
|
+
execute(command: IssueInvoiceCommand): Promise<string>;
|
|
215
|
+
}
|
|
216
|
+
declare class InMemoryInvoiceRepository implements InvoiceRepository {
|
|
217
|
+
findById(id: string): Promise<Invoice | undefined>;
|
|
218
|
+
save(invoice: Invoice): Promise<void>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export abstract class InvoiceRepository extends Repository<Invoice> {}
|
|
222
|
+
|
|
223
|
+
export const billingPermissions = definePermissions('billing', {
|
|
224
|
+
'invoice.issue': { description: 'Issue invoice' },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// billing.module.ts: every binding of the context, one line each
|
|
228
|
+
export const billingModule = defineContextModule({
|
|
229
|
+
name: 'billing',
|
|
230
|
+
register(builder) {
|
|
231
|
+
builder.bind(InvoiceRepository).toClass(InMemoryInvoiceRepository).as('repository');
|
|
232
|
+
builder
|
|
233
|
+
.command(IssueInvoiceCommand)
|
|
234
|
+
.requires(billingPermissions['invoice.issue'])
|
|
235
|
+
.handledBy(IssueInvoiceHandler, [InvoiceRepository, IdGenerator, Clock]);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`check` holds both forms: `authz/declared` reports a command that says neither who may send it
|
|
241
|
+
nor that it is public, `declarations/role-declared` a role file of the declared style without its
|
|
242
|
+
decorator.
|
|
243
|
+
|
|
135
244
|
**A port is an abstract class and its own injection token.** `application/ports/<name>.port.ts`
|
|
136
245
|
and `domain/repositories/<name>.repository.ts` hold one abstract class each. Generated code holds
|
|
137
246
|
no `token()`, no UPPER_SNAKE injection constant, no `<context>.ports.ts` and no
|
|
@@ -140,6 +249,50 @@ no `token()`, no UPPER_SNAKE injection constant, no `<context>.ports.ts` and no
|
|
|
140
249
|
list or a binding and type imports where they only type a parameter, an adapter `implements`
|
|
141
250
|
its port.
|
|
142
251
|
|
|
252
|
+
Instead of an interface, a token constant and an injection by name:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
export interface IPaymentGateway {
|
|
256
|
+
charge(invoiceId: string, amountInCents: number): Promise<void>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const PAYMENT_GATEWAY = Symbol('PaymentGateway'); // ports/no-token-constant
|
|
260
|
+
|
|
261
|
+
constructor(@inject(PAYMENT_GATEWAY) private readonly payments: IPaymentGateway) {}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
write one abstract class, which is both the contract and the token:
|
|
265
|
+
|
|
266
|
+
<!-- checked sample -->
|
|
267
|
+
```ts
|
|
268
|
+
import { Adapter } from '@ddd-ts-kit/kernel/declarations';
|
|
269
|
+
|
|
270
|
+
// application/ports/payment-gateway.port.ts
|
|
271
|
+
export abstract class PaymentGateway {
|
|
272
|
+
abstract charge(invoiceId: string, amountInCents: number): Promise<void>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// infrastructure/external/remote-payment-gateway.adapter.ts: implements the port, never extends it
|
|
276
|
+
@Adapter(PaymentGateway, { kind: 'external' })
|
|
277
|
+
export class RemotePaymentGateway implements PaymentGateway {
|
|
278
|
+
async charge(invoiceId: string, amountInCents: number): Promise<void> {
|
|
279
|
+
// TODO(platform): call the payment provider with invoiceId and amountInCents
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// a handler names the port by the type of its parameter: nothing else says what is injected
|
|
284
|
+
export class SettleInvoiceHandler {
|
|
285
|
+
constructor(private readonly payments: PaymentGateway) {}
|
|
286
|
+
|
|
287
|
+
async settle(invoiceId: string, amountInCents: number): Promise<void> {
|
|
288
|
+
await this.payments.charge(invoiceId, amountInCents);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`ports/abstract-class`, `ports/implements-never-extends`, `ports/no-token-constant` and
|
|
294
|
+
`ports/value-import` hold this form, in `check` and in ESLint.
|
|
295
|
+
|
|
143
296
|
**The constructor states the dependencies.** In the declared style a generated class lists
|
|
144
297
|
nothing in its decorator (`@CommandHandler(IssueInvoiceCommand)`): each constructor parameter
|
|
145
298
|
is typed by one class, its token (`declarations/constructor-injection`). `generate`, `docs` and
|
|
@@ -154,6 +307,31 @@ is absent with standard decorators and under `tsx`. `init` writes
|
|
|
154
307
|
what each class is given. Every generated file exports one class, interface or function and is named
|
|
155
308
|
after it.
|
|
156
309
|
|
|
310
|
+

|
|
311
|
+
|
|
312
|
+
An excerpt of the barrel of the `notification` context of the example application, as the CLI
|
|
313
|
+
writes it (never by hand):
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
// src/contexts/notification/notification.declarations.ts
|
|
317
|
+
import { injected } from '@ddd-ts-kit/kernel/declarations';
|
|
318
|
+
import { WithdrawReminderHandler } from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.handler';
|
|
319
|
+
import { ReminderRepository } from '@/contexts/notification/domain/repositories/reminder.repository';
|
|
320
|
+
import { KnexReminderRepository } from '@/contexts/notification/infrastructure/persistence/knex-reminder.repository';
|
|
321
|
+
import { Clock, ExecutionScope } from '@ddd-ts-kit/kernel';
|
|
322
|
+
import { KnexContext } from '@ddd-ts-kit/knex';
|
|
323
|
+
|
|
324
|
+
export * from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.command';
|
|
325
|
+
export * from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.handler';
|
|
326
|
+
export * from '@/contexts/notification/infrastructure/persistence/knex-reminder.repository';
|
|
327
|
+
|
|
328
|
+
injected(WithdrawReminderHandler, [ReminderRepository]);
|
|
329
|
+
injected(KnexReminderRepository, [KnexContext, ExecutionScope, Clock]);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
and the one line of the composition root that adds the whole context:
|
|
333
|
+
`contextModule('notification', notificationDeclarations)` in `src/platform/compose-application.ts`.
|
|
334
|
+
|
|
157
335
|
**Domain errors are built by factories.** `init` writes `"stack.refusals": "factories"`: a
|
|
158
336
|
context has one error class per concept, extending `ConceptError` of the kernel, and
|
|
159
337
|
`generate error <context> <Concept> <refusal>` adds a static factory to it. The `errors/*`
|
|
@@ -161,6 +339,46 @@ rules hold that form. `--refusals classes`, or a config without the key, keeps o
|
|
|
161
339
|
error built with `new`, as 0.1 and 0.2 wrote them; `error-factories` moves a project from one
|
|
162
340
|
to the other.
|
|
163
341
|
|
|
342
|
+
The older form, under `"stack.refusals": "classes"`: one class per error.
|
|
343
|
+
|
|
344
|
+
<!-- checked sample -->
|
|
345
|
+
```ts
|
|
346
|
+
import { DomainError } from '@ddd-ts-kit/kernel';
|
|
347
|
+
|
|
348
|
+
// domain/errors/slot-taken.error.ts
|
|
349
|
+
export class SlotTakenError extends DomainError {
|
|
350
|
+
readonly code = 'SLOT_TAKEN';
|
|
351
|
+
|
|
352
|
+
constructor(readonly calendarId: string) {
|
|
353
|
+
super(`The slot of calendar ${calendarId} is already held.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const refusal = new SlotTakenError('calendar-1');
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The form `init` writes: one class per concept of the glossary, one factory per refusal, which
|
|
361
|
+
`generate error scheduling Appointment slotTaken` adds.
|
|
362
|
+
|
|
363
|
+
<!-- checked sample -->
|
|
364
|
+
```ts
|
|
365
|
+
import { ConceptError } from '@ddd-ts-kit/kernel';
|
|
366
|
+
|
|
367
|
+
// domain/errors/appointment.error.ts
|
|
368
|
+
export class AppointmentError extends ConceptError {
|
|
369
|
+
static slotTaken(calendarId: string) {
|
|
370
|
+
return AppointmentError.refusal('SLOT_TAKEN', `The slot of calendar ${calendarId} is already held.`, { calendarId });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const refusal = AppointmentError.slotTaken('calendar-1');
|
|
375
|
+
|
|
376
|
+
// what catches it tells the refusal by its class and its code, and reads its facts
|
|
377
|
+
if (AppointmentError.is(refusal, 'SLOT_TAKEN')) {
|
|
378
|
+
console.log(refusal.facts.calendarId);
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
164
382
|
**A route is a controller class.** `init` writes `"stack.routes": "controllers"`: each route is
|
|
165
383
|
one class with one method, in a folder of its own, behind the guards of its route group, and a
|
|
166
384
|
declared context lists no route: its controllers reach the application through its generated
|
|
@@ -188,6 +406,65 @@ the aggregate recorded are dispatched once the save resolves, inside the unit of
|
|
|
188
406
|
command: by the kernel module on tsyringe, by `dispatchingOnSave` around each repository in
|
|
189
407
|
`platform/create-platform.ts` with hand wiring (`domain-events/dispatched-on-save`).
|
|
190
408
|
|
|
409
|
+
Instead of pulling the events and dispatching them in the handler:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
async execute(command: IssueInvoiceCommand): Promise<void> {
|
|
413
|
+
const invoice = await this.invoiceRepository.findById(command.invoiceId);
|
|
414
|
+
|
|
415
|
+
invoice.issue(this.clock.now());
|
|
416
|
+
await this.invoiceRepository.save(invoice);
|
|
417
|
+
await this.dispatcher.dispatch(invoice.pullEvents()); // domain-events/dispatched-on-save
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
write the save, and nothing after it:
|
|
422
|
+
|
|
423
|
+
<!-- checked sample -->
|
|
424
|
+
```ts
|
|
425
|
+
import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, type CommandHandler, ConceptError, Repository } from '@ddd-ts-kit/kernel';
|
|
426
|
+
|
|
427
|
+
declare class Invoice extends AggregateRoot {
|
|
428
|
+
/** Makes the invoice final and records InvoiceIssued, which waits in the aggregate. */
|
|
429
|
+
issue(now: Date): void;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
declare class InvoiceError extends ConceptError {
|
|
433
|
+
static notFound(invoiceId: string): InvoiceError;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export abstract class InvoiceRepository extends Repository<Invoice> {}
|
|
437
|
+
|
|
438
|
+
export class IssueInvoiceCommand extends AuthorizedCommand<void> {
|
|
439
|
+
constructor(
|
|
440
|
+
actor: Actor,
|
|
441
|
+
readonly invoiceId: string,
|
|
442
|
+
) {
|
|
443
|
+
super(actor);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> {
|
|
448
|
+
constructor(
|
|
449
|
+
private readonly invoiceRepository: InvoiceRepository,
|
|
450
|
+
private readonly clock: Clock,
|
|
451
|
+
) {}
|
|
452
|
+
|
|
453
|
+
async execute(command: IssueInvoiceCommand): Promise<void> {
|
|
454
|
+
const invoice = await this.invoiceRepository.findById(command.invoiceId);
|
|
455
|
+
|
|
456
|
+
if (invoice === undefined) {
|
|
457
|
+
throw InvoiceError.notFound(command.invoiceId);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
invoice.issue(this.clock.now());
|
|
461
|
+
|
|
462
|
+
// InvoiceIssued is dispatched once this save resolves, inside the unit of work of the command
|
|
463
|
+
await this.invoiceRepository.save(invoice);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
191
468
|
`--events in-memory` together with an ORM is refused unless `--allow-no-outbox "<reason>"` is given:
|
|
192
469
|
it records a `deviations` entry (`stack.events`) and writes the decision record it points to. The generated
|
|
193
470
|
persistence of the four ORMs is written for PostgreSQL; `custom` starts on in-memory adapters
|
|
@@ -212,6 +489,7 @@ config, `docsUi`, `ci` and `logger` included.
|
|
|
212
489
|
| `--http-cache varnish` | a compose service `varnish` (Varnish 9.1.0, reaching the API on `host.docker.internal`) with its VCL in `docker/varnish/default.vcl` (only answers whose `Cache-Control` allows them by `max-age` or `s-maxage` are kept, filed under their `xkey` tags, purged by `PURGE` from the machine of Varnish or with `VARNISH_PURGE_KEY`, `x-cache: HIT\|MISS`), `VARNISH_URL` and `VARNISH_PURGE_KEY` validated, `cacheTags: { header: CacheTagHeader.xkey }` in the HTTP boundary, and the outbox relay of `main.ts` and of every `generate service` entry point publishing through `PurgingPublisher` with `VarnishCachePurger` (the end-to-end application records the purges in `purges`); on `--di manual` and `nest` the platform gains `cacheInvalidation`; with `--tenancy`, the tags of the answers and the purges are filed under the tenant (`perTenant`) and the tenant of a request is read from `x-tenant-id`. Needs an HTTP framework and `--events outbox`: a purge is a row of the outbox |
|
|
213
490
|
| `--errors sentry` | unexpected failures reported to Sentry or GlitchTip (one protocol, only the DSN differs) through the kernel `ErrorReporter`, bound to `@ddd-ts-kit/sentry`. `src/platform/errors.ts` (`initSentry` on the validated configuration, then `reportProcessFailures`), the second import of `main.ts`, of `apps/<context>/main.ts` and of `apps/worker/main.ts`, right after `tracing.ts`; `errors` handed to `composeApplication`, which gives it to the kernel module on tsyringe (`errorReporter`, flushed by `container.stop()` before the logger, `errorReportingMiddleware` first on both buses, the default scheduler observing with `reportFailedRuns`) or to `createPlatform` by hand (the same middleware, `onRun: reportFailedRuns(errors)`, `platform.errors`, flushed two seconds at most when the application stops); `reportHttpErrors` in `auth/http-boundary.ts` for every request answered 5xx; `DeadLetterReports` on the `onFailure` hook of a provided broker (`createEventProvider(config, errors)`, with `observing` around each consumer) and of the outbox relay. `SENTRY_DSN`, `SENTRY_ENVIRONMENT` (default: `NODE_ENV`), `SENTRY_RELEASE` and `SENTRY_SCRUB_MESSAGES` validated in `config/load-config.ts` as `errorReporting`: without a DSN the application starts and reports nothing; a DSN that is not a URL refuses the start. Dependencies `@ddd-ts-kit/sentry` and `@sentry/node`. A compose profile `errors` running GlitchTip 6.2.6 with its own PostgreSQL and Valkey (`GLITCHTIP_PORT`, loopback only, development bootstrap), and a section of the generated README saying how to read its DSN. The tools and the tests compose the application without a reporter. It goes with every other choice of the stack; with `--errors none` nothing of this is written |
|
|
214
491
|
| `--authn keycloak` | a compose service `keycloak` (Keycloak 26.8.0, `start-dev --import-realm`, profile `identity`) and `docker/keycloak/<project>-realm.json`: the API client, a public client `<project>-local` with the direct grant, its audience and `tenant_id` mappers, the realm role `admin`, and the demo users `demo-admin` and `demo-user`, for local development only |
|
|
492
|
+
| `--storage local\|s3` | recorded as `stack.storage`: where the content of files lives, never in the database. `src/platform/config/read-files.ts` and `src/platform/files/open-files.ts`, one per choice: `local`, a directory in development (`FILES_DIRECTORY`, `.data/files`) and the signed links answered by the application under `/files` (`FILES_PUBLIC_URL` and `FILES_SIGNING_SECRET` required in production); `s3`, a bucket of any provider of the S3 protocol through `@ddd-ts-kit/s3` (the `S3_*` variables, MinIO in the compose stack); the memory of the process in the tests and the tools, either way. With `--di tsyringe`: `files/bind-files.ts`, `files/files.module.ts` and `files/staging-uploads.ts`; with hand wiring `files/create-files.ts`; with an HTTP stack `files/file-routes.ts`. They bind `FileStorage`, `FileKeys`, `FileScanner` and `FileStaging`, register the hourly purge of staged files and stage the file a command carries before its transaction; `generate file-upload` needs them. `--storage none` writes none of this ([File storage](../../docs/concepts/file-storage.md)) |
|
|
215
493
|
| `src/platform/events/` | `event-provider.ts` (`bus`, `forConsumer(name)`, `start()`, `close()`), `in-process-events.ts`, and for `--broker` `create-event-provider.ts`: `RabbitMqEventBus` (`AMQP_URL`), `KafkaEventBus` (`KAFKA_BROKERS`, with `aggregate-key-of.ts` for per-aggregate order), `NatsEventBus` (`NATS_URL`) or `RedisStreamsEventBus` of `@ddd-ts-kit/redis/event-bus` (`REDIS_URL`: Redis, Dragonfly or Valkey), each with one subscriber per consuming context (`forConsumer`), `assertTopology` at start, its address validated and a compose service with a healthcheck; or `custom-event-bus.ts` with its `eventBusContract` spec. `start-outbox-relay.ts` with the outbox. `--broker redis` with `--cache redis` is one server and one compose service, `cache`; alone, the service is `streams` |
|
|
216
494
|
| `src/platform/database/` | `persistence.ts` (`interface Persistence`: unit of work, outbox and processed-event stores of the ORM package, the storage handle), `open-persistence.ts`, `persistence-over.ts`, `inert-persistence.ts`, the connection (`connect-to-database.ts`, `create-client.ts` or `create-data-source.ts`), `schema.ts`, `open-test-database.ts` (PostgreSQL in process, or the server named by a URL). The storage class a repository adapter names in its dependency list: `database.ts` with drizzle and prisma (`abstract class Database extends` the context type of the ORM package), `KnexContext` of `@ddd-ts-kit/knex`, `TypeOrmContext` of `@ddd-ts-kit/typeorm` |
|
|
217
495
|
| migration tooling | `drizzle.config.ts`, `knexfile.js` and `migrations/00000000000000_initial.js`, `prisma/schema.prisma` and `prisma.config.ts`, or `data-source.ts` |
|
|
@@ -297,10 +575,42 @@ ddd-ts-kit generate <kind> ... [--term "<Term>=<meaning>"]... [--allow-name] [--
|
|
|
297
575
|
ddd-ts-kit generate --help --json
|
|
298
576
|
```
|
|
299
577
|
|
|
578
|
+
One run of `generate` does five things, in this order, and writes nothing when the first one
|
|
579
|
+
refuses:
|
|
580
|
+
|
|
581
|
+

|
|
582
|
+
|
|
583
|
+
In the example application, a use case named with a word its glossary does not define is refused,
|
|
584
|
+
and the message says how to go on:
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
$ npx ddd-ts-kit generate command notification ArchiveNotification --permission notification.archive --aggregate Notification
|
|
588
|
+
error: "ArchiveNotification" uses "archive", which the glossary of src/contexts/notification/CONTEXT.md does not define.
|
|
589
|
+
fix: Either use an existing term (Notification, Send, Recipient, Contact, Confirmation, Reminder, Plan, Due, Withdraw, Mailer, Kind, Request, Rescheduling, Cancellation, Read, Search), or add the term in the same command: --term "Archive=<what it means for the business>". The glossary is written before the code (rule language/glossary-term).
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
With the term given in the same command (and `--dry-run`, which says what it would write):
|
|
593
|
+
|
|
594
|
+
```
|
|
595
|
+
$ npx ddd-ts-kit generate command notification ArchiveNotification --permission notification.archive --aggregate Notification \
|
|
596
|
+
--term "Archive=To set a sent notification aside, out of the default search" --dry-run
|
|
597
|
+
would create src/contexts/notification/application/commands/archive-notification/archive-notification.command.ts
|
|
598
|
+
would create src/contexts/notification/application/commands/archive-notification/archive-notification.handler.ts
|
|
599
|
+
would create src/contexts/notification/test/unit/archive-notification.handler.spec.ts
|
|
600
|
+
would update src/contexts/notification/CONTEXT.md
|
|
601
|
+
would update src/contexts/notification/application/authorization/notification.permissions.ts
|
|
602
|
+
would update src/contexts/notification/notification.declarations.ts
|
|
603
|
+
|
|
604
|
+
Next, in this order: the language of the context first, then the business decisions only you can make
|
|
605
|
+
src/contexts/notification/application/commands/archive-notification/archive-notification.command.ts:9 declare the input of "archive notification" as a constructor with the actor first, ...
|
|
606
|
+
src/contexts/notification/application/commands/archive-notification/archive-notification.handler.ts:16 decide what "archive notification" does to a Notification: ...
|
|
607
|
+
src/contexts/notification/test/unit/archive-notification.handler.spec.ts:38 replace each entry below by a test named after the business behaviour of "archive notification": ...
|
|
608
|
+
```
|
|
609
|
+
|
|
300
610
|
| Kind | Arguments | Writes |
|
|
301
611
|
|---|---|---|
|
|
302
612
|
| `context` | `<name> --classification <c> [--purpose "<two sentences>"] [--term ...]... [--upstream <ctx>[:relationship]]... [--preset identity]` | `CONTEXT.md` with its glossary, `published-language/index.ts`, config entry, registration in `platform/compose-application.ts` and in `test/architecture/extractable.spec.ts`; on tsyringe, with HTTP, `presentation/http/<name>.routes.ts` (a function of the two buses, in both styles); declared: the barrel `<name>.declarations.ts`; explicit and hand wiring: `<name>.module.ts`. `application/authorization/<name>.permissions.ts` is born with the first permission, or at once when `platform/auth/permissions.ts` lists the permission sets (rebac, custom). With `--preset`, the context is then written whole from that preset, its classification and purpose those of the preset unless the flags say otherwise ([Presets](#presets)) |
|
|
303
|
-
| `aggregate` | `<context> <Name
|
|
613
|
+
| `aggregate` | `<context> <Name> [--event-sourced]` | aggregate, builder, spec in given / when / then; with `--event-sourced`, an aggregate on `EventSourcedAggregate` whose state is the replay of its events, born with its `Created` domain event, the codec of its stored events in `infrastructure/persistence/<name>-events.mapper.ts`, a builder that replays a history and a spec given events, and a note naming the `EventSourcedRepository` its repository adapter delegates to |
|
|
304
614
|
| `builder` | `<context> <Aggregate>` | `test/builders/<name>.builder.ts`, a class extending `Builder` |
|
|
305
615
|
| `entity` | `<context> <Name>` | entity, spec |
|
|
306
616
|
| `value-object` (`vo`) | `<context> <Name>` | value object, spec |
|
|
@@ -316,6 +626,7 @@ ddd-ts-kit generate --help --json
|
|
|
316
626
|
| `port` | `<context> <Name>` | `application/ports/<name>.port.ts`, one abstract class, and its fake |
|
|
317
627
|
| `route` | `<context> <UseCaseName> --method <GET\|POST\|PUT\|PATCH\|DELETE> --path <p> [--command\|--query] [--group <Name>] [--mapper] [--serializer] [--cache public\|private\|no-store]` | under `"stack.routes": "controllers"`: a controller class in `presentation/http/<resource>/<use-case>/` (the resource is the first segment of the path), depending on `CommandBus` or `QueryBus`, behind the route group of its resource, written with it when it is new (`<resource>.routes.ts`: prefix, tag, `Authenticated`); a public use case stands in no group and says `guards: [Public]`; with class-validator one class per file (`<use-case>.params.ts`, `.body.ts`, `.answer.ts`, `.accepted.ts`, `.line.ts`); `--mapper` and `--serializer` add a request mapper and a serializer the controller is given; a spec; no registration in a declared project, a controller built by hand in `<context>.routes.ts` (the module file with hand wiring) in an explicit one. Under `"functions"`: a `defineRoute` function, its spec, its registration in `<context>.routes.ts`, and `<use-case>-<role>.dto.ts` classes |
|
|
318
628
|
| `controller` | the same arguments as `route` | the controller class, whatever `stack.routes` says: for a project of function routes that writes its next route as a class. `--cache` writes its cache policy: `private` (a `GET` of a caller, `max-age=0`, `must-revalidate`, strong `ETag`), `public` (a `GET` of a public use case, `max-age` 1m, `s-maxage` 5m, tagged with the resources of its path), `no-store`; refused on a write, and `public` behind a route group |
|
|
629
|
+
| `file-upload` | `<context> <UseCaseName> --path <p> (--permission <aggregate>.<action> \| --public) [--method POST\|PUT] [--group <Name>]` | an upload: a controller that declares the file it receives (its size and media types in the contract, checked as it streams), the command that carries it as a `FileUpload`, staged by the command bus before the transaction, the handler that records it and promotes it with the commit, and their specs. Refused without `stack.storage` or without an HTTP stack, with the `init --force --storage local` to run |
|
|
319
630
|
| `route-group` (`group`) | `<context> <Name> [--prefix <path>] [--group <ParentName>] [--public \| --role <role>]` | `presentation/http/<name>.routes.ts`: an empty class carrying `@RouteGroup`, behind `Authenticated` unless `--public` or `--role` says otherwise (`HasRole(Role.Admin)`, the role read from the vocabulary of the platform, `<platformDir>/auth/role.ts`, which gets it when it does not hold it); with `--group` it refines that group and inherits from it |
|
|
320
631
|
| `guard` | `<context> <Name>` | `presentation/http/guards/<name>.guard.ts`: a declared guard class to fill, and its spec |
|
|
321
632
|
| `representation` | `<context> <UseCase> <xml\|csv\|media/type>[,...]` | adds `produces` to the `@Controller` of the use case, and `consumes: ['application/xml']` to one that receives a body when XML is asked; says when CSV needs a page or a streamed list, and when `stack.representations` is not `negotiated` |
|
|
@@ -323,7 +634,7 @@ ddd-ts-kit generate --help --json
|
|
|
323
634
|
| `schema` | `<context\|shared-kernel> <Name>` | `presentation/http/shared/<name>.schema.ts` of the context, or `presentation/<name>.schema.ts` of the shared kernel: one shape of the contract declared once (a zod schema with `.meta({ id })`, a class-validator class, or a schema wrapped by `sharedSchema`), to fill; the routes use it whole, nested or in part |
|
|
324
635
|
| `integration-event` | `<context> <Aggregate> <Fact> [--version n]` | `defineIntegrationEvent` declaration in the published language (its payload in `<event>.v<n>.payload.ts` with class-validator or a schema of your own), index export, the publisher `application/integration/publishers/<event>.publisher.ts`, spec, registration, a line of `platform/audit/audited-events.ts` |
|
|
325
636
|
| `integration-handler` (`consumer`) | `<context> <upstreamContext> <eventType>` | `application/integration/handlers/<name>.integration-handler.ts`, a class `<Name>IntegrationHandler` depending on `CommandBus`, its spec, its idempotent subscription under the consumer name `<context>.<name>`; refused unless the upstream is declared |
|
|
326
|
-
| `event-handler` | `<context> <Aggregate> <Fact> [--phase after-commit]` | reaction to a domain event of the context, spec, subscription; before the commit of the unit of work by default, after it with `--phase after-commit` (an effect no transaction covers, at most once), and the spec runs the reaction in that phase |
|
|
637
|
+
| `event-handler` | `<context> <Aggregate> <Fact> [--phase before-commit\|after-commit]` | reaction to a domain event of the context, spec, subscription; before the commit of the unit of work by default, after it with `--phase after-commit` (an effect no transaction covers, at most once), and the spec runs the reaction in that phase |
|
|
327
638
|
| `projection` (`projector`) | `<context> <Name> <event-type>` | a projector in `application/projections/<name>.projection.ts`, declared with `@Projection('<context>.<name>')` and one `@On(definition)` method for the integration event, which the context itself or a declared upstream publishes, and its spec; declared style only. It keeps one read model: its port is received in the constructor |
|
|
328
639
|
| `job` | `<context> <Name> (--every <duration> \| --cron "<expression>" [--time-zone <zone>]) --command <UseCaseName>` | `application/scheduling/<name>.job.ts`, a class `<Name>Job implements ScheduledJob` that sends the command, as `systemActor('<context>.<name>')` when it is protected, with `execution.scheduledAt` when the command takes it; `test/unit/<name>.job.spec.ts`, which runs the job (by name on tsyringe, `application.jobs.runNow`; `job.run(occurrence)` with hand wiring) and asserts the command it sent; its registration; its switch in `platform/job-settings.ts`; the grant of its system actor (below) |
|
|
329
640
|
| `background-job` | `<context> <Name> --command <UseCaseName>` | `application/background/<name>.background-job.ts`, a class `<Name>BackgroundJob implements BackgroundJob<<Name>Payload>` named by its static `jobName` `<context>.<name>`, that sends the command as `execution.actor` when it is protected, followed by one field of `<Name>Payload` per other parameter of the constructor of the command (strings, numbers, booleans, literals and lists of them as written, a `Date` as its ISO text; anything else is refused); `test/unit/<name>.background-job.spec.ts`, which enqueues it with a payload of every field as a user holding the permission and runs what is due; its registration (`@Background()`, or `builder.backgroundJob(..)` in the module file). `--di tsyringe` only |
|
|
@@ -622,6 +933,15 @@ this command rewrites, in every `.js`, `.mjs`, `.cjs`, `.d.ts`, `.d.mts` and `.d
|
|
|
622
933
|
output directory of the tsconfig (`tsconfig.build.json` by default), each alias specifier into
|
|
623
934
|
the relative path of the emitted file it names, with its `.js` extension
|
|
624
935
|
(`../../platform/errors.js`), so that `node dist/src/main.js` runs with no loader and no flag.
|
|
936
|
+
|
|
937
|
+
```js
|
|
938
|
+
// dist/src/contexts/billing/domain/aggregates/invoice.aggregate.js, as the compiler emits it
|
|
939
|
+
import { InvoiceError } from '@/contexts/billing/domain/errors/invoice.error';
|
|
940
|
+
|
|
941
|
+
// the same line after ddd-ts-kit build-aliases
|
|
942
|
+
import { InvoiceError } from '../errors/invoice.error.js';
|
|
943
|
+
```
|
|
944
|
+
|
|
625
945
|
It reads `importAlias` and `importExtension` of the config: `@/platform/errors` names
|
|
626
946
|
`platform/errors.js` under `none`, `@/platform/errors.js` names it under `js`, and no other
|
|
627
947
|
spelling is guessed.
|
|
@@ -722,6 +1042,53 @@ from `docs/architecture/slices.md`, the published languages it consumes. An exis
|
|
|
722
1042
|
kept and only the file is written again; `--dry-run` creates and writes nothing. It needs a git
|
|
723
1043
|
repository with one commit at least. The worker then generates with `--local-only`.
|
|
724
1044
|
|
|
1045
|
+
## affected
|
|
1046
|
+
|
|
1047
|
+
```
|
|
1048
|
+
ddd-ts-kit affected [--base <git-ref>] [--head <git-ref>] [--format text|json|github|env]
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
What a pull or merge request has to run, read from the context map and the paths that differ
|
|
1052
|
+
between `--base` and `--head` (`HEAD` by default; the merge base is used when the clone has it).
|
|
1053
|
+
Each changed path reaches something:
|
|
1054
|
+
|
|
1055
|
+
- a path under a declared context reaches that context and every context downstream of it, since
|
|
1056
|
+
their code may read its published language; a path under its `test/` alone reaches that
|
|
1057
|
+
context alone, since no other context may import its tests;
|
|
1058
|
+
- a document reaches nothing that runs: a Markdown file at the root, anything under `docs/`,
|
|
1059
|
+
`openapi/` or `.claude/` (the documents gate always runs);
|
|
1060
|
+
- anything else reaches everything: the platform, the shared kernel, `main.ts`, the config, the
|
|
1061
|
+
lockfile, the pipeline, a directory of `src/contexts/` the config does not declare.
|
|
1062
|
+
|
|
1063
|
+
Without `--base`, or when git cannot tell what changed (an unknown ref, a shallow clone without
|
|
1064
|
+
the base), everything is selected: that is what the default branch and the nightly run get. The
|
|
1065
|
+
selection is never narrower than the declared relationships, because `context/declared-relationship`
|
|
1066
|
+
keeps every import between contexts inside them, and that rule runs on the whole project in every
|
|
1067
|
+
pipeline.
|
|
1068
|
+
|
|
1069
|
+

|
|
1070
|
+
|
|
1071
|
+
In the example application, a branch that changes one aggregate of `scheduling`:
|
|
1072
|
+
|
|
1073
|
+
```
|
|
1074
|
+
$ npx ddd-ts-kit affected --base main
|
|
1075
|
+
Affected contexts: scheduling, notification, analytics (the changed contexts and the contexts downstream of them in the context map).
|
|
1076
|
+
Specs: src/contexts/scheduling/**/*.spec.ts src/contexts/notification/**/*.spec.ts src/contexts/analytics/**/*.spec.ts !src/contexts/**
|
|
1077
|
+
Left to the default branch: src/contexts/identity/
|
|
1078
|
+
|
|
1079
|
+
$ npx ddd-ts-kit affected --base main --format github
|
|
1080
|
+
scope=contexts
|
|
1081
|
+
contexts=scheduling,notification,analytics
|
|
1082
|
+
args=--passWithNoTests
|
|
1083
|
+
lint=--ignore-pattern src/contexts/identity/
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
`--format github` writes `key=value` lines for `$GITHUB_OUTPUT`, `--format env` the same keys in
|
|
1087
|
+
upper case behind `AFFECTED_` for a GitLab `dotenv` report, `--json` the selection as data
|
|
1088
|
+
(`{ scope, reason, contexts, specs, leftOut }`). `contexts` is what the test runner reads as
|
|
1089
|
+
`DDD_TS_KIT_AFFECTED`, `args` what a partial run adds to it, `lint` what it adds to ESLint; the
|
|
1090
|
+
generated pipelines wire all three.
|
|
1091
|
+
|
|
725
1092
|
## check
|
|
726
1093
|
|
|
727
1094
|
```
|
|
@@ -732,7 +1099,58 @@ ddd-ts-kit check --commit-size [--staged] [--strict]
|
|
|
732
1099
|
```
|
|
733
1100
|
|
|
734
1101
|
Each violation has a rule id, a severity, a file, a line, a message and a `fix`: one sentence
|
|
735
|
-
saying what to do instead. Exit code 1 when there is at least one error.
|
|
1102
|
+
saying what to do instead. Exit code 1 when there is at least one error. Suppose that the
|
|
1103
|
+
`Reminder` aggregate of the example application read the system clock in its domain: an `isDue()`
|
|
1104
|
+
at line 63 that calls `Date.now()`, where the real one receives the instant as `isDue(asOf: Date)`.
|
|
1105
|
+
`check` would report:
|
|
1106
|
+
|
|
1107
|
+
```
|
|
1108
|
+
$ npx ddd-ts-kit check src/contexts/notification/domain
|
|
1109
|
+
src/contexts/notification/domain/aggregates/reminder.aggregate.ts:64 error purity/no-system-clock
|
|
1110
|
+
Date.now() is used in the domain layer.
|
|
1111
|
+
fix: Receive the instant as a parameter in the domain, and take it from the Clock port (`this.clock.now()`) in a handler, so that a test fixes time with FixedClock.
|
|
1112
|
+
|
|
1113
|
+
1 error, 0 warnings, 0 suppressions in 772 files.
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
The `fix` is the change to make. Instead of that hypothetical method:
|
|
1117
|
+
|
|
1118
|
+
```ts
|
|
1119
|
+
isDue(): boolean {
|
|
1120
|
+
return this.state.status === ReminderStatus.Planned && this.state.dueAt.getTime() <= Date.now();
|
|
1121
|
+
}
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
write:
|
|
1125
|
+
|
|
1126
|
+
<!-- checked sample -->
|
|
1127
|
+
```ts
|
|
1128
|
+
import { Clock, FixedClock } from '@ddd-ts-kit/kernel';
|
|
1129
|
+
|
|
1130
|
+
// domain: the instant is a parameter, as in the Reminder aggregate of the example
|
|
1131
|
+
export class Reminder {
|
|
1132
|
+
constructor(private readonly dueAt: Date) {}
|
|
1133
|
+
|
|
1134
|
+
isDue(asOf: Date): boolean {
|
|
1135
|
+
return this.dueAt.getTime() <= asOf.getTime();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// application: the handler takes the instant from the Clock port
|
|
1140
|
+
export class SendDueRemindersHandler {
|
|
1141
|
+
constructor(private readonly clock: Clock) {}
|
|
1142
|
+
|
|
1143
|
+
isDueNow(reminder: Reminder): boolean {
|
|
1144
|
+
return reminder.isDue(this.clock.now());
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// a test fixes the time
|
|
1149
|
+
const handler = new SendDueRemindersHandler(new FixedClock(new Date('2026-03-02T09:00:00Z')));
|
|
1150
|
+
const due = handler.isDueNow(new Reminder(new Date('2026-03-01T09:00:00Z'))); // true
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
`ddd-ts-kit explain <rule-id>` prints the same kind of bad and good example for every rule.
|
|
736
1154
|
|
|
737
1155
|
- `paths` and `--changed-since` limit what is reported, not what is analysed.
|
|
738
1156
|
`--changed-since` runs `git diff --name-only --relative <ref>...` in the project.
|
|
@@ -783,6 +1201,7 @@ saying what to do instead. Exit code 1 when there is at least one error.
|
|
|
783
1201
|
| `naming/suffix` | a file without the suffix of its role, or not in kebab-case. `application/scheduling/` holds `*.job.ts`; a use case folder accepts `*.result.ts` next to its message and its handler; `presentation/http/<context>.routes.ts`, named after its context instead of a role, is accepted in both styles |
|
|
784
1202
|
| `naming/one-concept-per-file` | several exported classes in an aggregate, entity, value object, event, command, query, handler, event handler, integration event handler or job file; in the declared style, several classes carrying a declaration decorator in one role file |
|
|
785
1203
|
| `naming/search-not-list` | in a context, a method named `list` or `list<Something>` in a port (`domain/repositories/*.repository.ts`, `application/ports/*.port.ts`), in a class of `infrastructure/` or in a fake of `test/fakes/`; under `application/queries/`, a folder or a file named `list`, `list-...` or `list....`, or a class named `List` or `List<Something>`. Properties and `listAccessible` (required by the `Authorizer` port) are not judged. Name them `search...`: `generate query <context> Search<Things> --search` |
|
|
1204
|
+
| `naming/business-name` | in `domain/` and `application/` of a context and in the shared kernel outside `presentation/`, a variable, loop variable, destructured binding, parameter or property named with a generic word (`target`, `data`, `item` and the other words of the list, which `businessNames` of the config changes); the fix names the concept an initializer builds or reads when the text says it, and `npx eslint --fix` renames it |
|
|
786
1205
|
| `test/spec-required` | an aggregate, value object, domain service, handler, domain event handler, integration event handler or job without a spec (a `*.result.ts` needs none) |
|
|
787
1206
|
| `test/no-mocks` | `vi.mock`, `vi.fn`, `vi.spyOn`, their `jest` twins, or a mocking library in a context's tests |
|
|
788
1207
|
| `test/builder-required` | an aggregate without `test/builders/<name>.builder.ts` |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"closing-parenthesis.d.ts","sourceRoot":"","sources":["../../../src/check/business-names/closing-parenthesis.ts"],"names":[],"mappings":"AAEA,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcrE"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BRACKETS } from '../text/brackets.js';
|
|
2
|
+
/** Offset of the `)` that closes a parenthesis opened before `from`, or the end of the text. */
|
|
3
|
+
export function closingParenthesis(text, from) {
|
|
4
|
+
let depth = 0;
|
|
5
|
+
for (let i = from; i < text.length; i += 1) {
|
|
6
|
+
const char = text.charAt(i);
|
|
7
|
+
if (BRACKETS.openers.includes(char)) {
|
|
8
|
+
depth += 1;
|
|
9
|
+
}
|
|
10
|
+
else if (BRACKETS.closers.includes(char) && (depth -= 1) < 0) {
|
|
11
|
+
return i;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return text.length;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=closing-parenthesis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"closing-parenthesis.js","sourceRoot":"","sources":["../../../src/check/business-names/closing-parenthesis.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAE/C,gGAAgG;AAChG,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,IAAY;IAC3D,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,KAAK,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAE5B,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YACpC,KAAK,IAAI,CAAC,CAAC;QACb,CAAC;aAAM,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;YAC/D,OAAO,CAAC,CAAC;QACX,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC,MAAM,CAAC;AACrB,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NamedBinding } from './named-binding.js';
|
|
2
|
+
/** The variables a source declares with `const`, `let` or `var`, loop variables and destructured names included. */
|
|
3
|
+
export declare function declaredBindings(code: string): NamedBinding[];
|
|
4
|
+
//# sourceMappingURL=declared-bindings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"declared-bindings.d.ts","sourceRoot":"","sources":["../../../src/check/business-names/declared-bindings.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQvD,oHAAoH;AACpH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,EAAE,CAkB7D"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { statementEnd } from '../text/statement-end.js';
|
|
2
|
+
import { closingParenthesis } from './closing-parenthesis.js';
|
|
3
|
+
import { identifierAt } from './identifier-at.js';
|
|
4
|
+
import { patternBindings } from './pattern-bindings.js';
|
|
5
|
+
import { topLevelSeparator } from './top-level-separator.js';
|
|
6
|
+
const DECLARATION = /(?<![\w$.])(?:const|let|var)\s+(?=[A-Za-z_$[{])/g;
|
|
7
|
+
const OPENS_A_LOOP = /(?<![\w$.])for\s*(?:await\s*)?\(\s*$/;
|
|
8
|
+
const WALKS = /^\s+(?:of|in)\s+/;
|
|
9
|
+
/** The variables a source declares with `const`, `let` or `var`, loop variables and destructured names included. */
|
|
10
|
+
export function declaredBindings(code) {
|
|
11
|
+
return [...code.matchAll(DECLARATION)].flatMap((match) => {
|
|
12
|
+
const at = match.index + match[0].length;
|
|
13
|
+
const opener = code.charAt(at);
|
|
14
|
+
if (opener === '{' || opener === '[') {
|
|
15
|
+
return patternBindings(code, at);
|
|
16
|
+
}
|
|
17
|
+
const name = identifierAt(code, at);
|
|
18
|
+
const after = at + name.length;
|
|
19
|
+
if (OPENS_A_LOOP.test(code.slice(Math.max(0, match.index - 20), match.index))) {
|
|
20
|
+
return [{ name, offset: at, kind: 'loop variable', source: walkedCollection(code, after) }];
|
|
21
|
+
}
|
|
22
|
+
return [{ name, offset: at, kind: 'variable', source: initializer(code, after) }];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** `due` in `for (const target of due)`. */
|
|
26
|
+
function walkedCollection(code, from) {
|
|
27
|
+
const rest = code.slice(from, from + 400);
|
|
28
|
+
const walks = WALKS.exec(rest);
|
|
29
|
+
if (walks === null) {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
const start = walks[0].length;
|
|
33
|
+
return rest.slice(start, closingParenthesis(rest, start)).trim();
|
|
34
|
+
}
|
|
35
|
+
/** What follows the `=` of a declaration, its type annotation skipped. */
|
|
36
|
+
function initializer(code, from) {
|
|
37
|
+
const declared = code.slice(from, statementEnd(code, from));
|
|
38
|
+
const equals = topLevelSeparator(declared, '=');
|
|
39
|
+
return equals === -1 ? '' : declared.slice(equals + 1).trim();
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=declared-bindings.js.map
|