@cratis/pi 7.2.1 → 7.3.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.md +4 -2
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts +1 -1
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/index.ts +1 -1
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/skills.ts +3 -4
- package/package/corpus/harnesses/pi/extensions/shared/frontmatter.ts +100 -4
- package/package/corpus/harnesses/pi/extensions/shared/skillFrontmatter.ts +27 -5
- package/package/corpus/rules/profiles.md +23 -0
- package/package/corpus/skills/cratis-application-react-specifications/SKILL.md +2 -3
- package/package/corpus/skills/cratis-application-slice-specifications/SKILL.md +2 -3
- package/package/corpus/skills/cratis-arc-command-typescript/LICENSE +2 -0
- package/package/corpus/skills/cratis-arc-command-typescript/SKILL.md +495 -0
- package/package/corpus/skills/cratis-arc-command-typescript/references/chronicle-events.md +141 -0
- package/package/corpus/skills/cratis-arc-command-typescript/references/hosting-and-storage.md +183 -0
- package/package/corpus/skills/cratis-arc-command-typescript/verification.json +15 -0
- package/package/corpus/skills/cratis-arc-query-typescript/LICENSE +2 -0
- package/package/corpus/skills/cratis-arc-query-typescript/SKILL.md +350 -0
- package/package/corpus/skills/cratis-arc-query-typescript/verification.json +14 -0
- package/package/corpus/skills/cratis-arc-validation-typescript/LICENSE +2 -0
- package/package/corpus/skills/cratis-arc-validation-typescript/SKILL.md +320 -0
- package/package/corpus/skills/cratis-arc-validation-typescript/verification.json +14 -0
- package/package/corpus/skills/cratis-documentation-writing/SKILL.md +2 -2
- package/package/corpus/skills/cratis-engineering-docs-authoring/SKILL.md +2 -2
- package/package/corpus/skills/cratis-specifications-csharp/SKILL.md +2 -2
- package/package/corpus/skills/cratis-specifications-typescript/SKILL.md +2 -3
- package/package/corpus/skills/cratis-technical-examples/SKILL.md +2 -2
- package/package/profile-catalog.json +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-arc-command-typescript
|
|
3
|
+
description: Define a Cratis Arc command in a Node.js TypeScript server with Arc for TypeScript — project setup from the source preview, ArcApplication.createBuilder() and discovery, the @command() class with @field inputs, handle() and provide(), outcome helpers, service injection, authorization, returning Chronicle events through withChronicle, generated artifact metadata and proxies, hosting on the standalone host, Express, Fastify, Hono or the Fetch entry, and CommandScenario specs. Use when adding or changing a command in an Arc for TypeScript server, or bootstrapping such a server. Do not use for the .NET or Kotlin/Java Arc command shapes, for a React frontend that calls generated proxies, for validation-only changes, or for query/read-model definition.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Define an Arc command in TypeScript (Node.js server)
|
|
8
|
+
|
|
9
|
+
Arc for TypeScript (`Cratis/Arc.TypeScript`) is the Node.js server implementation
|
|
10
|
+
of Arc. A command is a decorated class that carries the input and handles itself;
|
|
11
|
+
Arc supplies the route, body binding, validation, authorization, and the
|
|
12
|
+
`CommandResult` envelope. There is no controller and no route to write.
|
|
13
|
+
|
|
14
|
+
## Source preview — read this first
|
|
15
|
+
|
|
16
|
+
- **None of the server packages is published to npm.** `@cratis/arc.core`,
|
|
17
|
+
`@cratis/arc.express`, `@cratis/arc.fastify`, `@cratis/arc.hono`,
|
|
18
|
+
`@cratis/arc.testing`, `@cratis/arc.mongodb`, `@cratis/arc.drizzle`,
|
|
19
|
+
`@cratis/arc.chronicle`, `@cratis/cratis`, `@cratis/arc.proxygenerator` and
|
|
20
|
+
`@cratis/eslint-plugin-arc-core` are an unpublished source preview (0.x
|
|
21
|
+
manifests; versions appear only as source-only GitHub pre-releases). The API may still change. `npm install @cratis/arc.core` fails.
|
|
22
|
+
- **`@cratis/arc` is a different package.** `@cratis/arc`, `@cratis/arc.react` and
|
|
23
|
+
`@cratis/arc.react.mvvm` are the published **client** runtime, built from
|
|
24
|
+
`Cratis/Arc`. Generated proxies import them in the frontend. The server packages
|
|
25
|
+
never depend on them. Never tell a user to install `@cratis/arc` to get a
|
|
26
|
+
server.
|
|
27
|
+
- `@cratis/arc.chronicle` and `@cratis/cratis` are additionally marked
|
|
28
|
+
**experimental**.
|
|
29
|
+
|
|
30
|
+
## Verified product sources
|
|
31
|
+
|
|
32
|
+
Verified against `Cratis/Arc.TypeScript` `main` at commit `94d398d` (tag
|
|
33
|
+
`v0.33.0` plus three commits), reading `Source/Core/index.ts` and the package
|
|
34
|
+
`index.ts` exports, `Documentation/**`, and the `Samples/Tasks` and
|
|
35
|
+
`Samples/Library` applications. Every API named here exists at that commit.
|
|
36
|
+
Chronicle SDK: `@cratis/chronicle` peer `^6.7.0`; the Library sample pins
|
|
37
|
+
`6.14.0`. Fundamentals: `@cratis/fundamentals` `^7.19.6`.
|
|
38
|
+
|
|
39
|
+
Host setup, the Fetch entry, and the storage integrations are in
|
|
40
|
+
[references/hosting-and-storage.md](references/hosting-and-storage.md). Returned
|
|
41
|
+
Chronicle events and their specs are in
|
|
42
|
+
[references/chronicle-events.md](references/chronicle-events.md).
|
|
43
|
+
|
|
44
|
+
## Set up a project
|
|
45
|
+
|
|
46
|
+
Consume the preview in one of two ways:
|
|
47
|
+
|
|
48
|
+
| Path | How | Use when |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Inside a clone | Put the application under `Samples/<Name>` in a clone of `Cratis/Arc.TypeScript`; reference packages as `workspace:^` | Experimenting, or changing Arc while building on it |
|
|
51
|
+
| Packed tarballs | In the clone: `yarn install`, `yarn build`, then `yarn workspace @cratis/arc.core pack --out <file>` per package; `npm install <file>.tgz` in your project | A separate repository |
|
|
52
|
+
|
|
53
|
+
Use `yarn pack`, not `npm pack` — Yarn rewrites `workspace:^` dependencies to
|
|
54
|
+
version ranges; npm does not, and the tarball then fails to install. A
|
|
55
|
+
`workspace:^` dependency outside the clone fails at install.
|
|
56
|
+
|
|
57
|
+
- Node.js 22.19 or later (the core runs on 22; the workspace build needs 22.19;
|
|
58
|
+
24 LTS is recommended).
|
|
59
|
+
- ES modules only: `"type": "module"` in `package.json`.
|
|
60
|
+
- Peer dependencies of `@cratis/arc.core`: `@cratis/fundamentals` `^7.19.6`,
|
|
61
|
+
`@opentelemetry/api` `^1.9.0`; `rxjs` `^7.8.2` is an optional peer, needed for
|
|
62
|
+
RxJS observable queries.
|
|
63
|
+
- TypeScript: the repository and proxy generator use TypeScript 6
|
|
64
|
+
(`typescript@npm:@typescript/typescript6@^6.0.2`, command `tsc6`).
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"compilerOptions": {
|
|
69
|
+
"target": "ES2022",
|
|
70
|
+
"module": "NodeNext",
|
|
71
|
+
"moduleResolution": "NodeNext",
|
|
72
|
+
"lib": ["ES2022", "ESNext.Decorators"],
|
|
73
|
+
"types": ["node"],
|
|
74
|
+
"strict": true,
|
|
75
|
+
"verbatimModuleSyntax": true,
|
|
76
|
+
"skipLibCheck": true,
|
|
77
|
+
"rootDir": ".",
|
|
78
|
+
"outDir": "dist"
|
|
79
|
+
},
|
|
80
|
+
"include": ["main.ts", "Features/**/*.ts"]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **No `experimentalDecorators`.** Arc's decorators compile as standard TC39
|
|
85
|
+
decorators, as the samples do; `ESNext.Decorators` declares `Symbol.metadata`.
|
|
86
|
+
Legacy `experimentalDecorators` also work. The Library sample compiles its
|
|
87
|
+
`@cratis/chronicle` 6.14 event types and projections under the same
|
|
88
|
+
standard-decorator settings. Generated frontend proxies are the exception:
|
|
89
|
+
they compile with `experimentalDecorators: true`.
|
|
90
|
+
- With `NodeNext`, relative imports carry a `.js` extension. `ESNext` +
|
|
91
|
+
`Bundler` (the samples' choice) also works.
|
|
92
|
+
- Node's own type stripping does not transform decorators — `node main.ts`
|
|
93
|
+
fails at the first `@`. Run source with `tsx watch main.ts` during
|
|
94
|
+
development and compile with `tsc6` for production.
|
|
95
|
+
- A Vitest/Vite transform must lower standard decorators with
|
|
96
|
+
`useDefineForClassFields: true` (`target: 'es2022'`,
|
|
97
|
+
`experimentalDecorators: false`), or decorators silently do nothing in specs.
|
|
98
|
+
|
|
99
|
+
## Bootstrap the application
|
|
100
|
+
|
|
101
|
+
`Samples/Tasks/main.ts`:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { ArcApplication } from '@cratis/arc.core';
|
|
105
|
+
import { Tasks } from './Features/Tasks/Tasks.js';
|
|
106
|
+
import { metadata } from './Features/generatedMetadata.js';
|
|
107
|
+
|
|
108
|
+
const builder = ArcApplication.createBuilder();
|
|
109
|
+
builder.useGeneratedMetadata(metadata);
|
|
110
|
+
builder.services.addSingleton(Tasks);
|
|
111
|
+
await builder.discover(new URL('./Features/', import.meta.url));
|
|
112
|
+
export const app = await builder.build();
|
|
113
|
+
await app.run({ port: Number(process.env.PORT ?? 3000) });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Call | Effect |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `ArcApplication.createBuilder(options?)` | Starts the application; the Node builder reads `appsettings.json` (`Cratis:Arc`) and `Cratis__...` environment variables |
|
|
119
|
+
| `useGeneratedMetadata(metadata)` | Installs parameter and return-type metadata extracted from source. Call it **before** `discover()`/`add()` |
|
|
120
|
+
| `services.addSingleton(Token)` | Registers a service; `addScoped`/`addTransient` take factories. Classes can also carry `@singleton()`, `@scoped()`, `@transient()` |
|
|
121
|
+
| `discover(folderUrl)` | Imports every module under the folder and registers **exported**, decorated artifacts. Skips `dist`, `node_modules`, `given`, `for_*`, `index.*`, declaration files |
|
|
122
|
+
| `add(...types)` | Registers artifacts explicitly — required on the Fetch entry, which cannot discover |
|
|
123
|
+
| `build()` | Validates the whole graph (missing services, lifetime mismatches, misplaced decorators, stale metadata) and throws before any listener opens |
|
|
124
|
+
| `app.run()` / `app.start()` | Standalone Node host. `app.dispose()` stops it and disposes services |
|
|
125
|
+
|
|
126
|
+
`discover()` refuses a folder that contains the entry point; keep artifacts in a
|
|
127
|
+
dedicated folder such as `Features/`. `Cratis:Arc:Development: true` is for a
|
|
128
|
+
local machine only.
|
|
129
|
+
|
|
130
|
+
## Lay out the slice
|
|
131
|
+
|
|
132
|
+
House convention (not a runtime requirement): one folder per behavior, one
|
|
133
|
+
TypeScript file named for the behavior holding the command, its validator and —
|
|
134
|
+
for Chronicle — its events. Concepts shared by several slices live one level up.
|
|
135
|
+
Specs sit beside the slice in `for_<Subject>/when_<action>/<case>.ts`, which
|
|
136
|
+
`discover()` skips.
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
Features/Tasks/
|
|
140
|
+
├── TaskId.ts
|
|
141
|
+
├── TaskTitle.ts
|
|
142
|
+
├── Tasks.ts
|
|
143
|
+
├── Registration/
|
|
144
|
+
│ ├── Registration.ts
|
|
145
|
+
│ └── for_RegisterTask/when_registering/with_valid_title.ts
|
|
146
|
+
└── Listing/
|
|
147
|
+
└── Listing.ts
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Declare the command
|
|
151
|
+
|
|
152
|
+
`Samples/Tasks/Features/Tasks/Registration/Registration.ts`:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { field } from '@cratis/fundamentals';
|
|
156
|
+
import { command, CommandValidator, validator } from '@cratis/arc.core';
|
|
157
|
+
import { TaskId } from '../TaskId.js';
|
|
158
|
+
import { TaskTitle } from '../TaskTitle.js';
|
|
159
|
+
import { Tasks } from '../Tasks.js';
|
|
160
|
+
|
|
161
|
+
/** Register a task. */
|
|
162
|
+
@command()
|
|
163
|
+
export class RegisterTask {
|
|
164
|
+
@field(TaskId) id!: TaskId;
|
|
165
|
+
@field(TaskTitle) title!: TaskTitle;
|
|
166
|
+
|
|
167
|
+
handle(tasks: Tasks): TaskId {
|
|
168
|
+
tasks.register(this.id, this.title);
|
|
169
|
+
return this.id;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@validator(RegisterTask)
|
|
174
|
+
export class RegisterTaskValidator extends CommandValidator<RegisterTask> {
|
|
175
|
+
constructor() {
|
|
176
|
+
super();
|
|
177
|
+
this.ruleFor(command => command.title).notEmpty().withMessage('A title is required');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Rules Arc enforces:
|
|
183
|
+
|
|
184
|
+
- `@command()` from `@cratis/arc.core` marks the class; it needs a public
|
|
185
|
+
instance `handle()` (which may be inherited). Name it as the action —
|
|
186
|
+
`RegisterTask`, not `RegisterTaskCommand`.
|
|
187
|
+
- `@field(Type)` comes from **`@cratis/fundamentals`**, not Arc. Every field is
|
|
188
|
+
required unless marked `@optional()`, `@nullable()`, or `@defaultValue(value)`
|
|
189
|
+
(from `@cratis/arc.core`). `@enumeration(EnumObject)` restricts a scalar field.
|
|
190
|
+
Arrays: `@field(Array, { genericArguments: [Item] })`.
|
|
191
|
+
- Domain values are `ConceptAs<T>` classes with a static `valueType`
|
|
192
|
+
(`static readonly valueType = Guid;`) because generics are erased at runtime.
|
|
193
|
+
A concept travels as its primitive on the wire.
|
|
194
|
+
- The artifact must be **exported** from a module under the discovery root, or
|
|
195
|
+
it is never registered.
|
|
196
|
+
- `handle()` returns a value or a promise; the value becomes `response`.
|
|
197
|
+
Returning nothing is success without a `response`.
|
|
198
|
+
|
|
199
|
+
### The command key
|
|
200
|
+
|
|
201
|
+
The key identifies what the command is about. It is resolved from, in order: a
|
|
202
|
+
registered `CommandKeyResolver`, a `getKey()` method, then the `@key()` field
|
|
203
|
+
(a concept unwraps to its primitive). **No key is inferred from an unmarked `id`
|
|
204
|
+
field.** With Chronicle, the key is the default event source.
|
|
205
|
+
|
|
206
|
+
### Route
|
|
207
|
+
|
|
208
|
+
`POST /api/<discovery-namespace>/<command-name>` in kebab case —
|
|
209
|
+
`Tasks.Registration.RegisterTask` serves `POST /api/tasks/registration/register-task`.
|
|
210
|
+
`POST <route>/validate` runs authorization and validation only, never
|
|
211
|
+
`provide()` or `handle()`. Pin a route across folder moves with
|
|
212
|
+
`@command({ namespace: 'Tasks.Registration' })` or `@path('/api/...')`, and keep
|
|
213
|
+
class names in bundled builds (`keepNames` in esbuild).
|
|
214
|
+
|
|
215
|
+
## Bind parameters
|
|
216
|
+
|
|
217
|
+
TypeScript erases parameter types, so Arc learns them one of two ways:
|
|
218
|
+
|
|
219
|
+
- **Generated artifact metadata** (the samples): `arc-proxygenerator --metadata`
|
|
220
|
+
writes a module from source; `handle(tasks: Tasks)` then needs no decorator.
|
|
221
|
+
- **Explicit tokens**: `@inject(Tasks)` on `handle()` or `provide()`, one token
|
|
222
|
+
or marker per parameter, in order.
|
|
223
|
+
|
|
224
|
+
Built-in markers for `@inject(...)`:
|
|
225
|
+
|
|
226
|
+
| Marker | Binds |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `abortSignal()` | The request's `AbortSignal` — pass it to cancelable I/O |
|
|
229
|
+
| `commandContext()` | The `CommandContext`: `key`, `values`, `correlationId`, `principal`, `tenantId`, `signal` |
|
|
230
|
+
| `provided(Type)` | A value from `provide()`, matched by runtime type |
|
|
231
|
+
| `commandReadModel(Type, { optional? })` | The read model for the command key, from a registered resolver (MongoDB, Drizzle, Chronicle) |
|
|
232
|
+
| `mongoCollection(Model)` / `drizzleDatabase()` | Storage handles from the optional integrations |
|
|
233
|
+
|
|
234
|
+
`build()` fails with `Unbound handle parameters on <Type>.handle` when neither
|
|
235
|
+
metadata nor tokens cover a parameter. Default and rest parameters always need
|
|
236
|
+
explicit tokens.
|
|
237
|
+
|
|
238
|
+
## Prepare data with `provide()`
|
|
239
|
+
|
|
240
|
+
`provide()` is optional. It runs after authorization and validation, before
|
|
241
|
+
`handle()`, on the same instance; its value becomes the **first** `handle()`
|
|
242
|
+
argument, before injected services.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { field } from '@cratis/fundamentals';
|
|
246
|
+
import { command, inject, rejected, validation } from '@cratis/arc.core';
|
|
247
|
+
|
|
248
|
+
@command()
|
|
249
|
+
export class RenameTask {
|
|
250
|
+
@field(TaskId) id!: TaskId;
|
|
251
|
+
@field(TaskTitle) title!: TaskTitle;
|
|
252
|
+
|
|
253
|
+
@inject(Tasks)
|
|
254
|
+
provide(tasks: Tasks) {
|
|
255
|
+
const task = tasks.byId(this.id);
|
|
256
|
+
return task ?? rejected(validation('The task does not exist', ['id'], 'notFound'));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
handle(task: TaskItem): void {
|
|
260
|
+
task.title = this.title;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
For several prepared values, return `tuple(...)` and mark each `handle()`
|
|
266
|
+
parameter with `@inject(provided(A), commandContext(), provided(B))`.
|
|
267
|
+
`@inject` is accepted only on a command's `handle()` or `provide()`;
|
|
268
|
+
authorization decorators on `provide()` fail at build.
|
|
269
|
+
|
|
270
|
+
## Return an outcome
|
|
271
|
+
|
|
272
|
+
| Return from `provide()` or `handle()` | Result |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| A plain value, or `response(value)` | Success; the value is `response` |
|
|
275
|
+
| Nothing | Success without `response` |
|
|
276
|
+
| `rejected(...results)` | 400 with the validation results; later steps do not run |
|
|
277
|
+
| `denied(reason?)` | 403 with `authorizationFailureReason`; later steps do not run |
|
|
278
|
+
| `tuple(a, b, ...)` | At most one client response; every other value must be consumed by a response value handler (Chronicle events, command operations) or the command fails |
|
|
279
|
+
|
|
280
|
+
Outcomes are recognized by origin (a private brand), not shape — an object with
|
|
281
|
+
a `kind` property is ordinary data. `rejected()` with no results throws. A
|
|
282
|
+
thrown error is a 500 whose message is redacted unless `exposeExceptionDetails`
|
|
283
|
+
is enabled; do not throw for a rejection the user can act on. Rules that can
|
|
284
|
+
run before `handle()` belong in validators: see
|
|
285
|
+
`cratis-arc-validation-typescript`.
|
|
286
|
+
|
|
287
|
+
## Protect the command
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { authorize, command, roles } from '@cratis/arc.core';
|
|
291
|
+
|
|
292
|
+
@command()
|
|
293
|
+
@roles('Librarian')
|
|
294
|
+
export class RegisterAuthor { /* ... */ }
|
|
295
|
+
|
|
296
|
+
@command()
|
|
297
|
+
@authorize({ policy: 'Finance' })
|
|
298
|
+
export class ApproveBudget { /* ... */ }
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
- No decorator allows everyone, including anonymous callers.
|
|
302
|
+
- `@authorize()` requires an authenticated caller; `@authorize({ policy?, roles?, schemes? })`
|
|
303
|
+
a policy, a role, or a scheme; `@roles('A', 'B')` any listed role;
|
|
304
|
+
`@allowAnonymous()` states anonymous access explicitly.
|
|
305
|
+
- Stacked decorators must **all** pass. `@allowAnonymous()` with an authenticated
|
|
306
|
+
requirement on the same class fails at startup.
|
|
307
|
+
- Authorization belongs on the **class**. Decorators on `handle()`, `provide()`
|
|
308
|
+
or any other command method fail the build (a deliberate difference from Arc
|
|
309
|
+
on .NET, which ignores them).
|
|
310
|
+
- A decision that needs loaded data returns `denied(reason)` from `provide()`.
|
|
311
|
+
|
|
312
|
+
## Return Chronicle events
|
|
313
|
+
|
|
314
|
+
With `@cratis/arc.chronicle` registered (`withChronicle`), `handle()` returns the
|
|
315
|
+
event and Arc appends it after authorization, validation and `provide()` pass,
|
|
316
|
+
in the namespace of the resolved tenant. Arc does not require event sourcing: a
|
|
317
|
+
command without Chronicle calls a service, as `RegisterTask` does.
|
|
318
|
+
|
|
319
|
+
`Samples/Library/Features/Authors/Registration/Registration.ts`:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
import { field } from '@cratis/fundamentals';
|
|
323
|
+
import { eventType } from '@cratis/chronicle/events';
|
|
324
|
+
import { command, key, roles } from '@cratis/arc.core';
|
|
325
|
+
import { AuthorId } from '../AuthorId.js';
|
|
326
|
+
import { AuthorName } from '../AuthorName.js';
|
|
327
|
+
|
|
328
|
+
@eventType()
|
|
329
|
+
export class AuthorRegistered {
|
|
330
|
+
@field(AuthorName) name: AuthorName;
|
|
331
|
+
constructor(name: AuthorName = new AuthorName('')) { this.name = name; }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@command()
|
|
335
|
+
@roles('Librarian')
|
|
336
|
+
export class RegisterAuthor {
|
|
337
|
+
@key() @field(AuthorId) id!: AuthorId;
|
|
338
|
+
@field(AuthorName) name!: AuthorName;
|
|
339
|
+
|
|
340
|
+
handle(): AuthorRegistered { return new AuthorRegistered(this.name); }
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- The event class lives in the slice file beside the command, carries
|
|
345
|
+
`@eventType()` from `@cratis/chronicle/events` and `@field` declarations, and
|
|
346
|
+
is created through its **constructor** in `handle()` — the house pattern in
|
|
347
|
+
both samples.
|
|
348
|
+
- `@key()` names the event source; without a key or `getEventSourceId()`, each
|
|
349
|
+
execution gets a new UUID.
|
|
350
|
+
- A returned event is consumed on the server, so the result carries no
|
|
351
|
+
`response`.
|
|
352
|
+
- Never append through the SDK inside `handle()` — that append is outside the
|
|
353
|
+
command's batch.
|
|
354
|
+
|
|
355
|
+
Returning several events, events beside a response, cross-stream targets,
|
|
356
|
+
routing decorators, concurrency, and the in-memory `ChronicleCommandScenario`
|
|
357
|
+
are in [references/chronicle-events.md](references/chronicle-events.md).
|
|
358
|
+
|
|
359
|
+
## Generate metadata and proxies
|
|
360
|
+
|
|
361
|
+
`arc-proxygenerator` (package `@cratis/arc.proxygenerator`, also unpublished)
|
|
362
|
+
reads the TypeScript source with the compiler API — it never queries a running
|
|
363
|
+
server. It writes server metadata and client proxies in one run. The CLI
|
|
364
|
+
rejects relative paths, and the output folder must exist:
|
|
365
|
+
|
|
366
|
+
```javascript
|
|
367
|
+
import { spawnSync } from 'node:child_process';
|
|
368
|
+
import { mkdirSync } from 'node:fs';
|
|
369
|
+
import process from 'node:process';
|
|
370
|
+
import { fileURLToPath } from 'node:url';
|
|
371
|
+
|
|
372
|
+
const path = relative => fileURLToPath(new URL(relative, import.meta.url));
|
|
373
|
+
const cli = fileURLToPath(new URL('./cli.js', import.meta.resolve('@cratis/arc.proxygenerator')));
|
|
374
|
+
mkdirSync(path('./generated'), { recursive: true });
|
|
375
|
+
const result = spawnSync(process.execPath, [cli,
|
|
376
|
+
'--project', path('./tsconfig.json'),
|
|
377
|
+
'--artifacts', path('./Features'),
|
|
378
|
+
'--output', path('./generated'),
|
|
379
|
+
'--metadata', path('./Features/generatedMetadata.ts'),
|
|
380
|
+
...process.argv.slice(2)], { stdio: 'inherit' });
|
|
381
|
+
process.exitCode = result.status ?? 1;
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- Commit the metadata module; never edit it. Regenerate after **every** change
|
|
385
|
+
to a command, read model or validator — `useGeneratedMetadata` throws
|
|
386
|
+
`Stale generated artifact metadata for <Type>` otherwise. Reordering
|
|
387
|
+
parameters without changing their count goes undetected, so gate CI with
|
|
388
|
+
`--check-metadata` and keep `--watch` running beside `tsx` while developing.
|
|
389
|
+
- `--artifacts` must be the folder passed to `discover()`. Match route options
|
|
390
|
+
(`--api-prefix`, `--segments-to-skip`, `--root-namespace`) to the server's
|
|
391
|
+
`generatedApis` options, or proxies call URLs the server does not serve.
|
|
392
|
+
- Generated proxies import the **published** `@cratis/arc` and
|
|
393
|
+
`@cratis/arc.react` (the docs pin `22.19.1`) plus `@cratis/fundamentals`.
|
|
394
|
+
Compile them in `Bundler` resolution with `experimentalDecorators: true`, and
|
|
395
|
+
import `reflect-metadata` once in the frontend entry. Consuming the proxies in
|
|
396
|
+
React is `cratis-arc-react-page`.
|
|
397
|
+
|
|
398
|
+
## Host the application
|
|
399
|
+
|
|
400
|
+
`app.run()` is the standalone Node host. To mount the same built application in
|
|
401
|
+
Express 5, Fastify 5, Hono 4, or a Fetch API runtime, keep the builder and swap
|
|
402
|
+
the host — see
|
|
403
|
+
[references/hosting-and-storage.md](references/hosting-and-storage.md). Mount
|
|
404
|
+
Express's `cratisArc(arc)` **before** any body parser, or every command answers
|
|
405
|
+
400 `malformedRequest`.
|
|
406
|
+
|
|
407
|
+
## Specify the command
|
|
408
|
+
|
|
409
|
+
`@cratis/arc.testing` runs a command through the real pipeline in-process —
|
|
410
|
+
binding, authorization, validators, services, `provide()`, `handle()` — without
|
|
411
|
+
a listener. From `Samples/Tasks`:
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
import { CommandScenario } from '@cratis/arc.testing';
|
|
415
|
+
import { Tasks } from '../../../Tasks.js';
|
|
416
|
+
import { RegisterTask, RegisterTaskValidator } from '../../Registration.js';
|
|
417
|
+
import { metadata } from '../../../../generatedMetadata.js';
|
|
418
|
+
|
|
419
|
+
export class a_task_registration {
|
|
420
|
+
tasks = new Tasks();
|
|
421
|
+
scenario = CommandScenario.for(RegisterTask, RegisterTaskValidator);
|
|
422
|
+
|
|
423
|
+
constructor() {
|
|
424
|
+
this.scenario.extend(builder => builder.useGeneratedMetadata(metadata));
|
|
425
|
+
this.scenario.services.addSingleton(Tasks, this.tasks);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import { given, type ScenarioCommandResult } from '@cratis/arc.testing';
|
|
432
|
+
import { TaskId } from '../../../TaskId.js';
|
|
433
|
+
import { TaskTitle } from '../../../TaskTitle.js';
|
|
434
|
+
import { a_task_registration } from '../given/a_task_registration.js';
|
|
435
|
+
|
|
436
|
+
describe('when registering a task with a valid title', given(a_task_registration, context => {
|
|
437
|
+
const id = TaskId.create();
|
|
438
|
+
let result: ScenarioCommandResult;
|
|
439
|
+
|
|
440
|
+
beforeAll(async () => {
|
|
441
|
+
result = await context.scenario.execute({ id, title: new TaskTitle('Plan release') });
|
|
442
|
+
});
|
|
443
|
+
afterAll(async () => { await context.scenario.dispose(); });
|
|
444
|
+
|
|
445
|
+
it('should succeed through the command pipeline', () => { result.shouldBeSuccessful(); });
|
|
446
|
+
}));
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
- `CommandScenario.for(Command, ...artifacts)` — pass the validators and other
|
|
450
|
+
artifacts the command needs; it cannot discover classes that were never
|
|
451
|
+
imported.
|
|
452
|
+
- Register fakes on `scenario.services` **before** the first call.
|
|
453
|
+
`extend(builder => ...)` installs builder setup such as generated metadata.
|
|
454
|
+
- `execute(values)` runs everything; `validate(values)` stops before
|
|
455
|
+
`provide()`/`handle()`. `withContext({ principal, tenantId, correlationId })`
|
|
456
|
+
sets a trusted caller.
|
|
457
|
+
- `given(Context, ...)` creates **one** context per `describe`: act in
|
|
458
|
+
`beforeAll`, dispose in `afterAll`. A disposed scenario cannot run again.
|
|
459
|
+
- Assertions on the result: `shouldBeSuccessful()`, `shouldNotBeSuccessful()`,
|
|
460
|
+
`shouldBeValid()`, `shouldHaveValidationErrors()`,
|
|
461
|
+
`shouldHaveValidationErrorForMember(member)`, `shouldHaveValidationErrorFor(text)`,
|
|
462
|
+
`shouldHaveValidationErrorBecauseOf(reason)`, `shouldBeAuthorized()`,
|
|
463
|
+
`shouldNotBeAuthorized()`, `shouldHaveExceptions()`, `shouldNotHaveExceptions()`.
|
|
464
|
+
- Specify authorization for three callers — anonymous, without the role, with
|
|
465
|
+
it — and on `validate()` too.
|
|
466
|
+
|
|
467
|
+
Folder and naming conventions for specs are `cratis-specifications-typescript`.
|
|
468
|
+
|
|
469
|
+
## Route near misses
|
|
470
|
+
|
|
471
|
+
- A rejection rule on an existing command: `cratis-arc-validation-typescript`.
|
|
472
|
+
- A `@readModel()` query or observable query: `cratis-arc-query-typescript`.
|
|
473
|
+
- A React page that calls the generated proxies: `cratis-arc-react-page`.
|
|
474
|
+
- Talking to Chronicle outside Arc (a worker, direct append, reactors,
|
|
475
|
+
projections): `cratis-chronicle-client-typescript`.
|
|
476
|
+
- The .NET command shape: `cratis-arc-command`; Kotlin/Java:
|
|
477
|
+
`cratis-arc-command-kotlin`.
|
|
478
|
+
|
|
479
|
+
## Verify
|
|
480
|
+
|
|
481
|
+
- The command class is exported under the discovery root, carries `@command()`,
|
|
482
|
+
has a public instance `handle()`, and carries authorization only on the class
|
|
483
|
+
— never on `handle()`, `provide()` or another method.
|
|
484
|
+
- Every input field has `@field(Type)` from `@cratis/fundamentals`; domain values
|
|
485
|
+
are concepts with a static `valueType`.
|
|
486
|
+
- The key is explicit (`@key()`, `getKey()`, or a resolver) wherever something
|
|
487
|
+
depends on it — never an assumed `id`.
|
|
488
|
+
- Rejections the user can act on are `rejected(...)`/`denied(...)` or validator
|
|
489
|
+
results, never thrown errors.
|
|
490
|
+
- Generated metadata is regenerated and `--check-metadata` passes; `build()`
|
|
491
|
+
succeeds at startup.
|
|
492
|
+
- The command's `CommandScenario` specs pass, including the unauthorized and
|
|
493
|
+
`/validate` cases.
|
|
494
|
+
- Guidance never claims the server packages are on npm, and never substitutes
|
|
495
|
+
`@cratis/arc` (the client) for `@cratis/arc.core` (the server).
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Returning Chronicle events from an Arc for TypeScript command
|
|
2
|
+
|
|
3
|
+
Verified against `Cratis/Arc.TypeScript` `main` at commit `94d398d` (tag
|
|
4
|
+
`v0.33.0` plus three commits): `Documentation/chronicle/**`,
|
|
5
|
+
`Documentation/testing/chronicle.md`, `Source/Chronicle/index.ts`, and
|
|
6
|
+
`Samples/Library`. `@cratis/arc.chronicle` is **experimental** and, like every
|
|
7
|
+
package in the repository, not published to npm. `@cratis/chronicle` (the SDK)
|
|
8
|
+
is published; the integration peers `^6.7.0`.
|
|
9
|
+
|
|
10
|
+
## Register the integration
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { ArcApplication } from '@cratis/arc.core';
|
|
14
|
+
import '@cratis/arc.chronicle';
|
|
15
|
+
import { metadata } from './Features/generatedMetadata.js';
|
|
16
|
+
|
|
17
|
+
const builder = ArcApplication.createBuilder();
|
|
18
|
+
builder.useGeneratedMetadata(metadata);
|
|
19
|
+
builder.withChronicle({ connectionString: 'chronicle://localhost:35000', eventStore: 'MyArcApp' });
|
|
20
|
+
await builder.discover(new URL('./Features/', import.meta.url));
|
|
21
|
+
const app = await builder.build();
|
|
22
|
+
await app.run({ port: 3000 });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The Library sample uses the equivalent function form,
|
|
26
|
+
`withChronicle(builder, options)`. Discovery hands every discovered event type,
|
|
27
|
+
projection, reducer, reactor and constraint to the Arc-owned client; they must be
|
|
28
|
+
**exported** beneath the discovery root. You do not import `reflect-metadata` for
|
|
29
|
+
the SDK — it imports it itself.
|
|
30
|
+
|
|
31
|
+
## What `handle()` may return
|
|
32
|
+
|
|
33
|
+
| `handle()` returns | Result |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| A registered event instance | Appended to the command's event source |
|
|
36
|
+
| An array of registered events | Appended in one batch |
|
|
37
|
+
| An array of ordinary objects | An ordinary response, not events |
|
|
38
|
+
| `tuple(event, 'message')` | The event is appended to the command key; `'message'` is the response |
|
|
39
|
+
| `tuple(eventSourceIdResponse(id), event)` | The event is appended to `id`, and `id` is the response |
|
|
40
|
+
| `eventForEventSourceId({ eventSourceId, event, eventSourceType?, subject?, tags? })` | Appended to that event source with explicit routing |
|
|
41
|
+
| `eventsWithConcurrencyScopes(events, scopes)` | Appended with exact per-source concurrency scopes |
|
|
42
|
+
| `tuple(event, operation)` | The command operation runs first; the event batch then decides compensation (compensated on a known append rejection, not on an unknown outcome) |
|
|
43
|
+
|
|
44
|
+
`tuple` comes from `@cratis/arc.core`; `eventSourceIdResponse`,
|
|
45
|
+
`eventForEventSourceId`, and `eventsWithConcurrencyScopes` from
|
|
46
|
+
`@cratis/arc.chronicle`. The wrappers are branded, so a DTO that happens to have
|
|
47
|
+
`event` and `eventSourceId` fields is never mistaken for one.
|
|
48
|
+
|
|
49
|
+
## The event source
|
|
50
|
+
|
|
51
|
+
| The command declares | Event source ID |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `getEventSourceId()` | Its result (string, `Guid`, or a concept over either); takes precedence |
|
|
54
|
+
| A `@key()` field | That field's value |
|
|
55
|
+
| Neither | A new UUID per execution |
|
|
56
|
+
|
|
57
|
+
## Routing defaults and concurrency
|
|
58
|
+
|
|
59
|
+
Class decorators from `@cratis/arc.chronicle` set defaults for every returned
|
|
60
|
+
event:
|
|
61
|
+
|
|
62
|
+
| Decorator | Sets |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `@eventSourceType('Book', { concurrency? })` | Event source type |
|
|
65
|
+
| `@eventStreamType('Lending', { concurrency? })` | Event stream type |
|
|
66
|
+
| `@eventStreamId('main', { concurrency? })` | Event stream ID |
|
|
67
|
+
| `@eventSubject('subject')` | Compliance subject |
|
|
68
|
+
| `@notAudited()` (on a command **field**) | Keeps the value out of the permanent causation chain |
|
|
69
|
+
|
|
70
|
+
`{ concurrency: true }` reads that dimension's tail **after** `handle()` and
|
|
71
|
+
before the append. It does not protect a decision made from state read earlier.
|
|
72
|
+
For "create once", return `eventsWithConcurrencyScopes([event], { [this.id]:
|
|
73
|
+
{ eventSourceId: true, sequenceNumber: EventSequenceNumber.beforeFirst.value } })`
|
|
74
|
+
(`EventSequenceNumber` from `@cratis/chronicle/eventSequences`). For a decision
|
|
75
|
+
from the source's full history, use a command aggregate (`commandAggregate`).
|
|
76
|
+
For a rule across sources, such as a unique name, use a Chronicle constraint in
|
|
77
|
+
the slice file (`@constraint()` from `@cratis/chronicle/events`, as the Library
|
|
78
|
+
sample's `UniqueAuthorName` does).
|
|
79
|
+
|
|
80
|
+
## Rejections and the batch
|
|
81
|
+
|
|
82
|
+
- A constraint or concurrency rejection answers 400 with a validation result
|
|
83
|
+
(`reason` `constraintViolation` or `concurrencyViolation`) and appends nothing.
|
|
84
|
+
- Events returned by nested Arc commands that share tenant, correlation ID and
|
|
85
|
+
event store join the outer command's batch, sent once after the outer command
|
|
86
|
+
succeeds. Any failure discards every staged event.
|
|
87
|
+
- An unknown, partial or incomplete acknowledgment fails the command with an
|
|
88
|
+
exception, because Arc cannot tell what was stored.
|
|
89
|
+
- An SDK `eventLog.append(...)` inside `handle()` is outside the batch. Return
|
|
90
|
+
events instead.
|
|
91
|
+
- Appending and projecting are separate: a query sent right after the command
|
|
92
|
+
may not see the event yet. Prefer an observable query on the client.
|
|
93
|
+
`completionTimeoutMs` makes the command wait for observers, but Chronicle waits
|
|
94
|
+
for **every** observer on the log (Cratis/Chronicle#4132), so a projection or
|
|
95
|
+
reactor that does not handle the event makes every command time out with 500.
|
|
96
|
+
|
|
97
|
+
## Read current state in the command
|
|
98
|
+
|
|
99
|
+
`@inject(commandReadModel(Book))` hands `handle()` the Chronicle read model
|
|
100
|
+
projected for the command key; a missing model rejects the command unless
|
|
101
|
+
`{ optional: true }`, which passes `null`. `ChronicleReadModels` (via
|
|
102
|
+
`service(ChronicleReadModels)` or `@inject`) reads other instances. The
|
|
103
|
+
validation-side equivalent is `readModelForValidation(Type)`.
|
|
104
|
+
|
|
105
|
+
## Reactors that return commands
|
|
106
|
+
|
|
107
|
+
A Chronicle reactor (from `@cratis/chronicle/reactors`) may return an Arc
|
|
108
|
+
`@command()` instance, or a non-empty array of only commands; Arc runs each
|
|
109
|
+
through the full command pipeline. Handlers are matched by camelCase event-class
|
|
110
|
+
method name, receive parsed JSON content (not a class instance), and run on
|
|
111
|
+
replay by default since SDK 6.9.0 — mark effects `@onceOnly()`, and keep returned
|
|
112
|
+
commands safe to repeat after a failed-partition re-delivery. Reactor authoring
|
|
113
|
+
itself is `cratis-chronicle-client-typescript`.
|
|
114
|
+
|
|
115
|
+
## Specify it without a kernel
|
|
116
|
+
|
|
117
|
+
`ChronicleCommandScenario` from `@cratis/arc.chronicle/testing` runs the real
|
|
118
|
+
pipeline with an in-memory event log:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing';
|
|
122
|
+
|
|
123
|
+
const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book);
|
|
124
|
+
scenario.givenReadModel(Book, bookId.toString(), book);
|
|
125
|
+
const result = await scenario.execute({ bookId, member: 'member-1', days: 14 });
|
|
126
|
+
result.shouldBeSuccessful();
|
|
127
|
+
result.appendedEvents.should.have.lengthOf(2);
|
|
128
|
+
result.shouldHaveAppendedEvent(BookLent, bookId.toString(), event => event.member === 'member-1');
|
|
129
|
+
await scenario.dispose();
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Pass the command, its event types, validators and read-model types to
|
|
133
|
+
`for(...)`; the in-memory log refuses an unregistered event type.
|
|
134
|
+
- `givenReadModel(Type, id, instance, tenant?)` pins state;
|
|
135
|
+
`scenario.given.forEventSource(id).events(...)` seeds reducer history (SDK
|
|
136
|
+
6.14.0 or later).
|
|
137
|
+
- `shouldHaveAppendedEvent` does not check count or order — assert
|
|
138
|
+
`appendedEvents` when either matters.
|
|
139
|
+
- The in-memory scenario does **not** run projections, aggregates, constraints
|
|
140
|
+
or concurrency checks. `ChronicleKernelScenario` runs against a live kernel at
|
|
141
|
+
`ARC_CHRONICLE_TEST_URL` for those.
|